Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -721,8 +721,8 @@ One design language, DENSITY-PRESERVING (pro tool — compact stays compact). A
- `GET /api/agent/task` / `POST /api/agent/task-progress|task-backlog` — vibespace-task endpoints (Bearer `vsst_`, scoped to the session's live Task-Group belonging via `resolveAgentGroup`: 403 if in 0 groups, 400 if in >1 without `--group`, explicit `--group` must be one it belongs to); all writes flow through TaskGroupManager (TASK.md regen + broadcast); progress notes tagged with the reporting session key. task-backlog (2.122.0; claim model 2.123.0) takes `{add: text, detail?}` (auto-claims for the caller) / `{done|drop|claim|unclaim: <B-xxxx id|1-based OPEN-list index|unique substring>}` / `{show: ref}` (read-only full item, any status — the pasted-id path), stamps addedBy/resolvedBy/claimedBy with the calling session key, returns the open items (the same list GET task's `backlog` field carries — indexes stay consistent). **`/api/agent/task-status` was REMOVED in 2.39.0** (a Task Group has no status; sessions report their own state via `/api/agent/session-status`); **`/api/agent/task-plan` was REMOVED in 2.121.0 with the checklist feature** — the route stays as a 410 responder so old CLI copies (remote hosts) get guidance instead of a confusing 404
- `GET /api/agent-hooks` / `POST /api/agent-hooks/install|uninstall` — hook registration status + one-click repair/remove (Manage Agents dialog); uninstall strips ONLY the vibespace entry
- `POST /api/agent/group-admin` — Task Group ADMIN for designated MANAGER sessions (2.132.0, issue #21; SCOPE + TEACHING 2.152.0): verbs create/update/bind/unbind/list (organize-only; NO delete/orchestration). DOUBLE-GATED, both default-off: setting `agents.allowGroupManagement` + per-session "Group manager" toggle (Session Properties → sessionConfigs[key].groupManager, read server-side from user-state — **the toggle silently never saved until 2.152.0: sidebar-state setSessionConfig's field whitelist dropped `groupManager`, third strike of the 2.43.0 'account' whitelist bug class; keep that list in sync with EVERY per-session config writer**). **Manager scope = ALL groups (2.152.0, user directive):** group-admin verbs were always unscoped; `resolveAgentGroup` additionally lets a manager target ANY group via explicit `--group <id>` on the REGULAR verbs (unknown id → 404 + group-list pointer; non-managers keep belonging enforcement; shared helper `isManagerSession(key)`). **Managers are TAUGHT their powers**: one-shot `<vibespace-group-manager>` block (task-context claude / prompt-context codex, `s._mgrIntroSeen`) + a per-turn reminder clause — before 2.152.0 nothing ever told the agent. contextDir/folders restricted to `agents.groupManagementRoots` (comma prefixes, default ~); EVERY op audited into the group's activity log attributed to the calling session. CLI: `vibespace-task group-list/create/update/bind/unbind`. Tests: scripts/test-group-admin.mjs (incl. cross-group + teaching assertions)
- `GET /api/hosts/:id/agent-tools` / `POST …/agent-tools/install|uninstall` — VibeSpace integration on a HOST (2.129.0): per-tool sha256-vs-local state + remote hook registration + node + keeper files; install = tar-over-stdin + hook-register, uninstall = register `--uninstall` then rm our files only
- `GET /api/accounts` — accounts list (API keys: tail fingerprints only; subscriptions: email/plan/loggedIn) + `subscription` global-login status + importable `cliKey`; `POST /api/accounts` `{name,key}` add API key; `POST /api/accounts/subscription` `{name}` — allocate an isolated creds dir + return `loginCmd` (sets BOTH `CLAUDE_CONFIG_DIR`+`CLAUDE_SECURESTORAGE_CONFIG_DIR`=dir so the OAuth login writes creds+identity into the dir, ~/.claude untouched); `POST /api/accounts/subscription/:id/finalize` — read back identity once the login landed; `POST /api/accounts/import-cli` — capture ~/.claude.json primaryApiKey; `POST /api/accounts/default` `{id|null}`; `PATCH/DELETE /api/accounts/:id` (delete rm's a subscription's dir); broadcasts `accounts-updated`
- `GET /api/hosts/:id/agent-tools` / `POST …/agent-tools/install|uninstall` — VibeSpace integration on a HOST (2.129.0): per-tool sha256-vs-local state + remote hook registration + node + keeper files; install = tar-over-stdin + hook-register, uninstall = register `--uninstall` then rm our files only. `vibespace-claude-subscription-login.mjs` is also a transport helper: a remote Add-subscription terminal receives that one file even when agent-visible Integration is OFF, so a Mac host can capture its own Keychain login without making agent hooks/tools visible.
- `GET /api/accounts` — accounts list (API keys: tail fingerprints only; subscriptions: email/plan/loggedIn; `localOnly` marks macOS Keychain-backed Claude subscriptions) + `subscription` global-login status + importable `cliKey`; `POST /api/accounts` `{name,key}` add API key; `POST /api/accounts/subscription` `{name}` — allocate an isolated creds dir + return a shell-quoted `loginCmd` running `data/bin/vibespace-claude-subscription-login.mjs` (sets BOTH `CLAUDE_CONFIG_DIR`+`CLAUDE_SECURESTORAGE_CONFIG_DIR`=dir so OAuth creds+identity stay isolated and ~/.claude stays untouched; on macOS the helper reads Claude's hashed per-dir Keychain service in the SAME interactive terminal after a successful official login, validates `claudeAiOauth`, then atomically writes only that root field to `.credentials.json` mode 0600 — a launchd server cannot reliably read the interactive Keychain item later; fresh Keychain data replaces stale fallback only after validation, no token enters argv/logs, and a sanitized `.vibespace-login-status.json` lets finalize/remote watchers stop on the exact attempt's success or failure); macOS Keychain shadows are NEVER config-exported or remote-shipped because two copies can fork a rotating refresh token, and their account dirs are NEVER auto-merged/renamed because the Keychain service hash includes the dir path — use an on-host login instead; `POST /api/accounts/subscription/:id/finalize` — read back identity/status once the login landed; `POST /api/accounts/import-cli` — capture ~/.claude.json primaryApiKey; `POST /api/accounts/default` `{id|null}`; `PATCH/DELETE /api/accounts/:id` (delete rm's a subscription's dir); broadcasts `accounts-updated`
- `GET /api/usage` — …also `accounts: {<subId>: {fiveHour, sevenDay, scopedWeekly, name, email, fetchedAt}}` (per-subscription passive usage) + `globalLogin: {email, loggedIn, accountId}` (2.62.0 — the machine's own CLI-login identity; `accountId` = the named claude subscription it IS when an email matches, in which case the server merges `__global__`↔`<subId>` usage NEWEST-WINS both ways so one real account never shows two conflicting values). Usage-popup switcher chips (Auto / CLI login / each named sub; per-device localStorage `vibespace.usageAccount`) pick which account the taskbar pies show; a sub whose creds dir lacks identity gets a "set email…" backfill in Manage Agents (`accounts.setEmail`, PATCH `{email}`) since the email is what enables the link. **CODEX PARITY (2.64.0):** + `codexGlobalLogin` (identity of ~/.codex/auth.json via `accounts.codexGlobalStatus()` — same JWT parse as named cxs accounts) + `codexAccounts: {<cxsId>|'__global_codex__': snapshot+name+email}` — codex rate limits bucketed PER ACCOUNT (`summarizeCodexRateLimits()`: live wrapper meta keyed by session._accountId; rollout-tail fallback attributes via thread-id→session-meta map; email-linked global↔named merge newest-wins; `summarizeCodexRateLimit()` = back-compat overall). Codex popup section gets the same switcher chips (`vibespace.usageAccountCodex`, chips carry `data-be="codex"`); Manage-Agents codex roster shows global email + `= "Name"` link hint + per-account usage donuts (`_acctUsageHtml`, shared with claude — 2.70.0: conic-gradient mini donuts 5h/7d(+scoped 'Fa') matching the taskbar pies, hole punched with ::after in --bg-dialog; replaced the wide label+bar+pct rows) + set-email for API-key-mode logins (no id_token → no email)
- `POST /api/agent/session-status` — agent self-report (Bearer `vsst_` per-session token from spawn env; EXEMPT from cookie auth in auth.middleware — the only cookie-less API besides /dav); `{state, urgency, reason}` / `{clear}` / `{show}`; rekeys `webui:<id>` records once the real backend id exists
- `GET /api/agent/stop-check` — stop-time bookkeeping nudge arbiter (Bearer `vsst_`): {block, reason} — block only when the session's status is >10min stale AND no nudge in 30min (in-memory cooldown); gated by `agents.stopBookkeepingNudge` (default on). Consumers: the claude Stop hook (blocking) + the codex wrapper at turn/completed
Expand Down
231 changes: 231 additions & 0 deletions data/bin/vibespace-claude-subscription-login.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
#!/usr/bin/env node
/**
* Interactive Claude subscription login helper.
*
* Claude Code stores CLAUDE_SECURESTORAGE_CONFIG_DIR credentials in the macOS
* Keychain. A VibeSpace server started by launchd may not share the interactive
* terminal's Keychain authorization, so after the official login succeeds this
* helper copies only claudeAiOauth into that account's normal fallback file.
* The Keychain read happens in the same terminal/security session as login.
*/
import crypto from 'node:crypto';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { execFileSync, spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';

const FALLBACK_ACCOUNT = 'claude-code-user';
const ACCOUNT_RE = /^[a-zA-Z0-9._-]+$/;
export const LOGIN_STATUS_FILE = '.vibespace-login-status.json';

function loginError(code, message) {
const error = new Error(message);
error.loginCode = code;
return error;
}

export function keychainServiceForDir(configDir) {
const suffix = crypto.createHash('sha256')
.update(String(configDir).normalize('NFC'))
.digest('hex')
.slice(0, 8);
return `Claude Code-credentials-${suffix}`;
}

// Keep this in lockstep with Claude Code's secure-storage account selection
// (verified against the native 2.1.220 release; failure is surfaced via the
// sanitized status marker instead of silently polling forever).
export function keychainAccount(env = process.env, userInfo = () => os.userInfo()) {
let account;
try {
account = env.USER || userInfo().username;
} catch {
account = FALLBACK_ACCOUNT;
}
return ACCOUNT_RE.test(account) ? account : FALLBACK_ACCOUNT;
}

export function parseOAuthCredentials(raw) {
let parsed;
try {
parsed = typeof raw === 'string' ? JSON.parse(raw) : raw;
} catch {
throw loginError('invalid-credentials', 'Claude login completed, but its credential data was not valid JSON.');
}
const oauth = parsed?.claudeAiOauth;
if (!oauth || typeof oauth !== 'object' || typeof oauth.accessToken !== 'string' || !oauth.accessToken) {
throw loginError('invalid-credentials', 'Claude login completed, but no OAuth credential was found.');
}
return { claudeAiOauth: oauth };
}

export function readMacOSKeychain(configDir, {
env = process.env,
userInfo = () => os.userInfo(),
execFile = execFileSync,
} = {}) {
const account = keychainAccount(env, userInfo);
const service = keychainServiceForDir(configDir);
let raw;
try {
raw = execFile('/usr/bin/security', [
'find-generic-password',
'-a', account,
'-s', service,
'-w',
], {
encoding: 'utf8',
timeout: 5000,
maxBuffer: 1024 * 1024,
stdio: ['ignore', 'pipe', 'pipe'],
env,
});
} catch {
// Never include security(1)'s stderr: depending on the failure it can
// contain Keychain metadata, and it does not help the user recover.
throw loginError('keychain-read', 'Claude login completed, but VibeSpace could not read the new macOS Keychain entry in this terminal.');
}
return parseOAuthCredentials(String(raw).trim());
}

export function readCredentialsFile(configDir) {
let raw;
try {
raw = fs.readFileSync(path.join(configDir, '.credentials.json'), 'utf8');
} catch {
throw loginError('credentials-missing', 'Claude login completed, but it did not write an OAuth credential file.');
}
return parseOAuthCredentials(raw);
}

function atomicWritePrivate(configDir, name, text, {
fsImpl = fs,
randomBytes = crypto.randomBytes,
} = {}) {
fsImpl.mkdirSync(configDir, { recursive: true, mode: 0o700 });
fsImpl.chmodSync(configDir, 0o700);
const target = path.join(configDir, name);
const tmp = path.join(
configDir,
`${name}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`,
);
let fd;
try {
fd = fsImpl.openSync(tmp, 'wx', 0o600);
fsImpl.writeFileSync(fd, text, 'utf8');
// Set and verify permissions while the old target is still intact. Once
// rename succeeds there are no remaining required operations that can turn
// a committed replacement into a reported failure.
fsImpl.fchmodSync(fd, 0o600);
if ((fsImpl.fstatSync(fd).mode & 0o777) !== 0o600) throw new Error('private mode not applied');
fsImpl.fsyncSync(fd);
fsImpl.closeSync(fd);
fd = undefined;
fsImpl.renameSync(tmp, target);
} catch {
if (fd !== undefined) {
try { fsImpl.closeSync(fd); } catch {}
}
try { fsImpl.unlinkSync(tmp); } catch {}
throw loginError('credential-write', 'Claude login completed, but VibeSpace could not save its isolated credential file.');
}
return target;
}

export function writeCredentialsFile(configDir, credentials, options) {
const payload = parseOAuthCredentials(credentials);
return atomicWritePrivate(configDir, '.credentials.json', JSON.stringify(payload), options);
}

export function writeLoginStatus(configDir, status, options) {
const state = status?.state === 'success'
? 'success'
: status?.state === 'running' ? 'running' : 'error';
const attempt = /^[a-zA-Z0-9._-]{8,80}$/.test(status?.attempt || '')
? status.attempt
: null;
const safe = {
version: 1,
state,
...(state === 'error' ? { code: String(status?.code || 'unknown').slice(0, 40) } : {}),
...(attempt ? { attempt } : {}),
updatedAt: Date.now(),
};
return atomicWritePrivate(path.resolve(configDir), LOGIN_STATUS_FILE, JSON.stringify(safe), options);
}

export function runLogin({
configDir,
claudeCmd,
platform = process.platform,
env = process.env,
spawn = spawnSync,
readKeychain = readMacOSKeychain,
output = (line) => console.log(line),
}) {
const dir = path.resolve(String(configDir || ''));
if (!configDir || !claudeCmd) throw new Error('Usage: --config-dir <path> --claude <path>');

fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
fs.chmodSync(dir, 0o700);
const login = spawn(claudeCmd, ['auth', 'login', '--claudeai'], {
env: {
...env,
CLAUDE_CONFIG_DIR: dir,
CLAUDE_SECURESTORAGE_CONFIG_DIR: dir,
},
stdio: 'inherit',
});
if (login.error) throw loginError('claude-start', 'VibeSpace could not start Claude Code for login.');
if (login.status !== 0) return Number.isInteger(login.status) ? login.status : 1;

if (platform === 'darwin') {
// Capture AFTER every successful login, even when an older fallback file
// exists. A re-login must replace stale credentials with the fresh Keychain
// value; any read/validation failure happens before the atomic rename.
writeCredentialsFile(dir, readKeychain(dir, { env }));
} else {
readCredentialsFile(dir);
fs.chmodSync(path.join(dir, '.credentials.json'), 0o600);
}
output('VibeSpace: subscription credentials saved.');
return 0;
}

function parseArgs(argv) {
let configDir = null;
let claudeCmd = null;
let attempt = null;
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--config-dir') configDir = argv[++i] || null;
else if (argv[i] === '--claude') claudeCmd = argv[++i] || null;
else if (argv[i] === '--attempt') attempt = argv[++i] || null;
else throw new Error('Usage: --config-dir <path> --claude <path>');
}
return { configDir, claudeCmd, attempt };
}

const isMain = process.argv[1]
&& path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
process.umask(0o077);
let args = null;
try {
args = parseArgs(process.argv.slice(2));
try { writeLoginStatus(args.configDir, { state: 'running', attempt: args.attempt }); } catch {}
const code = runLogin(args);
try {
writeLoginStatus(args.configDir, code === 0
? { state: 'success', attempt: args.attempt }
: { state: 'error', code: 'claude-login-exit', attempt: args.attempt });
} catch {}
process.exitCode = code;
} catch (e) {
if (args?.configDir) {
try { writeLoginStatus(args.configDir, { state: 'error', code: e.loginCode || 'unknown', attempt: args.attempt }); } catch {}
}
console.error(`VibeSpace: ${e.message}`);
process.exitCode = 1;
}
}
29 changes: 24 additions & 5 deletions docs/accounts.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,18 @@ not sharing one account with other people or pooling access.
- **Add subscription…** — opens a terminal running the official Claude OAuth
login, scoped to a fresh isolated dir. Sign in with the account you want to
add; your other logins are untouched. VibeSpace reads back the email/plan and
names the account (rename anytime via the pencil).
names the account (rename anytime via the pencil). On macOS, Claude Code
normally saves this isolated login in Keychain. After the official login
succeeds, VibeSpace copies only its `claudeAiOauth` record into the isolated
dir's standard fallback file while the same interactive terminal still has
Keychain access. This is a one-time local copy, not a token refresh or API
call; the dir is mode `0700` and the file is mode `0600`. Because OAuth
refresh tokens rotate and the Keychain/file copies can later diverge, this
macOS fallback stays on that machine: it is not included in config exports
or shipped to another host. Log in on the target host to use the account
there. The Keychain service name also depends on the isolated directory
path, so duplicate macOS subscription records are not auto-merged or renamed;
doing so could make Claude prefer an older Keychain item.
- **Add ChatGPT account…** — same idea via `codex login --device-auth` (a URL +
one-time code, so it works even when your browser is on a different machine
than the server).
Expand Down Expand Up @@ -117,7 +128,10 @@ When a session on a remote host uses a VibeSpace account:
subscription logins to remote hosts"**; VibeSpace then streams the credential
dir over ssh-stdin into a private `0700` dir on the host (per-file
newest-wins, so a token the host refreshed is never overwritten by a stale
copy).
copy). A macOS Keychain-backed login is never shipped even with this option,
because copying its fallback could fork a rotating refresh token. Use **Log
in on \<host\> as this account…** instead; VibeSpace runs the same interactive
login helper on that host and keeps the result there.
- Deleting an account best-effort removes its key file / creds dirs from every
registered host.

Expand Down Expand Up @@ -159,6 +173,11 @@ truth; this document is guidance, not legal advice.
and `data/codex-subs/`. All of these are gitignored — never commit `data/`.
- Credentials ride the process-env channel (or ssh-stdin for remote), never
command-line arguments.
- VibeSpace reads OAuth credentials strictly read-only; token refresh is left
entirely to the CLIs (refresh tokens rotate — an external refresher would
break the login).
- Outside an explicit **Add subscription…** login, VibeSpace treats OAuth
credentials strictly read-only; token refresh is left entirely to the CLIs
(refresh tokens rotate — an external refresher would break the login). The
macOS login helper only snapshots the Keychain result after that interactive
login succeeds, and never calls Anthropic or refreshes a token itself.
- Config export keeps a macOS subscription's account metadata, but deliberately
omits its Keychain-derived OAuth fallback. Re-authenticate that account after
importing on another machine.
Loading