diff --git a/CLAUDE.md b/CLAUDE.md index d6aa855c..90b271fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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: }` / `{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 ` 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 `` 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: {: {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__`↔`` 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: {|'__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:` 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 diff --git a/data/bin/vibespace-claude-subscription-login.mjs b/data/bin/vibespace-claude-subscription-login.mjs new file mode 100755 index 00000000..578dfd9a --- /dev/null +++ b/data/bin/vibespace-claude-subscription-login.mjs @@ -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 --claude '); + + 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 --claude '); + } + 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; + } +} diff --git a/docs/accounts.md b/docs/accounts.md index bdd0497c..d6a8112b 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -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). @@ -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 \ 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. @@ -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. diff --git a/scripts/test-claude-subscription-login.mjs b/scripts/test-claude-subscription-login.mjs new file mode 100755 index 00000000..5ebe8f9f --- /dev/null +++ b/scripts/test-claude-subscription-login.mjs @@ -0,0 +1,302 @@ +#!/usr/bin/env node +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { execFileSync, spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { + keychainAccount, + keychainServiceForDir, + LOGIN_STATUS_FILE, + parseOAuthCredentials, + readMacOSKeychain, + runLogin, + writeCredentialsFile, + writeLoginStatus, +} from '../data/bin/vibespace-claude-subscription-login.mjs'; + +const require = createRequire(import.meta.url); +const { + buildClaudeSubscriptionLoginCommand, + shellQuote, +} = require('../src/claude-subscription-login'); +const { AccountManager } = require('../src/accounts'); +const { HostManager } = require('../src/hosts'); + +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'vibespace-claude-login-')); +const mode = (file) => fs.statSync(file).mode & 0o777; +const oauth = (accessToken, extra = {}) => ({ + claudeAiOauth: { accessToken, refreshToken: 'test-refresh', ...extra }, +}); + +try { + console.log('— Claude secure-storage naming —'); + assert.equal( + keychainServiceForDir('/Users/alice/Vibe Space/data/subs/sub-0123456789ab'), + 'Claude Code-credentials-9bce6c0b', + ); + assert.equal( + keychainServiceForDir('/tmp/cafe\u0301'), + keychainServiceForDir('/tmp/café'), + 'service hash must use NFC normalization', + ); + assert.equal(keychainAccount({ USER: 'alice_1' }, () => { throw new Error('unused'); }), 'alice_1'); + assert.equal(keychainAccount({ USER: '' }, () => ({ username: 'fallback.user' })), 'fallback.user'); + assert.equal( + keychainAccount({ USER: 'not valid' }, () => ({ username: 'ignored' })), + 'claude-code-user', + 'an invalid USER falls directly back instead of consulting os.userInfo()', + ); + assert.equal(keychainAccount({ USER: '' }, () => { throw new Error('no user'); }), 'claude-code-user'); + + console.log('— scoped Keychain read —'); + let securityCall = null; + const captured = readMacOSKeychain('/Users/alice/Vibe Space/data/subs/sub-0123456789ab', { + env: { USER: 'alice_1' }, + execFile: (cmd, args, options) => { + securityCall = { cmd, args, options }; + return JSON.stringify({ ...oauth('test-access'), mcpOAuth: { token: 'must-not-copy' } }); + }, + }); + assert.deepEqual(captured, oauth('test-access')); + assert.equal(securityCall.cmd, '/usr/bin/security'); + assert.deepEqual(securityCall.args, [ + 'find-generic-password', + '-a', 'alice_1', + '-s', 'Claude Code-credentials-9bce6c0b', + '-w', + ]); + assert.deepEqual(securityCall.options.stdio, ['ignore', 'pipe', 'pipe']); + assert.deepEqual(securityCall.options.env, { USER: 'alice_1' }); + + let keychainError = null; + try { + readMacOSKeychain('/tmp/account', { + env: { USER: 'alice' }, + execFile: () => { throw new Error('sensitive-keychain-detail'); }, + }); + } catch (e) { + keychainError = e; + } + assert.ok(keychainError); + assert.doesNotMatch(keychainError.message, /sensitive-keychain-detail/); + + console.log('— login and atomic fallback persistence —'); + const macDir = path.join(tmp, 'mac account'); + fs.mkdirSync(macDir, { mode: 0o755 }); + const macFile = path.join(macDir, '.credentials.json'); + fs.writeFileSync(macFile, JSON.stringify(oauth('stale-access')), { mode: 0o644 }); + let spawnCall = null; + let output = ''; + const rc = runLogin({ + configDir: macDir, + claudeCmd: '/Applications/Claude Code/claude', + platform: 'darwin', + env: { USER: 'alice' }, + spawn: (cmd, args, options) => { + spawnCall = { cmd, args, options }; + return { status: 0 }; + }, + readKeychain: () => ({ ...oauth('fresh-access', { expiresAt: 1234 }), ignored: 'drop-me' }), + output: (line) => { output += line; }, + }); + assert.equal(rc, 0); + assert.equal(spawnCall.cmd, '/Applications/Claude Code/claude'); + assert.deepEqual(spawnCall.args, ['auth', 'login', '--claudeai']); + assert.equal(spawnCall.options.env.CLAUDE_CONFIG_DIR, path.resolve(macDir)); + assert.equal(spawnCall.options.env.CLAUDE_SECURESTORAGE_CONFIG_DIR, path.resolve(macDir)); + assert.equal(spawnCall.options.stdio, 'inherit'); + assert.deepEqual(JSON.parse(fs.readFileSync(macFile, 'utf8')), oauth('fresh-access', { expiresAt: 1234 })); + assert.equal(mode(macDir), 0o700); + assert.equal(mode(macFile), 0o600); + assert.deepEqual( + fs.readdirSync(macDir).filter((name) => name.endsWith('.tmp')), + [], + 'atomic-write temp files must be cleaned up', + ); + assert.match(output, /credentials saved/); + + console.log('— failure preserves the previous credential —'); + const before = fs.readFileSync(macFile, 'utf8'); + assert.throws(() => runLogin({ + configDir: macDir, + claudeCmd: '/usr/local/bin/claude', + platform: 'darwin', + spawn: () => ({ status: 0 }), + readKeychain: () => ({ claudeAiOauth: {} }), + }), /no OAuth credential/); + assert.equal(fs.readFileSync(macFile, 'utf8'), before); + + const faultFs = new Proxy(fs, { + get(target, prop) { + if (prop === 'fchmodSync') return () => { throw new Error('injected chmod failure'); }; + const value = Reflect.get(target, prop); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); + assert.throws(() => writeCredentialsFile(macDir, oauth('must-not-land'), { + fsImpl: faultFs, + randomBytes: () => Buffer.alloc(6, 1), + }), /could not save/); + assert.equal(fs.readFileSync(macFile, 'utf8'), before, 'a pre-rename permission failure must preserve the old file'); + assert.deepEqual(fs.readdirSync(macDir).filter((name) => name.endsWith('.tmp')), []); + + let readAfterFailedLogin = false; + const failedDir = path.join(tmp, 'failed'); + assert.equal(runLogin({ + configDir: failedDir, + claudeCmd: '/usr/local/bin/claude', + platform: 'darwin', + spawn: () => ({ status: 7 }), + readKeychain: () => { readAfterFailedLogin = true; return oauth('must-not-read'); }, + }), 7); + assert.equal(readAfterFailedLogin, false); + assert.equal(fs.existsSync(path.join(failedDir, '.credentials.json')), false); + const cliFailedDir = path.join(tmp, 'cli-failed'); + const helperCli = spawnSync(process.execPath, [ + path.join(process.cwd(), 'data/bin/vibespace-claude-subscription-login.mjs'), + '--config-dir', cliFailedDir, + '--claude', '/usr/bin/false', + '--attempt', 'vslogin-test-1234', + ], { encoding: 'utf8' }); + assert.equal(helperCli.status, 1); + assert.deepEqual( + (({ state, code, attempt }) => ({ state, code, attempt }))( + JSON.parse(fs.readFileSync(path.join(cliFailedDir, LOGIN_STATUS_FILE), 'utf8')), + ), + { state: 'error', code: 'claude-login-exit', attempt: 'vslogin-test-1234' }, + ); + + console.log('— non-macOS keeps the CLI-written file —'); + const linuxDir = path.join(tmp, 'linux'); + fs.mkdirSync(linuxDir, { mode: 0o755 }); + const linuxFile = path.join(linuxDir, '.credentials.json'); + fs.writeFileSync(linuxFile, JSON.stringify(oauth('linux-access')), { mode: 0o644 }); + assert.equal(runLogin({ + configDir: linuxDir, + claudeCmd: '/usr/bin/claude', + platform: 'linux', + spawn: () => ({ status: 0 }), + readKeychain: () => { throw new Error('Keychain must not run on Linux'); }, + output: () => {}, + }), 0); + assert.equal(mode(linuxDir), 0o700); + assert.equal(mode(linuxFile), 0o600); + + console.log('— account manager marks macOS shadows non-portable —'); + const macData = path.join(tmp, 'manager-mac'); + const macAccounts = new AccountManager({ dataDir: macData, platform: 'darwin' }); + const macAccount = macAccounts.createSubscription({ name: 'Mac account' }); + writeCredentialsFile(macAccount.dir, oauth('manager-mac-access')); + const macListed = macAccounts.list().accounts.find((a) => a.id === macAccount.id); + assert.equal(macListed.localOnly, true); + assert.equal(macAccounts.resolveForSpawn(macAccount.id).remoteCreds.shippable, false); + const macFinalized = macAccounts.finalizeSubscription(macAccount.id); + assert.equal(macFinalized.localOnly, true); + assert.equal(Object.hasOwn(macFinalized, 'accessToken'), false); + const macExport = macAccounts.exportBundle().accounts.find((a) => a.id === macAccount.id); + assert.equal(Object.hasOwn(macExport.files || {}, '.credentials.json'), false); + assert.equal(Object.hasOwn(macExport.files || {}, '.claude.json'), true); + + const macMergeFrom = macAccounts.createSubscription({ name: 'Fresh duplicate' }); + const macMergeInto = macAccounts.createSubscription({ name: 'Old duplicate' }); + writeCredentialsFile(macMergeFrom.dir, oauth('fresh-keychain-shadow')); + writeCredentialsFile(macMergeInto.dir, oauth('old-keychain-shadow')); + assert.throws( + () => macAccounts.mergeSubscription(macMergeFrom.id, macMergeInto.id, { preferFromCreds: true }), + /Keychain-backed subscriptions cannot be merged/, + ); + assert.equal(fs.existsSync(macMergeFrom.dir), true, 'rejected merge must preserve the fresh Keychain-bound dir'); + assert.equal(fs.existsSync(macMergeInto.dir), true, 'rejected merge must preserve the survivor dir'); + + const failedAccount = macAccounts.createSubscription({ name: 'Failed login' }); + writeLoginStatus(failedAccount.dir, { state: 'error', code: 'keychain-read' }); + assert.equal(mode(path.join(failedAccount.dir, LOGIN_STATUS_FILE)), 0o600); + assert.deepEqual( + (({ loggedIn, loginFailed, loginErrorCode }) => ({ loggedIn, loginFailed, loginErrorCode }))( + macAccounts.finalizeSubscription(failedAccount.id), + ), + { loggedIn: false, loginFailed: true, loginErrorCode: 'keychain-read' }, + ); + + const linuxData = path.join(tmp, 'manager-linux'); + const linuxAccounts = new AccountManager({ dataDir: linuxData, platform: 'linux' }); + const linuxAccount = linuxAccounts.createSubscription({ name: 'Linux account' }); + writeCredentialsFile(linuxAccount.dir, oauth('manager-linux-access')); + assert.equal(linuxAccounts.list().accounts.find((a) => a.id === linuxAccount.id).localOnly, false); + assert.equal(linuxAccounts.resolveForSpawn(linuxAccount.id).remoteCreds.shippable, true); + assert.ok(linuxAccounts.exportBundle().accounts.find((a) => a.id === linuxAccount.id).files['.credentials.json']); + + const mergeFrom = linuxAccounts.createSubscription({ name: 'Merge from' }); + const mergeInto = linuxAccounts.createSubscription({ name: 'Merge into' }); + writeCredentialsFile(mergeFrom.dir, oauth('fresh-merge-access')); + writeCredentialsFile(mergeInto.dir, oauth('stale-merge-access')); + fs.chmodSync(path.join(mergeInto.dir, '.credentials.json'), 0o644); + fs.chmodSync(path.join(mergeInto.dir, '.claude.json'), 0o644); + linuxAccounts.mergeSubscription(mergeFrom.id, mergeInto.id, { preferFromCreds: true }); + assert.equal(mode(path.join(mergeInto.dir, '.credentials.json')), 0o600); + assert.equal(mode(path.join(mergeInto.dir, '.claude.json')), 0o600); + + console.log('— credential minimization and shell quoting —'); + assert.deepEqual( + parseOAuthCredentials(JSON.stringify({ ...oauth('kept-access'), mcpOAuth: { token: 'must-not-copy' } })), + oauth('kept-access'), + ); + const hostilePath = `/tmp/space ' quote $(printf INJECTED)`; + const quotedRoundTrip = execFileSync('/bin/sh', [ + '-c', + `printf %s ${shellQuote(hostilePath)}`, + ], { encoding: 'utf8' }); + assert.equal(quotedRoundTrip, hostilePath); + assert.throws(() => shellQuote('/tmp/first\nsecond'), /control character/); + assert.equal(buildClaudeSubscriptionLoginCommand({ + nodeCmd: '/node path', + helperPath: "/repo/it's/helper.mjs", + claudeCmd: '/claude path', + configDir: hostilePath, + }), [ + "'/node path'", + `'/repo/it'"'"'s/helper.mjs'`, + "'--config-dir'", + `'/tmp/space '"'"' quote $(printf INJECTED)'`, + "'--claude'", + "'/claude path'", + ].join(' ')); + assert.ok(HostManager.AGENT_TOOLS.includes('vibespace-claude-subscription-login.mjs')); + const fakeHosts = Object.create(HostManager.prototype); + fakeHosts.get = () => ({ id: 'host-test' }); + fakeHosts._hostShell = async () => [ + 'OS:Darwin', 'SUB:0', 'KEY:', 'HELPER:no', 'EMAIL:', 'CXJWT:', 'CMT:', 'XMT:', + 'HSUBS:', + 'HSSTAT:/Users/alice/.vibespace/subs/sub-a1b2c3/.vibespace-login-status.json:{"version":1,"state":"error","code":"keychain-read","attempt":"vslogin-test-1234","updatedAt":1}', + ].join('\n'); + const fakeHostStatus = await fakeHosts.accountsStatus('host-test'); + assert.equal(fakeHostStatus.platform, 'darwin'); + assert.deepEqual(fakeHostStatus.hostSubLoginStatus, { + 'sub-a1b2c3': { state: 'error', attempt: 'vslogin-test-1234' }, + }); + const manageAgentsSource = fs.readFileSync(path.join(process.cwd(), 'src/lib/manage-agents.js'), 'utf8'); + assert.equal( + (manageAgentsSource.match(/remoteClaudeSubscriptionLoginCommand\(/g) || []).length, + 4, + 'all three remote per-account login entry points must use the helper', + ); + assert.doesNotMatch(manageAgentsSource, /CLAUDE_SECURESTORAGE_CONFIG_DIR=.*claude (?:auth login|\/login)/); + assert.match(manageAgentsSource, /loginStatus\.attempt !== loginAttempt/); + assert.match(manageAgentsSource, /loginStatus\.state === 'success'\) complete\(false\)/); + const wsSource = fs.readFileSync(path.join(process.cwd(), 'src/ws-handler.js'), 'utf8'); + assert.match(wsSource, /needsClaudeLoginHelper && !present\.includes\('vibespace-claude-subscription-login\.mjs'\)/); + assert.equal( + (wsSource.match(/spawnAccount\?\.secret \|\| needsClaudeLoginHelper/g) || []).length, + 2, + 'dial helper placement failures must not degrade to a helper-less terminal', + ); + const serverSource = fs.readFileSync(path.join(process.cwd(), 'server.js'), 'utf8'); + assert.match(serverSource, /r\.platform && r\.platform !== 'darwin'/); + assert.match(serverSource, /!fin\.localOnly/); + + console.log('\nall Claude subscription login tests passed'); +} finally { + fs.rmSync(tmp, { recursive: true, force: true }); +} diff --git a/server.js b/server.js index ade1ac22..c82c1b75 100644 --- a/server.js +++ b/server.js @@ -20,6 +20,7 @@ const { cwdToProjectDir, SessionMessages, findSessionJsonlPath, dedupWebuiSocket const { CodexSessionMessages } = require('./src/codex-session-store'); const { normalizeCodexSource, CODEX_SESSIONS_DIR } = require('./src/adapters/codex'); const { createAdapterRegistry } = require('./src/adapters'); +const { buildClaudeSubscriptionLoginCommand } = require('./src/claude-subscription-login'); const fileRoutes = require('./src/routes/files'); const { SafeFs } = require('./src/safe-fs'); const { router: persistenceRouter, setup: setupPersistence } = require('./src/routes/persistence'); @@ -161,6 +162,7 @@ const X_ENV = detectXDisplay(); function refreshXEnv() { Object.assign(X_ENV, detectXDisplay()); return X_ENV; } const CLAUDE_CMD = CLAUDE_CMD_RAW.startsWith('/') ? CLAUDE_CMD_RAW : resolveCmd(CLAUDE_CMD_RAW); const CODEX_CMD = CODEX_CMD_RAW.startsWith('/') ? CODEX_CMD_RAW : resolveCmd(CODEX_CMD_RAW); +const CLAUDE_SUBSCRIPTION_LOGIN_HELPER = path.join(__dirname, 'data', 'bin', 'vibespace-claude-subscription-login.mjs'); const CODEX_LINUX_SANDBOX_CMD = resolveCmd('codex-linux-sandbox'); const CODEX_SANDBOX_SUPPORTED = process.platform !== 'linux' || (!!CODEX_LINUX_SANDBOX_CMD && CODEX_LINUX_SANDBOX_CMD !== 'codex-linux-sandbox') @@ -3169,7 +3171,11 @@ app.get('/api/hosts/:id/accounts-status', async (req, res) => { // survivor and fold the records (the local finalize path does the same // for local logins). Learned dir emails also backfill records that // never declared one (enables the =host-login link). - if (accounts && r.hostSubEmails) { + // On macOS the Keychain service name is hashed from the dir path. Renaming + // a host dir cannot migrate that item from this non-interactive server + // context, so keep duplicate records rather than silently selecting stale + // credentials or orphaning the fresh service. + if (accounts && r.hostSubEmails && r.platform && r.platform !== 'darwin') { for (const [dirId, email] of Object.entries(r.hostSubEmails)) { try { const em = String(email).trim().toLowerCase(); @@ -3180,6 +3186,11 @@ app.get('/api/hosts/:id/accounts-status', async (req, res) => { const dup = all.find((x) => x.id !== dirId && (x.backend || 'claude') === 'claude' && x.type === 'subscription' && String(x.email || (String(x.name || '').includes('@') ? x.name : '')).trim().toLowerCase() === em); if (dup) { + // A Darwin VibeSpace marks these records local-only even when this + // particular copy lives on a Linux host. Be conservative: check + // before renaming so mergeSubscription cannot reject after the + // host path has already changed. + if (rec.localOnly || dup.localOnly) continue; // survivor = the OLDER record; rename the newer's host dir first const survivor = (dup.createdAt || 0) <= (rec.createdAt || 0) ? dup : rec; const gone = survivor === dup ? rec : dup; @@ -3257,22 +3268,19 @@ app.delete('/api/accounts/:id', (req, res) => { app.post('/api/accounts/subscription', (req, res) => { try { const { id, dir } = accounts.createSubscription(req.body || {}); - // The client opens a shell terminal with this exact command. Set ONLY - // CLAUDE_SECURESTORAGE_CONFIG_DIR → the login's CREDS go to the isolated dir - // (that's what bills), while the CONFIG dir stays ~/.claude — so claude does - // NOT show its first-run onboarding (an empty CLAUDE_CONFIG_DIR would, which - // broke the login flow). `/login` matches the proven console-wizard command. - // (Identity in ~/.claude.json is cosmetically overwritten — the global's - // TOKENS in ~/.claude/.credentials.json are untouched since they read from - // the securestorage dir.) - // `claude auth login` (subcommand — NOT the TUI `/login`, which errors from - // a shell) prints an OAuth URL to a HOSTED callback + a "Paste code" prompt, - // so it works headlessly. --claudeai = the subscription flow. Set BOTH env - // vars → dir: creds AND identity (.claude.json oauthAccount) isolate into the - // dir, so the GLOBAL ~/.claude.json is NOT clobbered. The dir is pre-seeded - // with onboarding-complete flags so no first-run screen appears. - const q = JSON.stringify(dir); - const loginCmd = `CLAUDE_CONFIG_DIR=${q} CLAUDE_SECURESTORAGE_CONFIG_DIR=${q} claude auth login --claudeai`; + // The client opens a shell terminal with this exact command. The helper + // runs the official `claude auth login --claudeai` with BOTH config envs + // scoped to the pre-seeded account dir, isolating credentials + identity + // without touching the global login. On macOS it then copies the new + // per-dir Keychain value to Claude's normal fallback file while it is still + // in the interactive terminal's Keychain security session; launchd-started + // VibeSpace processes cannot reliably read that item later. + const loginCmd = buildClaudeSubscriptionLoginCommand({ + nodeCmd: NODE_CMD, + helperPath: CLAUDE_SUBSCRIPTION_LOGIN_HELPER, + claudeCmd: CLAUDE_CMD, + configDir: dir, + }); res.json({ success: true, id, dir, loginCmd }); } catch (e) { res.status(400).json({ error: e.message }); } }); @@ -3283,7 +3291,11 @@ app.post('/api/accounts/subscription/:id/finalize', (req, res) => { // a fresh login whose identity email matches an EXISTING subscription is // the SAME account — fold the new record into the existing one (fresh // creds win) instead of keeping a duplicate. - if (fin?.loggedIn && fin?.email && accounts) { + // A macOS Keychain service is tied to this fresh config-dir path. Folding + // its file fallback into an older id would leave Claude preferring the + // older id's stale Keychain item, so Darwin logins deliberately keep their + // fresh record instead of using the path-changing auto-merge. + if (fin?.loggedIn && fin?.email && !fin.localOnly && accounts) { const em = String(fin.email).trim().toLowerCase(); const dup = (accounts.list().accounts || []).find((x) => x.id !== req.params.id && (x.backend || 'claude') === 'claude' && x.type === 'subscription' diff --git a/src/accounts.js b/src/accounts.js index 46bcd3aa..1cf43fd1 100644 --- a/src/accounts.js +++ b/src/accounts.js @@ -20,9 +20,10 @@ const os = require('os'); const crypto = require('crypto'); class AccountManager { - constructor({ dataDir, onChange }) { + constructor({ dataDir, onChange, platform = process.platform }) { this._file = path.join(dataDir, 'accounts.json'); this._keyFile = path.join(dataDir, '.accounts-key'); + this._platform = platform; // Per-SUBSCRIPTION credential dirs. A subscription account is a real dir // holding ONLY that account's .credentials.json; the CLI reads it via // CLAUDE_SECURESTORAGE_CONFIG_DIR (relocates the SECRET store only — @@ -47,6 +48,11 @@ class AccountManager { _acctType(a) { return a.type || 'api'; } // legacy records (no type) = API key _acctBackend(a) { return a.backend || 'claude'; } // legacy records = Claude + _localOnlyClaudeSub(a) { + return this._platform === 'darwin' + && this._acctBackend(a) === 'claude' + && this._acctType(a) === 'subscription'; + } subDir(id) { return path.join(this._subsDir, id); } subCredsPath(id) { return path.join(this.subDir(id), '.credentials.json'); } codexSubDir(id) { return path.join(this._codexSubsDir, id); } @@ -109,7 +115,7 @@ class AccountManager { accounts: this._state.accounts.map((a) => { const type = this._acctType(a); const backend = this._acctBackend(a); - const base = { id: a.id, name: a.name, type, backend, source: a.source, originHost: a.originHost || null, note: a.note || null, hostLogins: a.hostLogins || null, createdAt: a.createdAt }; + const base = { id: a.id, name: a.name, type, backend, source: a.source, originHost: a.originHost || null, note: a.note || null, hostLogins: a.hostLogins || null, createdAt: a.createdAt, localOnly: this._localOnlyClaudeSub(a) }; if (backend === 'codex') { const info = this.readCodexSubAuth(a.id); return { ...base, loggedIn: info.loggedIn, email: info.email || a.email || null, emailDeclared: !info.email && !!a.email, subscriptionType: info.plan, authMode: info.authMode }; @@ -127,9 +133,9 @@ class AccountManager { // ── Subscription accounts (each = its own securestorage creds dir) ── - // Allocate an empty account + dir. The OAuth login happens externally - // (a terminal running `CLAUDE_SECURESTORAGE_CONFIG_DIR= claude /login`); - // the caller watches for the creds file, then calls finalizeSubscription. + // Allocate an empty account + dir. The OAuth login happens in an interactive + // terminal through vibespace-claude-subscription-login.mjs; the caller + // watches for the creds/status file, then calls finalizeSubscription. createSubscription({ name } = {}) { const id = 'sub-' + crypto.randomBytes(6).toString('hex'); fs.mkdirSync(this.subDir(id), { recursive: true, mode: 0o700 }); @@ -170,18 +176,33 @@ class AccountManager { } catch { return { loggedIn: false }; } } + _subscriptionLoginStatus(id) { + try { + const status = JSON.parse(fs.readFileSync(path.join(this.subDir(id), '.vibespace-login-status.json'), 'utf-8')); + if (status?.state !== 'error' || !/^[a-z0-9-]{1,40}$/.test(status.code || '')) return null; + return { state: 'error', code: status.code }; + } catch { return null; } + } + // After the login terminal wrote creds: pull identity, default the name to // the email/plan if the user didn't set one. Returns loggedIn. finalizeSubscription(id) { const a = this.get(id); if (!a || this._acctType(a) !== 'subscription') throw new Error('not a subscription account'); const info = this.readSubCreds(id); + const loginStatus = this._subscriptionLoginStatus(id); if (info.loggedIn && (!a.name || a.name === 'Subscription')) { a.name = (info.email || (info.subscriptionType ? info.subscriptionType[0].toUpperCase() + info.subscriptionType.slice(1) : 'Subscription')).slice(0, 60); this._save(); } this._notify(); - return { id, ...info, name: a.name }; + const { accessToken: _accessToken, ...publicInfo } = info; + return { + id, ...publicInfo, name: a.name, + localOnly: this._localOnlyClaudeSub(a), + loginFailed: !info.loggedIn && loginStatus?.state === 'error', + loginErrorCode: !info.loggedIn ? loginStatus?.code || null : null, + }; } // ── Codex subscription accounts (each = its own CODEX_HOME, auth isolated) ── @@ -281,7 +302,12 @@ class AccountManager { if (a.tail) rec.tail = a.tail; if (a.keyEnc) { try { rec.key = this._dec(a.keyEnc); } catch { } } if (backend === 'codex') rec.files = readFiles(this.codexSubDir(a.id), CODEX_SUB_FILES); - else if (type === 'subscription') rec.files = readFiles(this.subDir(a.id), CLAUDE_SUB_FILES); + else if (type === 'subscription') { + // macOS secure storage is Keychain-primary. The local fallback is a + // same-machine compatibility shadow for launchd and can diverge after + // refresh-token rotation, so never treat it as a portable backup. + rec.files = readFiles(this.subDir(a.id), this._localOnlyClaudeSub(a) ? ['.claude.json'] : CLAUDE_SUB_FILES); + } return rec; }); return { @@ -353,15 +379,40 @@ class AccountManager { const from = this._state.accounts.find((x) => x.id === fromId); const into = this._state.accounts.find((x) => x.id === intoId); if (!from || !into || fromId === intoId) throw new Error('bad merge pair'); + // Claude's macOS Keychain service is hashed from the config-dir path. + // Moving fresh fallback bytes to another id does NOT move that Keychain + // item, so the survivor can keep reading its old token and the new item is + // orphaned. Keep both records instead of claiming a safe file-only merge. + if (this._localOnlyClaudeSub(from) || this._localOnlyClaudeSub(into)) { + throw new Error('macOS Keychain-backed subscriptions cannot be merged across config directories'); + } // local creds: bring the dup's dir over when it's fresher/the only login try { const fromDir = this.subDir(fromId), intoDir = this.subDir(intoId); const intoLogged = this.readSubCreds(intoId).loggedIn; if (fs.existsSync(path.join(fromDir, '.credentials.json')) && (preferFromCreds || !intoLogged)) { fs.mkdirSync(intoDir, { recursive: true, mode: 0o700 }); + fs.chmodSync(intoDir, 0o700); for (const f of ['.credentials.json', '.claude.json']) { const src = path.join(fromDir, f); - if (fs.existsSync(src)) fs.copyFileSync(src, path.join(intoDir, f)); + if (fs.existsSync(src)) { + const dest = path.join(intoDir, f); + const tmp = dest + `.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`; + let fd; + try { + const content = fs.readFileSync(src); + fd = fs.openSync(tmp, 'wx', 0o600); + fs.writeFileSync(fd, content); + fs.fchmodSync(fd, 0o600); + if ((fs.fstatSync(fd).mode & 0o777) !== 0o600) throw new Error('private mode not applied'); + fs.fsyncSync(fd); + fs.closeSync(fd); fd = undefined; + fs.renameSync(tmp, dest); + } finally { + if (fd !== undefined) { try { fs.closeSync(fd); } catch { } } + try { fs.unlinkSync(tmp); } catch { } + } + } } } fs.rmSync(fromDir, { recursive: true, force: true }); @@ -539,7 +590,15 @@ class AccountManager { // Console /login inside a remote session wipes .credentials.json to {} // with a fresh mtime) — a remote primary file MISSING the marker is // deleted before extract so the valid local copy always restores it. - remoteCreds: { srcDir: this.subDir(id), dirName: 'subs/' + id, envVar: 'CLAUDE_SECURESTORAGE_CONFIG_DIR', files: ['.credentials.json', '.claude.json'], symlinks: {}, ensureTargets: [], probe: { file: '.credentials.json', marker: 'accessToken' } }, + remoteCreds: { + srcDir: this.subDir(id), dirName: 'subs/' + id, envVar: 'CLAUDE_SECURESTORAGE_CONFIG_DIR', + files: ['.credentials.json', '.claude.json'], symlinks: {}, ensureTargets: [], + probe: { file: '.credentials.json', marker: 'accessToken' }, + // Keychain + fallback can fork when either copy refreshes (rotating + // refresh tokens). It remains usable on this Mac, or on another host + // that has its OWN login for the account, but must never be copied. + shippable: !this._localOnlyClaudeSub(a), + }, }; } const key = this.getKey(id); diff --git a/src/claude-subscription-login.js b/src/claude-subscription-login.js new file mode 100644 index 00000000..c2450bcc --- /dev/null +++ b/src/claude-subscription-login.js @@ -0,0 +1,29 @@ +'use strict'; + +// Build the command typed into VibeSpace's interactive helper terminal. Values +// here are paths only (never credentials), but still need real shell quoting: +// the command is interpreted by the user's login shell. +function shellQuote(value) { + const text = String(value); + // initialCommand is typed into a live terminal, so shell quotes alone cannot + // make terminal control bytes safe (a newline would submit half a command). + if (/[\x00-\x1f\x7f]/.test(text)) throw new Error('control character in shell command path'); + return `'${text.replace(/'/g, `'"'"'`)}'`; +} + +function buildClaudeSubscriptionLoginCommand({ nodeCmd, helperPath, claudeCmd, configDir }) { + const values = [nodeCmd, helperPath, configDir, claudeCmd]; + if (values.some((v) => typeof v !== 'string' || !v)) { + throw new Error('missing Claude subscription login command path'); + } + return [ + nodeCmd, + helperPath, + '--config-dir', + configDir, + '--claude', + claudeCmd, + ].map(shellQuote).join(' '); +} + +module.exports = { buildClaudeSubscriptionLoginCommand, shellQuote }; diff --git a/src/hosts.js b/src/hosts.js index c189b48b..5f209fcb 100644 --- a/src/hosts.js +++ b/src/hosts.js @@ -355,7 +355,8 @@ class HostManager { // "Import its key" never appeared and the key was later orphaned by an // OAuth login switch). const out = await this._hostShell(h, - `S=$(grep -c accessToken "$HOME/.claude/.credentials.json" 2>/dev/null); echo "SUB:$S"; ` + `echo "OS:$(uname -s 2>/dev/null)"; ` + + `S=$(grep -c accessToken "$HOME/.claude/.credentials.json" 2>/dev/null); echo "SUB:$S"; ` + `K=$(grep -o "primaryApiKey\\": *\\"sk-ant-[^\\"]*" "$HOME/.claude.json" 2>/dev/null | head -1); echo "KEY:$K"; ` + `grep -q "\\"apiKeyHelper\\"" "$HOME/.claude/settings.json" 2>/dev/null && echo "HELPER:yes" || echo "HELPER:no"; ` + `E=$(grep -o "emailAddress\\": *\\"[^\\"]*" "$HOME/.claude.json" 2>/dev/null | head -1); echo "EMAIL:$E"; ` @@ -372,10 +373,15 @@ class HostManager { // on the host-side dir, nothing ships. find -exec (no shell globs — // zsh nomatch aborts glob-carrying lines in _hostShell scripts). + `HS=$(find "$HOME/.vibespace/subs" -maxdepth 2 -name ".credentials.json" -exec grep -l accessToken {} + 2>/dev/null | tr "\\n" " "); echo "HSUBS:$HS"; ` + // Sanitized helper state for a currently-watched on-host login. The + // attempt id is random but non-secret; matching it avoids both stale + // markers and another concurrent account's completion. + + `find "$HOME/.vibespace/subs" -maxdepth 2 -name ".vibespace-login-status.json" -exec grep -H "\\"state\\":" {} + 2>/dev/null | sed "s/^/HSSTAT:/"; ` // per-dir identity email — the anchor for same-account auto-merge // (2.205.0): a host login whose email matches an EXISTING record means // duplicate records of one real account + `find "$HOME/.vibespace/subs" -maxdepth 2 -name ".claude.json" -exec grep -H -o "emailAddress\\": *\\"[^\\"]*" {} + 2>/dev/null | sed "s/^/HSE:/"`); + const hostOs = /^OS:([A-Za-z0-9._-]+)\s*$/m.exec(out); const sub = /SUB:(\d+)/.exec(out); const key = /KEY:primaryApiKey": *"(sk-ant-[^\s"]+)/.exec(out); const helper = /HELPER:yes/.test(out); @@ -395,11 +401,16 @@ class HostManager { const hostSubs = hsubs ? [...hsubs[1].matchAll(/subs\/([\w-]+)\/\.credentials\.json/g)].map((m) => m[1]) : []; + const hostSubLoginStatus = {}; + for (const m of out.matchAll(/HSSTAT:.*subs\/([\w-]+)\/\.vibespace-login-status\.json:[^\n]*"state":"(running|success|error)"[^\n]*"attempt":"([a-zA-Z0-9._-]{8,80})"/g)) { + hostSubLoginStatus[m[1]] = { state: m[2], attempt: m[3] }; + } const hostSubEmails = {}; for (const m of out.matchAll(/HSE:.*subs\/([\w-]+)\/\.claude\.json:emailAddress": *"([^"\s]+)/g)) { hostSubEmails[m[1]] = m[2]; } return { + platform: hostOs ? hostOs[1].toLowerCase() : null, subscription: { loggedIn: !!(sub && parseInt(sub[1]) > 0), email: email ? email[1] : null }, cliKey: key ? { present: true, tail: key[1].slice(-8) } : { present: false }, keyHelper: helper, @@ -407,6 +418,7 @@ class HostManager { credsMtime: cmt ? parseInt(cmt[1]) : null, // seconds — claude .credentials.json codexAuthMtime: xmt ? parseInt(xmt[1]) : null, // seconds — codex auth.json hostSubs, // acct ids with a live host-side creds dir (~/.vibespace/subs/) + hostSubLoginStatus, // {acctId:{state,attempt}} from sanitized on-host helper markers hostSubEmails, // { acctId: identity email of its host-side dir } — merge anchor }; } @@ -589,7 +601,7 @@ class HostManager { /** The agent-tool set shipped to remotes (same list the per-spawn * distribution in ws-handler uses — keep in sync). */ - static AGENT_TOOLS = ['vibespace-status', 'vibespace-task', 'vibespace-ask', 'vibespace-exit', 'vibespace-hook.mjs', 'vibespace-hook-register.mjs', 'vibespace-remote-keeper']; + static AGENT_TOOLS = ['vibespace-status', 'vibespace-task', 'vibespace-ask', 'vibespace-exit', 'vibespace-hook.mjs', 'vibespace-hook-register.mjs', 'vibespace-remote-keeper', 'vibespace-claude-subscription-login.mjs']; /** Integration state ON THE HOST in one ssh round trip: per-tool presence + * sha256 (content compare beats mtime — the local hook/status tools are diff --git a/src/lib/app.js b/src/lib/app.js index 87ce6ffb..79d280b7 100644 --- a/src/lib/app.js +++ b/src/lib/app.js @@ -1667,7 +1667,7 @@ class App { const held = !linked && hostHeld(a); if (linked) opts.push([a.id, a.name + ' ' + t('· uses {host}’s own login', { host: hostName })]); else if (held) opts.push([a.id, a.name + ' ' + t('· logged in on {host}', { host: hostName })]); - else if (a.loggedIn && allowSubRemote && hostRec?.transport !== 'dial') opts.push([a.id, t('{name} (subscription)', { name: a.name })]); + else if (a.loggedIn && allowSubRemote && !a.localOnly && hostRec?.transport !== 'dial') opts.push([a.id, t('{name} (subscription)', { name: a.name })]); // Not usable there — show WHY instead of silently omitting (the // omission is what taught users the create-then-switch workaround) else opts.push([a.id, a.name + ' — ' + t('not logged in on {host}', { host: hostName }), true]); diff --git a/src/lib/i18n-ja.js b/src/lib/i18n-ja.js index 430c54f6..952805c6 100644 --- a/src/lib/i18n-ja.js +++ b/src/lib/i18n-ja.js @@ -906,6 +906,7 @@ export default { "Refresh failed": "更新に失敗しました", "Already logged in as this account ON {host}? Then pick “CLI login @ {host}” when switching the session’s billing — that uses the host’s own login.": "このアカウントで既に {host} 上でログイン済みですか?セッションの課金を切り替える際に「CLI login @ {host}」を選んでください——ホスト自身のログインが使われます。", "This stored login can’t ship to {host}. If you’ve logged this account in ON {host}, pick “CLI login @ {host}” above — that uses the host’s own login. (Or enable Settings → “Ship subscription logins to remote hosts”.)": "このマシンに保存されたログインは {host} へ配布できません。{host} 上でこのアカウントにログイン済みなら、上の「CLI login @ {host}」を選んでください——ホスト自身のログインが使われます。(または 設定 → 「サブスクリプションのログインをリモートホストへ配布」を有効に。)", + "This macOS Keychain-backed login stays on this machine. Log in as this account on {host} instead; copying it can invalidate rotating OAuth credentials.": "この macOS キーチェーン由来のログインはこのマシン内に保持されます。代わりに {host} 上でこのアカウントへログインしてください。コピーすると、ローテーションする OAuth 認証情報が無効になる可能性があります。", "Attached pages ({n})": "添付ページ画像({n})", "off by default; encrypted with a passphrase. The file lets anyone who has it (and the passphrase) log in / use your agent accounts. Treat it like a key.": "既定でオフ;パスフレーズで暗号化されます。このファイルを持つ人(とパスフレーズ)は誰でもログインしたり、あなたのエージェントアカウントを使用できます。キーと同様に扱ってください。", "optional": "任意", @@ -998,6 +999,7 @@ export default { "Add subscription…": "サブスクリプションを追加…", "Could not start": "開始できませんでした", "Could not start — server unreachable": "開始できませんでした——サーバーに接続できません", + "Subscription login could not be saved. Check the login terminal for details, then try again.": "サブスクリプションのログインを保存できませんでした。ログイン用ターミナルの詳細を確認して、もう一度お試しください。", "Name this subscription (e.g. Work Max, Personal Max)": "このサブスクに名前を付ける(例:仕事Max、個人Max)", "Open a terminal session on this subscription": "このサブスクでターミナルセッションを開く", "Remove this subscription from VibeSpace (deletes its stored login)": "この サブスクを VibeSpace から削除(保存されたログインを削除)", diff --git a/src/lib/i18n-zh.js b/src/lib/i18n-zh.js index 2ff98d6a..b03d435d 100644 --- a/src/lib/i18n-zh.js +++ b/src/lib/i18n-zh.js @@ -907,6 +907,7 @@ export default { "Refresh failed": "刷新失败", "Already logged in as this account ON {host}? Then pick “CLI login @ {host}” when switching the session’s billing — that uses the host’s own login.": "已经在 {host} 上登录了这个账号?切换会话计费时选“CLI login @ {host}”即可——那会使用主机自己的登录。", "This stored login can’t ship to {host}. If you’ve logged this account in ON {host}, pick “CLI login @ {host}” above — that uses the host’s own login. (Or enable Settings → “Ship subscription logins to remote hosts”.)": "这个存储在本机的登录无法下发到 {host}。如果你已在 {host} 上登录了该账号,选上面的“CLI login @ {host}”——那会使用主机自己的登录。(或开启 设置 → “将订阅登录下发到远程主机”。)", + "This macOS Keychain-backed login stays on this machine. Log in as this account on {host} instead; copying it can invalidate rotating OAuth credentials.": "这个由 macOS 钥匙串支持的登录只保留在本机。请改为在 {host} 上登录此账号;复制它可能使轮换中的 OAuth 凭据失效。", "Attached pages ({n})": "文档页面图({n})", "off by default; encrypted with a passphrase. The file lets anyone who has it (and the passphrase) log in / use your agent accounts. Treat it like a key.": "默认关闭;用口令加密。拥有该文件(及口令)的任何人都可登录 / 使用你的 agent 账号。请像对待密钥一样对待它。", "optional": "可选", @@ -999,6 +1000,7 @@ export default { "Add subscription…": "添加订阅…", "Could not start": "无法启动", "Could not start — server unreachable": "无法启动——服务器不可达", + "Subscription login could not be saved. Check the login terminal for details, then try again.": "无法保存订阅登录。请查看登录终端中的详细信息,然后重试。", "Name this subscription (e.g. Work Max, Personal Max)": "给这个订阅起个名(如 工作Max、个人Max)", "Open a terminal session on this subscription": "用这个订阅打开一个终端会话", "Remove this subscription from VibeSpace (deletes its stored login)": "从 VibeSpace 移除此订阅(删除其存储的登录)", diff --git a/src/lib/manage-agents.js b/src/lib/manage-agents.js index 9c24e2b0..567015f1 100644 --- a/src/lib/manage-agents.js +++ b/src/lib/manage-agents.js @@ -2,6 +2,19 @@ import { t } from './i18n.js'; import { copyText, createModalShell, escHtml, fetchJson, showConfirmDialog, showContextMenu, showInputDialog, showToast } from './utils.js'; +function remoteClaudeSubscriptionLoginCommand(id) { + if (!/^sub-[a-f0-9]+$/.test(id)) throw new Error('invalid subscription id'); + const dir = `$HOME/.vibespace/subs/${id}`; + const attempt = `vslogin-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; + // The server ships this single transport helper even when agent Integration + // is off. It runs the official login and, on a Mac host, captures Keychain + // credentials before the interactive terminal/security session ends. + return { + attempt, + command: `mkdir -p "${dir}" && node "$HOME/.vibespace/bin/vibespace-claude-subscription-login.mjs" --config-dir "${dir}" --claude claude --attempt "${attempt}"`, + }; +} + export function installManageAgents(App, ctx = {}) { Object.assign(App.prototype, { // ── Manage Agents dialog: install/login status + login/update actions ── @@ -41,9 +54,9 @@ export function installManageAgents(App, ctx = {}) { // is minted on the host and never leaves it; the account record still // lives in VibeSpace (machine-independent identity). if (hostId) { - const dir = `$HOME/.vibespace/subs/${created.id}`; // id shape sub-, metachar-free - this._watchHostLogin(hostId, hostLabel); - this.openShellTerminal(undefined, { hostId, initialCommand: `mkdir -p "${dir}" && CLAUDE_CONFIG_DIR="${dir}" CLAUDE_SECURESTORAGE_CONFIG_DIR="${dir}" claude auth login --claudeai` }); + const login = remoteClaudeSubscriptionLoginCommand(created.id); + this._watchHostLogin(hostId, hostLabel, created.id, login.attempt); + this.openShellTerminal(undefined, { hostId, initialCommand: login.command }); showToast(t('A terminal opened ON {host} — sign in there. The login lives on {host} only; sessions on it can then pick this account.', { host: hostLabel }), { duration: 7000 }); return; } @@ -61,6 +74,9 @@ export function installManageAgents(App, ctx = {}) { clearInterval(iv); if (r.merged) showToast(t('✓ Recognized as existing account “{name}” — merged (freshest login kept)', { name: r.account?.name || '' }), { duration: 7000 }); else showToast(t('✓ Added {name}', { name: r.name || t('subscription') })); + } else if (r?.loginFailed) { + clearInterval(iv); + showToast(t('Subscription login could not be saved. Check the login terminal for details, then try again.'), { type: 'error', duration: 8000 }); } } catch { /* keep polling */ } }, 3000); @@ -122,7 +138,7 @@ export function installManageAgents(App, ctx = {}) { // Polls the host's live login state — a read-only ssh probe, NO API calls // (§ban-safety) — until the credential files CHANGE vs the pre-login // snapshot, then brings the Agents surface back on the SAME machine. - _watchHostLogin(hostId, hostLabel) { + _watchHostLogin(hostId, hostLabel, accountId = null, loginAttempt = null) { if (!hostId) return; if (this._hostLoginWatch) { clearInterval(this._hostLoginWatch); this._hostLoginWatch = null; } const sig = (r) => (r && !r.error) @@ -136,33 +152,50 @@ export function installManageAgents(App, ctx = {}) { // OAuth login takes ≥15-30s and still lands after the baseline. let baseSig = null; let tries = 0; + const complete = (machineLoginChanged) => { + clearInterval(this._hostLoginWatch); this._hostLoginWatch = null; + if (machineLoginChanged) (this._hostLoginSeenAt ||= {})[hostId] = Date.now(); + // Respect a machine the user explicitly switched to while waiting — + // yanking the surface back would re-instance the jumps-machines bug. + if (this._agentsHostPref && this._agentsHostPref !== hostId) { + showToast(t('✓ Login on {host} updated', { host: hostLabel }), { duration: 5000 }); + return; + } + showToast(t('✓ Login on {host} updated — reopening Agents there', { host: hostLabel }), { duration: 5000 }); + this._agentsHostPref = hostId; + // Refresh the open Agents surface in place (forcing it onto the login's + // machine), else reopen it (the wizard pattern). + if (!this._agentsRefreshHook?.(hostId)) this._showAgentsDialog(); + }; this._hostLoginWatch = setInterval(async () => { if (++tries > 50) { clearInterval(this._hostLoginWatch); this._hostLoginWatch = null; return; } let cur = null; try { cur = await fetchJson(`/api/hosts/${encodeURIComponent(hostId)}/accounts-status`); } catch { return; } + if (accountId && loginAttempt) { + const loginStatus = cur?.hostSubLoginStatus?.[accountId]; + // A named-account helper owns its exact completion signal. Never let + // another account/global refresh satisfy this watcher, and do not lose + // a fast login that completed before the first six-second poll. + if (!loginStatus || loginStatus.attempt !== loginAttempt || loginStatus.state === 'running') return; + if (loginStatus.state === 'error') { + clearInterval(this._hostLoginWatch); this._hostLoginWatch = null; + showToast(t('Subscription login could not be saved. Check the login terminal for details, then try again.'), { type: 'error', duration: 8000 }); + return; + } + if (loginStatus.state === 'success') complete(false); + return; + } const s = sig(cur); if (s === null) return; if (baseSig === null) { baseSig = s; return; } if (s === baseSig) return; - clearInterval(this._hostLoginWatch); this._hostLoginWatch = null; // Stamp for the roster's identity-freshness note — ONLY when the // MACHINE login itself changed (a per-account host login landing is // the last sig field; it must not arm the CLI-login row's amber // "login changed" note). Local clocks only — remote mtimes rotate on // normal token refresh and skew. const machinePart = (x) => x.split('|').slice(0, 5).join('|'); - if (machinePart(s) !== machinePart(baseSig)) (this._hostLoginSeenAt ||= {})[hostId] = Date.now(); - // Respect a machine the user explicitly switched to while waiting — - // yanking the surface back would re-instance the jumps-machines bug. - if (this._agentsHostPref && this._agentsHostPref !== hostId) { - showToast(t('✓ Login on {host} updated', { host: hostLabel }), { duration: 5000 }); - return; - } - showToast(t('✓ Login on {host} updated — reopening Agents there', { host: hostLabel }), { duration: 5000 }); - this._agentsHostPref = hostId; - // Refresh the open Agents surface in place (forcing it onto the login's - // machine), else reopen it (the wizard pattern). - if (!this._agentsRefreshHook?.(hostId)) this._showAgentsDialog(); + complete(machinePart(s) !== machinePart(baseSig)); }, 6000); }, @@ -1033,9 +1066,10 @@ export function installManageAgents(App, ctx = {}) { const keyLines = claudeAccts.map(a => { const isDef = accts.defaultAccountId === a.id; const isSub = a.type === 'subscription'; - const linked = isSub && subBlocked && !!hostOwnEmail && acctEmailOf(a) === hostOwnEmail; - const hostSub = isSub && subBlocked && !linked && hostSubIds.includes(a.id); - const blocked = isSub && subBlocked && !linked && !hostSub; // subscription on a remote host, opt-in off + const restricted = isSub && !!selectedHost && (subBlocked || a.localOnly); + const linked = restricted && !!hostOwnEmail && acctEmailOf(a) === hostOwnEmail; + const hostSub = restricted && !linked && hostSubIds.includes(a.id); + const blocked = restricted && !linked && !hostSub; // needs a host-owned login; cannot/should not ship // token-derived orgEmail (per-account ⟳ roles bake) beats the creds // dir's config email — same staleness class as the global row (2.188.0) const aEmail = this._accountUsage?.[a.id]?.orgEmail || a.email; @@ -1062,7 +1096,9 @@ export function installManageAgents(App, ctx = {}) { ? ` ${t('· = {host}’s own login', { host: escHtml(hostLabel) })}` : hostSub ? ` ${t('· logged in on {host}', { host: escHtml(hostLabel) })}` - : blocked ? ` ${t('· this machine only')}` : ''; + : blocked ? ` ${t('· this machine only')}` : ''; // Provenance + user note tags (2.201.0, real report: a key imported // from a host read as live-shared FROM it — say where it came from and // that it's an independent copy) @@ -1138,9 +1174,9 @@ export function installManageAgents(App, ctx = {}) { if (hostOwnEmail && acctEmailOf(sa) === hostOwnEmail) continue; // IS the host's own login items.push({ label: t('Log in on {host} as “{name}”…', { host: hostLabel, name: sa.name }), action: () => { done(); - const dir = `$HOME/.vibespace/subs/${sa.id}`; // id shape sub-, metachar-free - this._watchHostLogin(selectedHost, hostLabel); - this.openShellTerminal(undefined, { hostId: selectedHost, initialCommand: `mkdir -p "${dir}" && CLAUDE_CONFIG_DIR="${dir}" CLAUDE_SECURESTORAGE_CONFIG_DIR="${dir}" claude auth login --claudeai` }); + const login = remoteClaudeSubscriptionLoginCommand(sa.id); + this._watchHostLogin(selectedHost, hostLabel, sa.id, login.attempt); + this.openShellTerminal(undefined, { hostId: selectedHost, initialCommand: login.command }); showToast(t('Sign in as “{name}” in the terminal — this login lives ON {host} only; the machine’s own login is untouched.', { name: sa.name, host: hostLabel }), { duration: 7000 }); } }); } @@ -1226,7 +1262,10 @@ export function installManageAgents(App, ctx = {}) { // §ban-safety: a subscription can't run on a remote host by default. // Explain instead of firing a create the server will reject. if (keyRow.dataset.blocked) { - showToast(t('“{name}” runs on this machine only. For {host}, use “Log in on host…” on the CLI-login row, or turn on Settings → “Ship subscription logins to remote hosts.”', { name: a?.name, host: escHtml(hostLabel) }) + ' ' + t('Already logged in as this account ON {host}? Then pick “CLI login @ {host}” when switching the session’s billing — that uses the host’s own login.', { host: escHtml(hostLabel) }), { type: 'error', duration: 8000 }); + showToast((a?.localOnly + ? t('This macOS Keychain-backed login stays on this machine. Log in as this account on {host} instead; copying it can invalidate rotating OAuth credentials.', { host: escHtml(hostLabel) }) + : t('“{name}” runs on this machine only. For {host}, use “Log in on host…” on the CLI-login row, or turn on Settings → “Ship subscription logins to remote hosts.”', { name: a?.name, host: escHtml(hostLabel) })) + + ' ' + t('Already logged in as this account ON {host}? Then pick “CLI login @ {host}” when switching the session’s billing — that uses the host’s own login.', { host: escHtml(hostLabel) }), { type: 'error', duration: 8000 }); return; } done(); @@ -1303,9 +1342,9 @@ export function installManageAgents(App, ctx = {}) { // pick this account directly. if (isSub && selectedHost && keyRow.dataset.blocked) { items.splice(1, 0, { label: t('Log in on {host} as this account…', { host: hostLabel }), action: () => { - const dir = `$HOME/.vibespace/subs/${id}`; // id shape sub-, metachar-free - this._watchHostLogin(selectedHost, hostLabel); - run(`mkdir -p "${dir}" && CLAUDE_CONFIG_DIR="${dir}" CLAUDE_SECURESTORAGE_CONFIG_DIR="${dir}" claude /login`); + const login = remoteClaudeSubscriptionLoginCommand(id); + this._watchHostLogin(selectedHost, hostLabel, id, login.attempt); + run(login.command); showToast(t('Sign in as “{name}” in the terminal — this login lives ON {host} only; the machine’s own login is untouched.', { name: a?.name, host: hostLabel }), { duration: 7000 }); } }); } diff --git a/src/lib/session-lifecycle.js b/src/lib/session-lifecycle.js index 4257b6a9..65616839 100644 --- a/src/lib/session-lifecycle.js +++ b/src/lib/session-lifecycle.js @@ -659,6 +659,7 @@ export function installSessionLifecycle(App, ctx = {}) { if (!isSub) return null; // API keys always ship if (hostLinked(a)) return null; // = the host's own login — usable directly if (hostSubHeld(a)) return null; // has its own login held ON the host + if (a.localOnly) return t('This macOS Keychain-backed login stays on this machine. Log in as this account on {host} instead; copying it can invalidate rotating OAuth credentials.', { host: rHostName }); if (rTransport === 'dial') return t('Subscription logins can’t ship to a paired device — log in on the device, or use an API-key account'); if (!shipSubs) return t('This stored login can’t ship to {host}. If you’ve logged this account in ON {host}, pick “CLI login @ {host}” above — that uses the host’s own login. (Or enable Settings → “Ship subscription logins to remote hosts”.)', { host: rHostName }); return null; diff --git a/src/lib/session-props.js b/src/lib/session-props.js index 3270a14d..52ca7683 100644 --- a/src/lib/session-props.js +++ b/src/lib/session-props.js @@ -157,7 +157,17 @@ export function openSessionProps(app, sessionRef, { syncId } = {}) { const rHost = s.host || null; const rTransport = rHost ? (sidebar._hostsData?.hosts?.find(h => h.id === rHost)?.transport || 'ssh') : null; const shipSubs = !!app.settings?.get?.('accounts.shipSubscriptionToRemote'); - const subBlocked = (x) => rHost && (sbe === 'codex' || x.type === 'subscription') && (rTransport === 'dial' || !shipSubs); + const hostOwnEmail = rHost + ? String(app._hostOwnUsage?.[rHost]?.orgEmail || '').trim().toLowerCase() + : ''; + const acctEmailOf = (x) => String(x.email || (String(x.name || '').includes('@') ? x.name : '')).trim().toLowerCase(); + const hostLinked = (x) => sbe !== 'codex' && !!hostOwnEmail && acctEmailOf(x) === hostOwnEmail; + const hostSubHeld = (x) => sbe !== 'codex' && (app._hostSubsKnown?.[rHost] || []).includes(x.id); + const subBlocked = (x) => rHost + && (sbe === 'codex' || x.type === 'subscription') + && !hostLinked(x) + && !hostSubHeld(x) + && (rTransport === 'dial' || !shipSubs || x.localOnly); for (const [v, label, blocked] of [['', t('Default')], ['subscription', globalLabel], ...accts.map(x => [x.id, x.type === 'subscription' ? `${x.name} (${t('subscription')})` : `${x.name} — API …${x.tail}`, subBlocked(x)])]) { const o = document.createElement('option'); o.value = v; o.textContent = blocked ? label + ' · ' + t('blocked on this host') : label; if (blocked) { o.disabled = true; o.title = t('Subscription logins don’t ship to this machine — log in there, or use an API-key account'); } diff --git a/src/ws-handler.js b/src/ws-handler.js index 775007a2..eee6f799 100644 --- a/src/ws-handler.js +++ b/src/ws-handler.js @@ -425,7 +425,7 @@ function registerWsHandler(wss, ctx) { if (spawnAccount?.remoteCreds && data.hostId && !data.accountId) { let allowShip = false; try { allowShip = !!serverSetting('accounts.shipSubscriptionToRemote'); } catch {} - if (!allowShip) spawnAccount = null; // = the host's own login + if (!allowShip || spawnAccount.remoteCreds.shippable === false) spawnAccount = null; // = the host's own login } // EXPLICITLY-chosen subscription on a remote host without the // ship opt-in: when the account IS the machine's own login (same @@ -437,7 +437,7 @@ function registerWsHandler(wss, ctx) { if (spawnAccount?.remoteCreds && data.hostId && data.accountId && hosts) { let allowShip = false; try { allowShip = !!serverSetting('accounts.shipSubscriptionToRemote'); } catch {} - if (!allowShip) { + if (!allowShip || spawnAccount.remoteCreds.shippable === false) { try { const rs = await hosts.accountsStatus(data.hostId); // (a) The account IS the host's own login (same email) → @@ -541,6 +541,11 @@ function registerWsHandler(wss, ctx) { // VIBESPACE_SESSION_TOKEN (Ctrl+G editor auth; inert without the // api var — every consumer guards on both). Read per spawn = live. let integrationOn = integrationEnabled ? integrationEnabled() : true; + // A remote Add-subscription helper terminal needs this one transport + // utility even when agent-visible Integration is OFF. It handles the + // host's own Keychain/file login only and exposes nothing to agents. + const needsClaudeLoginHelper = backend === 'shell' + && String(data.initialCommand || '').includes('/vibespace-claude-subscription-login.mjs'); // Remote agent enablement (P3): a remote session can't reach the local // API at 127.0.0.1:, and the vibespace-status/-task tools don't // exist on the remote box. So for any remote session we (1) open an @@ -562,22 +567,28 @@ function registerWsHandler(wss, ctx) { // 0600 files, the inner command references the token via a // `VAR="$(cat …)"` shell prefix so the value never enters // any argv), then hook-register + tools PATH in the prelude. - // OFF → pristine spawn: ship ONLY the transport keeper, and only - // for CHAT (remote chat persistence rides it, invoked by - // absolute path — terminal uses remote dtach and needs - // nothing); no hook-register, no token, no tools PATH, no - // VIBESPACE_API reverse tunnel. A hook a PREVIOUS spawn - // registered on the host stays inert (it guards on env we - // no longer pass) — Manage Agents → host → Remove strips it. + // OFF → pristine agent spawn: ship only model-invisible transport + // utilities as needed — the keeper for CHAT persistence and + // the Claude login helper for an explicit Add-subscription + // shell. No hook-register, token, tools PATH, or VIBESPACE_API + // reverse tunnel. A hook a PREVIOUS spawn registered on the + // host stays inert (it guards on env we no longer pass) — + // Manage Agents → host → Remove strips it. // + the fake `code` editor helper (remote Ctrl+G, B-2de8) — moved // OUT of the PATH dir after extract: its basename must be `code` // (claude's GUI-editor check) but shadowing a real vscode `code` // on the host's PATH would hang any `code …` shell command. const names = integrationOn ? [...require('./hosts').HostManager.AGENT_TOOLS, 'code'] - : (sessionMode === 'chat' ? ['vibespace-remote-keeper'] : []); + : [ + ...(sessionMode === 'chat' ? ['vibespace-remote-keeper'] : []), + ...(needsClaudeLoginHelper ? ['vibespace-claude-subscription-login.mjs'] : []), + ]; const toolDir = path.dirname(EDITOR_CMD); const present = names.filter((n) => { try { return fs.statSync(path.join(toolDir, n)).isFile(); } catch { return false; } }); + if (needsClaudeLoginHelper && !present.includes('vibespace-claude-subscription-login.mjs')) { + throw new Error('Claude subscription login helper is unavailable on the VibeSpace server'); + } if (present.length) { try { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vs-tok-')); @@ -600,9 +611,10 @@ function registerWsHandler(wss, ctx) { } } finally { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch {} } } catch (e) { - // tool shipping failed (an unreachable host fails the create - // later anyway) — session still runs, agent tools just degrade + // Ordinary agent-tool shipping is best-effort; the explicit + // login terminal's helper is required and must fail closed. console.error('[remote] tool distribution failed:', e.message); + if (needsClaudeLoginHelper) throw e; } } if (!integrationOn) return { prelude, envPairs: [], tokenAssign: '', reverse: null }; @@ -633,19 +645,28 @@ function registerWsHandler(wss, ctx) { // daemon IS the persistence layer). Only BILLING (an API-key file) // still needs device round trips; with no key either, there is // nothing to place at all. - if (!integrationOn && !spawnAccount?.secret) return { envPairs: [], tokenAssign: '' }; + if (!integrationOn && !spawnAccount?.secret && !needsClaudeLoginHelper) return { envPairs: [], tokenAssign: '' }; const dm = await hosts.device(h.id); // dial → deviceForDial const home = String((await dm.runCmd('sh', ['-c', 'printf %s "$HOME"'], { timeoutMs: 8000 }))?.stdout || '').trim() || '/root'; const bin = `${home}/.vibespace/bin`; const tokName = `.tok-${sid}`; let rf = null; - if (integrationOn) { + if (integrationOn || needsClaudeLoginHelper) { await dm.fsMkdir(bin); const toolDir = path.dirname(EDITOR_CMD); - const names = require('./hosts').HostManager.AGENT_TOOLS; + const names = integrationOn + ? require('./hosts').HostManager.AGENT_TOOLS + : ['vibespace-claude-subscription-login.mjs']; for (const n of names) { - try { const buf = fs.readFileSync(path.join(toolDir, n)); await dm.fsWrite(`${bin}/${n}`, buf); } catch { } + try { + const buf = fs.readFileSync(path.join(toolDir, n)); + await dm.fsWrite(`${bin}/${n}`, buf); + } catch (e) { + if (needsClaudeLoginHelper && n === 'vibespace-claude-subscription-login.mjs') throw e; + } } + } + if (integrationOn) { // fake `code` editor helper (remote Ctrl+G, B-2de8) — OUTSIDE the // PATH dir so it can't shadow a real vscode `code` on the device try { @@ -721,6 +742,9 @@ function registerWsHandler(wss, ctx) { { input: spawnAccount.secret.value, timeout: 15000 }); return `${spawnAccount.secret.var}="$(cat "${kf}")" `; } + if (spawnAccount.remoteCreds?.shippable === false) { + throw new Error('this macOS Keychain-backed subscription login cannot be copied to another machine because OAuth refresh tokens rotate. Log in as this account on the host instead, or use an API-key account.'); + } // §ban-safety GATE: shipping a SUBSCRIPTION's OAuth creds to a remote // host means that subscription token is live from a (likely // datacenter) IP different from where you normally use it — an @@ -817,7 +841,7 @@ function registerWsHandler(wss, ctx) { // an API key must be placed — a swallowed failure would run the // session on the device's own login = wrong billing (review). const da = await deviceAgentSetup(h, id).catch((e) => { - if (spawnAccount?.secret) throw e; + if (spawnAccount?.secret || needsClaudeLoginHelper) throw e; console.warn('[dial] agent setup degraded:', e.message); return { envPairs: [], tokenAssign: '' }; }); // tools PATH only while integrated — leftover tools from an @@ -1002,7 +1026,7 @@ done`; // its original env, and a fresh reverseForward here would leak // an unused device port per attach. const da = dialKeeperSid ? { envPairs: [], tokenAssign: '' } : await deviceAgentSetup(h, id).catch((e) => { - if (spawnAccount?.secret) throw e; // wrong billing must fail, not silently degrade + if (spawnAccount?.secret || needsClaudeLoginHelper) throw e; // required setup must fail, not silently degrade console.warn('[dial] agent setup degraded:', e.message); return { envPairs: [], tokenAssign: '' }; }); // tools PATH only while integrated (see the pty branch note)