feat(run,notifications,profiles): stop sending prompts nobody typed, and fix notification + profile delivery
Run console:
- Picking a pipeline no longer pre-writes anything into the prompt box
(`promptPrefill` in `LaneConsolePane.tsx` is gone — it wrote the driving
slash command on a claude lane and a stage checklist on a codex one).
Whatever sits in that box is sent as a real prompt the moment the run
starts, so the box is the user's alone now.
- Resuming a session sends NO prompt at all (`handleStart` in `RunSetup.tsx`):
the resumed session carries its own transcript, and leftover text in the box
fired an extra turn into it.
Notifications:
- `client/src/lib/notify.ts` is now the ONE sender, used by both
`useNotifications` and Settings' test button, and it reports where the
notification landed: `POST /api/push/send` answering `{native:false,pushed:0}`
means it went NOWHERE, so the client falls back to a local notification
instead of counting the 200 as delivered.
- A notification carries the lane that raised it (`/workspace?lane=<id>`), and
Workspace selects that lane on arrival.
Profiles:
- `server/lib/cc-profiles.js` reads and writes the macOS login Keychain item
`Claude Code-credentials`, where the `claude` CLI actually keeps
`claudeAiOauth` on darwin — a switch used to rewrite a file nothing reads.
The file stays the fallback, is kept in step only when it already exists, and
`AGENTHUB_CC_KEYCHAIN=0` pins it (tests set it).
Tests: npm run test:server (1820 pass), client vitest (553 pass).
This commit is contained in:
+3
-4
@@ -583,10 +583,9 @@ journal's mtime, under the guard hook ingestion uses (no `run_id`, only from
|
||||
|
||||
Getting a codex lane's *declared* stage to actually happen is a separate
|
||||
problem — only the session itself can call `agenthub stage`, and unlike a Claude
|
||||
lane it has no slash command to pre-fill. `LaneConsolePane.tsx` pre-fills a
|
||||
codex pane's opening prompt with a plain-text checklist instead, one
|
||||
`agenthub stage <stage> --tool codex` line per node of the lane's own
|
||||
`pipeline_nodes`. As a safety net beyond that opening nudge,
|
||||
lane it has no slash command to pre-fill — and the run console pre-fills
|
||||
nothing into the prompt box any more, for either tool, since anything sitting
|
||||
there is sent as a real prompt on start. What is left is
|
||||
`server/lib/codex-nudge.js`'s `sweepCodexNudges()` runs on an unref'd 60s
|
||||
interval (`DASHBOARD_CODEX_NUDGE_MS`, `0` disables): when a codex lane's fresh
|
||||
`detected_stage` has sat ahead of its declared `stage` for at least two
|
||||
|
||||
@@ -11,6 +11,9 @@ const fs = require("node:fs");
|
||||
|
||||
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-cc-profiles-"));
|
||||
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
|
||||
// File-backed by default, so running this suite on a Mac never reads or rewrites
|
||||
// the developer's real login Keychain. The Keychain path has its own test below.
|
||||
process.env.AGENTHUB_CC_KEYCHAIN = "0";
|
||||
|
||||
const { describe, it, beforeEach, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
@@ -325,6 +328,56 @@ describe("cc-profiles", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("switches the account in the macOS Keychain, where the CLI actually reads it", () => {
|
||||
const cp = require("node:child_process");
|
||||
const realExec = cp.execFileSync;
|
||||
const realPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
|
||||
process.env.AGENTHUB_CC_KEYCHAIN = "1";
|
||||
// A fake `security` binary backed by one string — the keychain item.
|
||||
let item = null;
|
||||
cp.execFileSync = (_bin, args) => {
|
||||
if (args[0] === "find-generic-password") {
|
||||
if (item === null) throw new Error("SecKeychainSearchCopyNext: not found");
|
||||
return item;
|
||||
}
|
||||
if (args[0] === "add-generic-password") {
|
||||
item = args[args.indexOf("-w") + 1];
|
||||
return "";
|
||||
}
|
||||
throw new Error(`unexpected security verb ${args[0]}`);
|
||||
};
|
||||
try {
|
||||
// Logged in as A, Keychain-only: no `.credentials.json` exists at all.
|
||||
login("uuid-a", "a@example.com");
|
||||
fs.rmSync(CREDS, { force: true });
|
||||
item = JSON.stringify({
|
||||
claudeAiOauth: { accessToken: "at-uuid-a", refreshToken: "rt-uuid-a" },
|
||||
});
|
||||
profiles.saveProfile("work");
|
||||
|
||||
login("uuid-b", "b@example.com");
|
||||
fs.rmSync(CREDS, { force: true });
|
||||
item = JSON.stringify({
|
||||
claudeAiOauth: { accessToken: "at-uuid-b", refreshToken: "rt-uuid-b" },
|
||||
});
|
||||
profiles.saveProfile("personal");
|
||||
assert.equal(profiles.listProfiles().items.find((p) => p.active).name, "personal");
|
||||
|
||||
const result = profiles.activateProfile("work");
|
||||
assert.equal(JSON.parse(item).claudeAiOauth.accessToken, "at-uuid-a");
|
||||
assert.ok(
|
||||
result.written.some((w) => w.includes("Keychain")),
|
||||
"the switch must report the Keychain as written"
|
||||
);
|
||||
assert.equal(fs.existsSync(CREDS), false, "no .credentials.json is invented on macOS");
|
||||
} finally {
|
||||
cp.execFileSync = realExec;
|
||||
Object.defineProperty(process, "platform", { value: realPlatform, configurable: true });
|
||||
process.env.AGENTHUB_CC_KEYCHAIN = "0";
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a corrupt live credentials file rather than guessing", () => {
|
||||
fs.mkdirSync(HOME, { recursive: true });
|
||||
fs.writeFileSync(CREDS, "{nope");
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* anything else in the Claude home.
|
||||
*
|
||||
* A profile is exactly the two identity artifacts and nothing more:
|
||||
* - `claudeAiOauth` out of `<CLAUDE_HOME>/.credentials.json`
|
||||
* - `claudeAiOauth` out of `<CLAUDE_HOME>/.credentials.json` — or, on macOS,
|
||||
* out of the login Keychain item the CLI actually reads there
|
||||
* - `oauthAccount` + `userID` out of the `.claude.json` config file(s)
|
||||
* Skills, subagents, commands, memory, plugins, marketplaces, MCP servers,
|
||||
* settings and session history all live elsewhere under CLAUDE_HOME and are
|
||||
@@ -23,10 +24,59 @@
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const cp = require("node:child_process");
|
||||
|
||||
const { getClaudeHome } = require("./claude-home");
|
||||
|
||||
/**
|
||||
* On macOS the `claude` CLI keeps `claudeAiOauth` in the login Keychain, NOT in
|
||||
* `<CLAUDE_HOME>/.credentials.json` — a file there is absent or a stale leftover
|
||||
* from an older build. Reading/writing only the file on a Mac is why a switch
|
||||
* reported success while the CLI kept running as the previous account.
|
||||
*/
|
||||
const KEYCHAIN_SERVICE = "Claude Code-credentials";
|
||||
const KEYCHAIN_LABEL = `macOS Keychain (${KEYCHAIN_SERVICE})`;
|
||||
|
||||
/** Off with AGENTHUB_CC_KEYCHAIN=0 — tests, and anyone pinning the file. */
|
||||
function keychainEnabled() {
|
||||
return process.platform === "darwin" && process.env.AGENTHUB_CC_KEYCHAIN !== "0";
|
||||
}
|
||||
|
||||
function keychainArgs(verb) {
|
||||
return [verb, "-s", KEYCHAIN_SERVICE, "-a", os.userInfo().username];
|
||||
}
|
||||
|
||||
/** The keychain item's JSON, or null when there is none / it is unreadable. */
|
||||
function readKeychain() {
|
||||
try {
|
||||
const out = cp.execFileSync(
|
||||
"/usr/bin/security",
|
||||
[...keychainArgs("find-generic-password"), "-w"],
|
||||
{
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
}
|
||||
);
|
||||
const parsed = JSON.parse(out.trim());
|
||||
return parsed && typeof parsed === "object" ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeKeychain(value) {
|
||||
// ponytail: the secret rides in argv (`security` has no stdin mode), so it is
|
||||
// briefly visible in `ps` on a shared machine. Upgrade path is a native
|
||||
// keychain binding if that ever matters.
|
||||
cp.execFileSync(
|
||||
"/usr/bin/security",
|
||||
[...keychainArgs("add-generic-password"), "-U", "-w", JSON.stringify(value)],
|
||||
{ stdio: "ignore" }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A profile's `name` is its filename, so the charset is deliberately narrow.
|
||||
* It is an internal identifier: what the user reads and edits is `label`, which
|
||||
@@ -84,9 +134,38 @@ function writeJson(file, value) {
|
||||
fs.renameSync(tmp, file);
|
||||
}
|
||||
|
||||
/**
|
||||
* The live credentials blob and where it came from. The Keychain wins on macOS
|
||||
* when it holds an item; everything else (and a Mac whose CLI still uses the
|
||||
* file) falls back to `.credentials.json`.
|
||||
*/
|
||||
function readCredentials() {
|
||||
if (keychainEnabled()) {
|
||||
const kc = readKeychain();
|
||||
if (kc) return { creds: kc, keychain: true };
|
||||
}
|
||||
return { creds: readJson(credentialsPath()), keychain: false };
|
||||
}
|
||||
|
||||
/** Write credentials back to the store they were read from. Returns what was written. */
|
||||
function writeCredentials(creds, keychain) {
|
||||
if (!keychain) {
|
||||
writeJson(credentialsPath(), creds);
|
||||
return [credentialsPath()];
|
||||
}
|
||||
writeKeychain(creds);
|
||||
// A stale file beside the Keychain would still name the old account; keep it
|
||||
// in step when it exists, never create one.
|
||||
if (fs.existsSync(credentialsPath())) {
|
||||
writeJson(credentialsPath(), creds);
|
||||
return [KEYCHAIN_LABEL, credentialsPath()];
|
||||
}
|
||||
return [KEYCHAIN_LABEL];
|
||||
}
|
||||
|
||||
/** The account identity currently on disk. `oauth`/`account` may be null. */
|
||||
function readActiveAuth() {
|
||||
const creds = readJson(credentialsPath());
|
||||
const { creds, keychain } = readCredentials();
|
||||
let account = null;
|
||||
let userID = null;
|
||||
for (const file of configPaths()) {
|
||||
@@ -97,7 +176,7 @@ function readActiveAuth() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return { oauth: (creds && creds.claudeAiOauth) || null, account, userID };
|
||||
return { oauth: (creds && creds.claudeAiOauth) || null, account, userID, keychain };
|
||||
}
|
||||
|
||||
/** Token-free description of an account, safe to send to the browser. */
|
||||
@@ -344,12 +423,12 @@ function activateProfile(name) {
|
||||
}
|
||||
|
||||
const written = [];
|
||||
const creds = readJson(credentialsPath()) || {};
|
||||
const { creds: live, keychain } = readCredentials();
|
||||
const creds = live || {};
|
||||
// Only the Claude account slot moves; `mcpOAuth` holds this machine's MCP
|
||||
// server tokens, which are not part of the account identity.
|
||||
creds.claudeAiOauth = target.credentials;
|
||||
writeJson(credentialsPath(), creds);
|
||||
written.push(credentialsPath());
|
||||
written.push(...writeCredentials(creds, keychain));
|
||||
|
||||
for (const file of configPaths()) {
|
||||
const cfg = readJson(file);
|
||||
|
||||
+9
-1
@@ -82,8 +82,12 @@ function showNativeNotificationIfElectron(title, body) {
|
||||
*
|
||||
* Returns `{ native, pushed, failed }` so the caller can surface what actually
|
||||
* happened in its API response — silent failures stop looking like success.
|
||||
*
|
||||
* @param {string} [url] Same-origin path (e.g. `/workspace?lane=3`) the
|
||||
* service worker's `notificationclick` handler navigates to. Undefined for
|
||||
* notifications with no specific target.
|
||||
*/
|
||||
async function sendPushToAll(db, title, body) {
|
||||
async function sendPushToAll(db, title, body, url) {
|
||||
const native = showNativeNotificationIfElectron(title, body);
|
||||
|
||||
const subscriptions = db.prepare("SELECT * FROM push_subscriptions").all();
|
||||
@@ -102,6 +106,10 @@ async function sendPushToAll(db, title, body) {
|
||||
badge: "/favicon.svg",
|
||||
silent: false,
|
||||
sound: "default",
|
||||
// `data` is the standard NotificationOptions field notificationclick reads
|
||||
// back via `event.notification.data`; omitted entirely when there's no url
|
||||
// so `event.notification.data` stays undefined rather than `{url: undefined}`.
|
||||
...(url ? { data: { url } } : {}),
|
||||
});
|
||||
const results = await Promise.allSettled(
|
||||
subscriptions.map((sub) =>
|
||||
|
||||
@@ -2623,7 +2623,7 @@ const paths = {
|
||||
tags: ["CcConfig"],
|
||||
summary: "Switch the local CLI to another Anthropic account",
|
||||
description:
|
||||
"Makes a saved profile the live account. Swaps EXACTLY two identity artifacts and nothing else: `claudeAiOauth` inside `<CLAUDE_HOME>/.credentials.json`, and `oauthAccount` + `userID` inside the `.claude.json` config file(s) (key-merged in place, and only where the file already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never touched - that is what makes a switch keep the whole workspace and change only the quota. `mcpOAuth` stays put too (machine MCP tokens, not account identity).\n\nThe outgoing account is re-snapshotted into its own profile FIRST, so tokens Claude Code refreshed since its last save are not lost. A switch takes effect for NEW Claude Code sessions only, and a session still running will rewrite the config on exit - close them first.",
|
||||
"Makes a saved profile the live account. Swaps EXACTLY two identity artifacts and nothing else: `claudeAiOauth` inside `<CLAUDE_HOME>/.credentials.json` (on macOS: the login Keychain item `Claude Code-credentials`, which is where the CLI reads it), and `oauthAccount` + `userID` inside the `.claude.json` config file(s) (key-merged in place, and only where the file already exists, so `projects`/`numStartups`/everything else survives). Skills, subagents, commands, memory, plugins, marketplaces, MCP servers and settings are never touched - that is what makes a switch keep the whole workspace and change only the quota. `mcpOAuth` stays put too (machine MCP tokens, not account identity).\n\nThe outgoing account is re-snapshotted into its own profile FIRST, so tokens Claude Code refreshed since its last save are not lost. A switch takes effect for NEW Claude Code sessions only, and a session still running will rewrite the config on exit - close them first.",
|
||||
operationId: "ccConfigActivateProfile",
|
||||
requestBody: {
|
||||
required: true,
|
||||
|
||||
@@ -121,7 +121,7 @@ const schemas = {
|
||||
type: "object",
|
||||
required: ["title", "body"],
|
||||
description:
|
||||
"Notification content to broadcast. Both fields are mandatory; a missing title or body yields a 400. The same title/body is delivered to every reachable surface (native Electron notification + all stored Web Push subscriptions).",
|
||||
"Notification content to broadcast. `title`/`body` are mandatory; a missing one yields a 400. The same content is delivered to every reachable surface (native Electron notification + all stored Web Push subscriptions).",
|
||||
properties: {
|
||||
title: {
|
||||
type: "string",
|
||||
@@ -133,6 +133,12 @@ const schemas = {
|
||||
description: "Notification body text.",
|
||||
example: "Your Claude Code session finished with 3 subagents.",
|
||||
},
|
||||
url: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional same-origin path the Web Push delivery navigates to on click, via the service worker's `notificationclick` handler (`client/public/sw.js`). Ignored by the native Electron leg, which has no click handling. Omit for notifications with no specific target.",
|
||||
example: "/workspace?lane=3",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -168,6 +174,27 @@ const schemas = {
|
||||
"Count of Web Push sends that were rejected. Subscriptions rejected with HTTP 410 (Gone) are deleted from `push_subscriptions` as part of this request.",
|
||||
example: 1,
|
||||
},
|
||||
errors: {
|
||||
type: "array",
|
||||
description:
|
||||
"Why the rejected sends were rejected, straight from the push service (capped at 3). Diagnostic only - it never includes the subscription endpoint, which is a capability URL. Drives the Settings test-notification banner, so a delivery that reached nobody names its reason instead of looking like success.",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
statusCode: {
|
||||
type: "integer",
|
||||
nullable: true,
|
||||
description: "HTTP status the push service answered with, when there was one.",
|
||||
example: 403,
|
||||
},
|
||||
message: {
|
||||
type: "string",
|
||||
description: "Response body or error message, truncated to 200 characters.",
|
||||
example: "UnauthorizedRegistration",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -287,7 +314,7 @@ const paths = {
|
||||
tags: ["Push"],
|
||||
summary: "Broadcast a notification to all surfaces",
|
||||
description:
|
||||
"Dispatches a notification to every reachable surface at once: it fires a native OS notification via Electron's main-process Notification API when the server is hosted inside the desktop app, AND sends an encrypted Web Push delivery to every stored subscription. Both legs run unconditionally so whichever surface the user is on receives the alert — under `npm start` the native leg is a no-op, and under the desktop app the Web Push leg is typically a no-op (Electron has no FCM credentials, so `push_subscriptions` is empty). Subscriptions rejected with HTTP 410 (Gone) are pruned from `push_subscriptions` during the request. The response reports `{ native, pushed, failed }` so the caller can tell a real delivery from a silent no-op. No authentication (local-first). A missing `title` or `body` returns 400; an unexpected dispatch error returns 500.",
|
||||
"Dispatches a notification to every reachable surface at once: it fires a native OS notification via Electron's main-process Notification API when the server is hosted inside the desktop app, AND sends an encrypted Web Push delivery to every stored subscription. Both legs run unconditionally so whichever surface the user is on receives the alert — under `npm start` the native leg is a no-op, and under the desktop app the Web Push leg is typically a no-op (Electron has no FCM credentials, so `push_subscriptions` is empty). Subscriptions rejected with HTTP 410 (Gone) are pruned from `push_subscriptions` during the request. The response reports `{ native, pushed, failed, errors }` so the caller can tell a real delivery from a silent no-op, and can show WHY a rejected delivery failed. No authentication (local-first). A missing `title` or `body` returns 400; an unexpected dispatch error returns 500.",
|
||||
operationId: "pushSend",
|
||||
requestBody: {
|
||||
required: true,
|
||||
|
||||
@@ -34,7 +34,7 @@ router.delete("/subscribe", (req, res) => {
|
||||
});
|
||||
|
||||
router.post("/send", async (req, res) => {
|
||||
const { title, body } = req.body;
|
||||
const { title, body, url } = req.body;
|
||||
if (!title || !body) {
|
||||
return res.status(400).json({ error: { message: "Missing title or body" } });
|
||||
}
|
||||
@@ -43,7 +43,7 @@ router.post("/send", async (req, res) => {
|
||||
// { native: true|false, pushed: <count>, failed: <count> }
|
||||
// so a silent "no subscribers, no Electron host" no-op stops looking like
|
||||
// success on the client side.
|
||||
const result = await sendPushToAll(db, title, body);
|
||||
const result = await sendPushToAll(db, title, body, typeof url === "string" ? url : undefined);
|
||||
res.json({ ok: true, ...result });
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: { message: err.message } });
|
||||
|
||||
Reference in New Issue
Block a user