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:
@@ -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) =>
|
||||
|
||||
Reference in New Issue
Block a user