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:
2026-09-21 15:15:22 +07:00
parent 9156781d75
commit 64017c6ca5
29 changed files with 527 additions and 170 deletions
+85 -6
View File
@@ -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);