From 53a0185dd82b5e9b23e20c727235520d62a6d272 Mon Sep 17 00:00:00 2001 From: Ame Date: Mon, 1 Jun 2026 17:27:50 +0800 Subject: [PATCH 1/3] fix(workspaces): exclude Codex key files from workspace git MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup_git_excludes only guarded .claude/settings.local.json and .codex/auth.json, but OpenAlice's per-workspace Codex config (writeCodexConfig) writes the API key to .codex/env.json (OPENALICE_WORKSPACE_KEY) and a possibly-private base_url to .codex/config.toml — neither was excluded. auto-quant workspaces are clones of the public TraderAlice/Auto-Quant on a pushable autoresearch/ branch; the bootstrap comment explicitly says the excludes exist to keep secrets "out of any push to upstream Auto-Quant" — but it missed the file that actually holds the key. A user who set a custom Codex provider on an auto-quant workspace and pushed the branch would have leaked their key. Add both files to the default block in _common.sh:setup_git_excludes — one place, covers chat/finance/auto-quant and any future template. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/workspaces/templates/_common.sh | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/workspaces/templates/_common.sh b/src/workspaces/templates/_common.sh index 5ca3559e8..f1907e080 100755 --- a/src/workspaces/templates/_common.sh +++ b/src/workspaces/templates/_common.sh @@ -108,6 +108,8 @@ copy_readme() { # Always includes: # - .claude/settings.local.json (workspace-specific Claude config) # - .codex/auth.json (workspace-local Codex auth) +# - .codex/env.json (workspace-local Codex API-key bridge) +# - .codex/config.toml (workspace-local Codex provider config) # Extra paths passed as args are appended too — useful for templates that # clone third-party content (e.g. finance-research clones .finance-skills/ # and doesn't want git add . to swallow it). @@ -120,6 +122,8 @@ setup_git_excludes() { { echo '.claude/settings.local.json' echo '.codex/auth.json' + echo '.codex/env.json' + echo '.codex/config.toml' for extra in "$@"; do echo "$extra" done From 7420909d91f472b27c07b84b78ce404b4875e989 Mon Sep 17 00:00:00 2001 From: Ame Date: Mon, 1 Jun 2026 17:42:33 +0800 Subject: [PATCH 2/3] refactor(workspaces): unify AI-config IO behind CliAdapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per-workspace AI-provider config (key/endpoint/model) was written by four CLI-specific functions living in the webui route file (readClaudeConfig/writeClaudeConfig/readCodexConfig/writeCodexConfig), dispatched ad-hoc from the route handlers. That put per-CLI file-format logic in the wrong layer and duplicated the dispatch. Standardize the contract, keep the per-CLI format behind it: - New WorkspaceAiCred (superset shape: authMode is Claude-only, wireApi Codex-only) + optional writeAiConfig/readAiConfig on the CliAdapter interface (cli-adapter.ts). - Move the four function bodies verbatim into claudeAdapter / codexAdapter (+ the tomlString helper and path/env-name constants). shell has none. - Thin GET/PUT /:id/agent-config to dispatch via svc.adapters.get(agent) — response shape unchanged. Behavior-preserving: a golden spec (ai-config.spec.ts) asserts the exact bytes the pre-move writers produced for both CLIs across the full input matrix (x-api-key / bearer / model-only / reset / chat / responses) plus read round-trips. tsc --noEmit clean; full suite 1713 passed. This is the seam a future config-push (apply-profile-to-workspaces) will build on. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/webui/routes/workspaces.ts | 187 ++-------------------- src/workspaces/adapters/ai-config.spec.ts | 115 +++++++++++++ src/workspaces/adapters/claude.ts | 56 ++++++- src/workspaces/adapters/codex.ts | 95 ++++++++++- src/workspaces/cli-adapter.ts | 30 ++++ 5 files changed, 306 insertions(+), 177 deletions(-) create mode 100644 src/workspaces/adapters/ai-config.spec.ts diff --git a/src/webui/routes/workspaces.ts b/src/webui/routes/workspaces.ts index 7ba2ddc64..af158d61f 100644 --- a/src/webui/routes/workspaces.ts +++ b/src/webui/routes/workspaces.ts @@ -8,15 +8,16 @@ import { Hono } from 'hono'; import { randomUUID } from 'node:crypto'; -import { readFile, rm } from 'node:fs/promises'; +import { readFile } from 'node:fs/promises'; import { join, resolve as resolvePath } from 'node:path'; import { probeAnthropic, probeOpenAI } from '../../workspaces/agent-probe.js'; -import { listDir, PathTraversal, readWorkspaceFile, writeWorkspaceFile } from '../../workspaces/file-service.js'; +import { listDir, PathTraversal, readWorkspaceFile } from '../../workspaces/file-service.js'; import { gitLog, gitStatus } from '../../workspaces/git-service.js'; import { logger as launcherLogger } from '../../workspaces/logger.js'; import type { SessionRecord } from '../../workspaces/session-registry.js'; import { resumeFromRecord, type SessionFactoryContext, type WorkspaceService } from '../../workspaces/service.js'; +import type { WorkspaceAiCred } from '../../workspaces/cli-adapter.js'; const SESSION_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -729,8 +730,8 @@ export function createWorkspaceRoutes(svc: WorkspaceService): Hono { if (!meta) return c.json({ error: 'not_found' }, 404); try { const [claude, codex] = await Promise.all([ - readClaudeConfig(meta.dir), - readCodexConfig(meta.dir), + svc.adapters.get('claude')?.readAiConfig?.(meta.dir) ?? null, + svc.adapters.get('codex')?.readAiConfig?.(meta.dir) ?? null, ]); return c.json({ claude, codex }); } catch (err) { @@ -748,14 +749,12 @@ export function createWorkspaceRoutes(svc: WorkspaceService): Hono { const meta = svc.registry.get(id); if (!meta) return c.json({ error: 'not_found' }, 404); - const body = (await safeJson(c)) as AgentConfigInput | null; + const body = (await safeJson(c)) as WorkspaceAiCred | null; const cfg = body && typeof body === 'object' ? body : {}; try { - if (agent === 'claude') { - await writeClaudeConfig(meta.dir, cfg); - } else { - await writeCodexConfig(meta.dir, cfg); - } + const adapter = svc.adapters.get(agent); + if (!adapter?.writeAiConfig) return c.json({ error: 'unknown_agent' }, 400); + await adapter.writeAiConfig(meta.dir, cfg); launcherLogger.info('agent_config.saved', { id, agent }); return c.json({ ok: true }); } catch (err) { @@ -775,7 +774,7 @@ export function createWorkspaceRoutes(svc: WorkspaceService): Hono { return c.json({ ok: false, error: 'unknown_agent' }, 400); } - const body = (await safeJson(c)) as AgentConfigInput | null; + const body = (await safeJson(c)) as WorkspaceAiCred | null; const baseUrl = typeof body?.baseUrl === 'string' ? body.baseUrl.trim() : ''; const apiKey = typeof body?.apiKey === 'string' ? body.apiKey.trim() : ''; const model = typeof body?.model === 'string' ? body.model.trim() : ''; @@ -818,169 +817,9 @@ interface ProfileShape { authMode?: unknown; } -interface AgentConfigInput { - baseUrl?: string; - apiKey?: string; - model?: string; - wireApi?: 'chat' | 'responses'; - /** Claude only — which header carries the key (see ClaudeProbeInput). */ - authMode?: 'x-api-key' | 'bearer'; -} - -interface ClaudeConfigShape { - baseUrl: string | null; - apiKey: string | null; - model: string | null; - authMode: 'x-api-key' | 'bearer'; -} - -interface CodexConfigShape { - baseUrl: string | null; - apiKey: string | null; - model: string | null; - wireApi: 'chat' | 'responses' | null; -} - -const CLAUDE_SETTINGS_PATH = '.claude/settings.local.json'; -const CODEX_CONFIG_PATH = '.codex/config.toml'; -const CODEX_ENV_PATH = '.codex/env.json'; -const CODEX_KEY_ENV_NAME = 'OPENALICE_WORKSPACE_KEY'; -const CODEX_PROVIDER_NAME = 'workspace'; - -async function readClaudeConfig(workspaceDir: string): Promise { - const raw = await readWorkspaceFile(workspaceDir, CLAUDE_SETTINGS_PATH); - if (raw === null) return null; - let parsed: Record; - try { - parsed = JSON.parse(raw) as Record; - } catch { - return null; - } - const env = (parsed['env'] ?? {}) as Record; - const baseUrl = typeof env['ANTHROPIC_BASE_URL'] === 'string' ? (env['ANTHROPIC_BASE_URL'] as string) : null; - // The key lives in exactly one of two env vars depending on auth mode: - // ANTHROPIC_API_KEY → x-api-key header, ANTHROPIC_AUTH_TOKEN → Bearer. - // Which one is present tells us the mode to surface back to the modal. - const xApiKey = typeof env['ANTHROPIC_API_KEY'] === 'string' ? (env['ANTHROPIC_API_KEY'] as string) : null; - const authToken = typeof env['ANTHROPIC_AUTH_TOKEN'] === 'string' ? (env['ANTHROPIC_AUTH_TOKEN'] as string) : null; - const authMode: 'x-api-key' | 'bearer' = authToken !== null ? 'bearer' : 'x-api-key'; - const apiKey = authToken ?? xApiKey; - const model = typeof parsed['model'] === 'string' ? (parsed['model'] as string) : null; - if (baseUrl === null && apiKey === null && model === null) return null; - return { baseUrl, apiKey, model, authMode }; -} - -async function writeClaudeConfig(workspaceDir: string, cfg: AgentConfigInput): Promise { - const hasAny = cfg.baseUrl || cfg.apiKey || cfg.model; - if (!hasAny) { - // Reset: delete the settings file so claude falls back to its global - // OAuth / settings. We don't leave an empty `{}` behind — workspace - // files exist only when there's an actual override. - const filePath = join(workspaceDir, CLAUDE_SETTINGS_PATH); - await rm(filePath, { force: true }); - return; - } - const out: Record = {}; - const env: Record = {}; - if (cfg.baseUrl) env['ANTHROPIC_BASE_URL'] = cfg.baseUrl; - // Write the key into exactly one env var. Bearer-mode gateways (MiniMax - // international, proxy front-ends) read ANTHROPIC_AUTH_TOKEN → the CLI sends - // `Authorization: Bearer`. Default x-api-key mode uses ANTHROPIC_API_KEY. - // Never write both: Claude Code warns on dual-set, and the two headers - // together can be rejected as ambiguous auth. - if (cfg.apiKey) { - if (cfg.authMode === 'bearer') env['ANTHROPIC_AUTH_TOKEN'] = cfg.apiKey; - else env['ANTHROPIC_API_KEY'] = cfg.apiKey; - } - if (Object.keys(env).length > 0) out['env'] = env; - if (cfg.model) out['model'] = cfg.model; - await writeWorkspaceFile(workspaceDir, CLAUDE_SETTINGS_PATH, JSON.stringify(out, null, 2) + '\n'); -} - -async function readCodexConfig(workspaceDir: string): Promise { - const tomlRaw = await readWorkspaceFile(workspaceDir, CODEX_CONFIG_PATH); - const envRaw = await readWorkspaceFile(workspaceDir, CODEX_ENV_PATH); - if (tomlRaw === null && envRaw === null) return null; - - let baseUrl: string | null = null; - let wireApi: 'chat' | 'responses' | null = null; - let model: string | null = null; - if (tomlRaw) { - // Shape-specific extraction: we always write the provider section as - // `[model_providers.workspace]` with `base_url`, `wire_api`, plus - // top-level `model`. Regex is brittle in general but our shape is - // controlled (writer below produces deterministic output). - const providerBlock = tomlRaw.match(/\[model_providers\.workspace\][^\[]*/); - if (providerBlock) { - const block = providerBlock[0]; - const base = block.match(/base_url\s*=\s*"([^"]*)"/); - if (base) baseUrl = base[1] ?? null; - const wire = block.match(/wire_api\s*=\s*"(chat|responses)"/); - if (wire) wireApi = wire[1] as 'chat' | 'responses'; - } - const modelMatch = tomlRaw.match(/^model\s*=\s*"([^"]*)"\s*$/m); - if (modelMatch) model = modelMatch[1] ?? null; - } - - let apiKey: string | null = null; - if (envRaw) { - try { - const env = JSON.parse(envRaw) as Record; - const k = env[CODEX_KEY_ENV_NAME]; - if (typeof k === 'string') apiKey = k; - } catch { /* ignore parse error, leave apiKey null */ } - } - - if (baseUrl === null && apiKey === null && model === null && wireApi === null) return null; - return { baseUrl, apiKey, model, wireApi }; -} - -async function writeCodexConfig(workspaceDir: string, cfg: AgentConfigInput): Promise { - const hasProvider = !!(cfg.baseUrl || cfg.model); - - if (!hasProvider) { - // Reset: tear down the workspace's entire `.codex/` directory. The - // adapter's `composeEnv` won't set `CODEX_HOME` when the directory is - // absent, so codex falls back to the user's global `~/.codex/`. We - // don't leave empty stubs behind — workspace files exist only when - // there's an actual override. Note: `CODEX_HOME` is exclusive (not a - // merge layer), so a half-empty `.codex/` would *shadow* the user's - // global login and break auth. Full teardown is the only safe reset. - const codexDir = join(workspaceDir, '.codex'); - await rm(codexDir, { recursive: true, force: true }); - return; - } - - // Provider override. config.toml carries only model / model_provider / - // [model_providers.*] — the OpenAlice MCP server entry is wired per-spawn - // via the codex adapter's `-c mcp_servers.openalice.url=...` flag, so we - // don't repeat it here. - let toml = ''; - if (cfg.model) toml += `model = ${tomlString(cfg.model)}\n`; - if (cfg.baseUrl) toml += `model_provider = "${CODEX_PROVIDER_NAME}"\n`; - if (cfg.baseUrl) { - toml += '\n'; - toml += `[model_providers.${CODEX_PROVIDER_NAME}]\n`; - toml += `name = "OpenAlice workspace provider"\n`; - toml += `base_url = ${tomlString(cfg.baseUrl)}\n`; - toml += `env_key = "${CODEX_KEY_ENV_NAME}"\n`; - toml += `wire_api = "${cfg.wireApi ?? 'chat'}"\n`; - } - await writeWorkspaceFile(workspaceDir, CODEX_CONFIG_PATH, toml); - - // env.json: holds the per-workspace API key codex picks up via env_key. - // Adapter's composeEnv reads this and exports at spawn. - if (cfg.apiKey) { - const envObj: Record = { [CODEX_KEY_ENV_NAME]: cfg.apiKey }; - await writeWorkspaceFile(workspaceDir, CODEX_ENV_PATH, JSON.stringify(envObj, null, 2) + '\n'); - } else { - await writeWorkspaceFile(workspaceDir, CODEX_ENV_PATH, '{}\n'); - } -} - -function tomlString(s: string): string { - return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; -} +// AI-provider config IO moved into the CLI adapters (writeAiConfig / +// readAiConfig on claudeAdapter / codexAdapter). The routes above dispatch +// through svc.adapters so each CLI owns its own file format. function validId(id: string | undefined): id is string { return typeof id === 'string' && /^[a-zA-Z0-9_-]+$/.test(id); diff --git a/src/workspaces/adapters/ai-config.spec.ts b/src/workspaces/adapters/ai-config.spec.ts new file mode 100644 index 000000000..642f6b1e0 --- /dev/null +++ b/src/workspaces/adapters/ai-config.spec.ts @@ -0,0 +1,115 @@ +/** + * Characterization / golden test for the per-workspace AI-config writers after + * they moved out of the webui routes into the CLI adapters (Phase A). The + * asserted bytes are exactly what the pre-move route-level writers produced — + * this is the regression guard proving the move is behavior-preserving. + */ + +import { existsSync } from 'node:fs'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { claudeAdapter } from './claude.js'; +import { codexAdapter } from './codex.js'; + +let dir: string; + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'aicfg-')); +}); +afterEach(async () => { + await rm(dir, { recursive: true, force: true }); +}); + +const read = (rel: string): Promise => readFile(join(dir, rel), 'utf8'); + +describe('claudeAdapter AI-config', () => { + it('writes full x-api-key config byte-exact', async () => { + await claudeAdapter.writeAiConfig!(dir, { + baseUrl: 'https://api.test/v1', apiKey: 'sk-123', model: 'claude-x', authMode: 'x-api-key', + }); + expect(await read('.claude/settings.local.json')).toBe( + '{\n "env": {\n "ANTHROPIC_BASE_URL": "https://api.test/v1",\n "ANTHROPIC_API_KEY": "sk-123"\n },\n "model": "claude-x"\n}\n', + ); + }); + + it('writes the key into ANTHROPIC_AUTH_TOKEN in bearer mode', async () => { + await claudeAdapter.writeAiConfig!(dir, { + baseUrl: 'https://g/v1', apiKey: 'k', model: 'm', authMode: 'bearer', + }); + expect(await read('.claude/settings.local.json')).toBe( + '{\n "env": {\n "ANTHROPIC_BASE_URL": "https://g/v1",\n "ANTHROPIC_AUTH_TOKEN": "k"\n },\n "model": "m"\n}\n', + ); + }); + + it('writes a model-only config with no env block', async () => { + await claudeAdapter.writeAiConfig!(dir, { model: 'm' }); + expect(await read('.claude/settings.local.json')).toBe('{\n "model": "m"\n}\n'); + }); + + it('reset (empty cred) deletes the settings file', async () => { + await claudeAdapter.writeAiConfig!(dir, { model: 'm' }); + await claudeAdapter.writeAiConfig!(dir, {}); + expect(existsSync(join(dir, '.claude/settings.local.json'))).toBe(false); + }); + + it('round-trips through readAiConfig', async () => { + await claudeAdapter.writeAiConfig!(dir, { + baseUrl: 'https://api.test/v1', apiKey: 'sk-123', model: 'claude-x', authMode: 'bearer', + }); + expect(await claudeAdapter.readAiConfig!(dir)).toEqual({ + baseUrl: 'https://api.test/v1', apiKey: 'sk-123', model: 'claude-x', authMode: 'bearer', + }); + }); + + it('readAiConfig returns null when no file exists', async () => { + expect(await claudeAdapter.readAiConfig!(dir)).toBeNull(); + }); +}); + +describe('codexAdapter AI-config', () => { + it('writes full provider config byte-exact (config.toml + env.json)', async () => { + await codexAdapter.writeAiConfig!(dir, { + baseUrl: 'https://oai.test/v1', apiKey: 'sk-c', model: 'gpt-x', wireApi: 'responses', + }); + expect(await read('.codex/config.toml')).toBe( + 'model = "gpt-x"\nmodel_provider = "workspace"\n\n' + + '[model_providers.workspace]\nname = "OpenAlice workspace provider"\n' + + 'base_url = "https://oai.test/v1"\nenv_key = "OPENALICE_WORKSPACE_KEY"\nwire_api = "responses"\n', + ); + expect(await read('.codex/env.json')).toBe('{\n "OPENALICE_WORKSPACE_KEY": "sk-c"\n}\n'); + }); + + it('defaults wire_api to chat when unset', async () => { + await codexAdapter.writeAiConfig!(dir, { baseUrl: 'https://oai.test/v1', apiKey: 'sk-c', model: 'gpt-x' }); + expect(await read('.codex/config.toml')).toContain('wire_api = "chat"\n'); + }); + + it('model-only writes no provider block and an empty env.json', async () => { + await codexAdapter.writeAiConfig!(dir, { model: 'gpt-y' }); + expect(await read('.codex/config.toml')).toBe('model = "gpt-y"\n'); + expect(await read('.codex/env.json')).toBe('{}\n'); + }); + + it('reset (empty cred) tears down the entire .codex/ directory', async () => { + await codexAdapter.writeAiConfig!(dir, { baseUrl: 'u', model: 'm' }); + await codexAdapter.writeAiConfig!(dir, {}); + expect(existsSync(join(dir, '.codex'))).toBe(false); + }); + + it('round-trips through readAiConfig', async () => { + await codexAdapter.writeAiConfig!(dir, { + baseUrl: 'https://oai.test/v1', apiKey: 'sk-c', model: 'gpt-x', wireApi: 'responses', + }); + expect(await codexAdapter.readAiConfig!(dir)).toEqual({ + baseUrl: 'https://oai.test/v1', apiKey: 'sk-c', model: 'gpt-x', wireApi: 'responses', + }); + }); + + it('readAiConfig returns null when no files exist', async () => { + expect(await codexAdapter.readAiConfig!(dir)).toBeNull(); + }); +}); diff --git a/src/workspaces/adapters/claude.ts b/src/workspaces/adapters/claude.ts index be96cfe04..f2ac0b665 100644 --- a/src/workspaces/adapters/claude.ts +++ b/src/workspaces/adapters/claude.ts @@ -1,10 +1,14 @@ +import { rm } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; -import type { CliAdapter, SpawnContext } from '../cli-adapter.js'; +import type { CliAdapter, SpawnContext, WorkspaceAiCred } from '../cli-adapter.js'; +import { readWorkspaceFile, writeWorkspaceFile } from '../file-service.js'; const SESSION_FILE_RE = /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; +const CLAUDE_SETTINGS_PATH = '.claude/settings.local.json'; + /** dashed-cwd convention used by Claude Code's project store. */ function projectKey(workspaceDir: string): string { const abs = resolve(workspaceDir); @@ -52,6 +56,56 @@ export const claudeAdapter: CliAdapter = { return [...base, '--resume', ctx.resume.sessionId]; }, + async writeAiConfig(cwd: string, cred: WorkspaceAiCred): Promise { + const hasAny = cred.baseUrl || cred.apiKey || cred.model; + if (!hasAny) { + // Reset: delete the settings file so claude falls back to its global + // OAuth / settings. We don't leave an empty `{}` behind — workspace + // files exist only when there's an actual override. + const filePath = join(cwd, CLAUDE_SETTINGS_PATH); + await rm(filePath, { force: true }); + return; + } + const out: Record = {}; + const env: Record = {}; + if (cred.baseUrl) env['ANTHROPIC_BASE_URL'] = cred.baseUrl; + // Write the key into exactly one env var. Bearer-mode gateways (MiniMax + // international, proxy front-ends) read ANTHROPIC_AUTH_TOKEN → the CLI sends + // `Authorization: Bearer`. Default x-api-key mode uses ANTHROPIC_API_KEY. + // Never write both: Claude Code warns on dual-set, and the two headers + // together can be rejected as ambiguous auth. + if (cred.apiKey) { + if (cred.authMode === 'bearer') env['ANTHROPIC_AUTH_TOKEN'] = cred.apiKey; + else env['ANTHROPIC_API_KEY'] = cred.apiKey; + } + if (Object.keys(env).length > 0) out['env'] = env; + if (cred.model) out['model'] = cred.model; + await writeWorkspaceFile(cwd, CLAUDE_SETTINGS_PATH, JSON.stringify(out, null, 2) + '\n'); + }, + + async readAiConfig(cwd: string): Promise { + const raw = await readWorkspaceFile(cwd, CLAUDE_SETTINGS_PATH); + if (raw === null) return null; + let parsed: Record; + try { + parsed = JSON.parse(raw) as Record; + } catch { + return null; + } + const env = (parsed['env'] ?? {}) as Record; + const baseUrl = typeof env['ANTHROPIC_BASE_URL'] === 'string' ? (env['ANTHROPIC_BASE_URL'] as string) : null; + // The key lives in exactly one of two env vars depending on auth mode: + // ANTHROPIC_API_KEY → x-api-key header, ANTHROPIC_AUTH_TOKEN → Bearer. + // Which one is present tells us the mode to surface back to the modal. + const xApiKey = typeof env['ANTHROPIC_API_KEY'] === 'string' ? (env['ANTHROPIC_API_KEY'] as string) : null; + const authToken = typeof env['ANTHROPIC_AUTH_TOKEN'] === 'string' ? (env['ANTHROPIC_AUTH_TOKEN'] as string) : null; + const authMode: 'x-api-key' | 'bearer' = authToken !== null ? 'bearer' : 'x-api-key'; + const apiKey = authToken ?? xApiKey; + const model = typeof parsed['model'] === 'string' ? (parsed['model'] as string) : null; + if (baseUrl === null && apiKey === null && model === null) return null; + return { baseUrl, apiKey, model, authMode }; + }, + transcriptDir(cwd: string): string { return join(homedir(), '.claude', 'projects', projectKey(cwd)); }, diff --git a/src/workspaces/adapters/codex.ts b/src/workspaces/adapters/codex.ts index f4d0819eb..493a53b29 100644 --- a/src/workspaces/adapters/codex.ts +++ b/src/workspaces/adapters/codex.ts @@ -1,9 +1,15 @@ import { existsSync, readFileSync } from 'node:fs'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; -import type { BootstrapContext, CliAdapter, SpawnContext } from '../cli-adapter.js'; +import type { BootstrapContext, CliAdapter, SpawnContext, WorkspaceAiCred } from '../cli-adapter.js'; +import { readWorkspaceFile, writeWorkspaceFile } from '../file-service.js'; + +const CODEX_CONFIG_PATH = '.codex/config.toml'; +const CODEX_ENV_PATH = '.codex/env.json'; +const CODEX_KEY_ENV_NAME = 'OPENALICE_WORKSPACE_KEY'; +const CODEX_PROVIDER_NAME = 'workspace'; /** * OpenAI Codex CLI (Rust rewrite, `codex-cli`). @@ -81,6 +87,87 @@ export const codexAdapter: CliAdapter = { return [...head, 'resume', ctx.resume.sessionId]; }, + async writeAiConfig(cwd: string, cred: WorkspaceAiCred): Promise { + const hasProvider = !!(cred.baseUrl || cred.model); + + if (!hasProvider) { + // Reset: tear down the workspace's entire `.codex/` directory. The + // adapter's `composeEnv` won't set `CODEX_HOME` when the directory is + // absent, so codex falls back to the user's global `~/.codex/`. We + // don't leave empty stubs behind — workspace files exist only when + // there's an actual override. Note: `CODEX_HOME` is exclusive (not a + // merge layer), so a half-empty `.codex/` would *shadow* the user's + // global login and break auth. Full teardown is the only safe reset. + const codexDir = join(cwd, '.codex'); + await rm(codexDir, { recursive: true, force: true }); + return; + } + + // Provider override. config.toml carries only model / model_provider / + // [model_providers.*] — the OpenAlice MCP server entry is wired per-spawn + // via this adapter's `-c mcp_servers.openalice.url=...` flag, so we + // don't repeat it here. + let toml = ''; + if (cred.model) toml += `model = ${tomlString(cred.model)}\n`; + if (cred.baseUrl) toml += `model_provider = "${CODEX_PROVIDER_NAME}"\n`; + if (cred.baseUrl) { + toml += '\n'; + toml += `[model_providers.${CODEX_PROVIDER_NAME}]\n`; + toml += `name = "OpenAlice workspace provider"\n`; + toml += `base_url = ${tomlString(cred.baseUrl)}\n`; + toml += `env_key = "${CODEX_KEY_ENV_NAME}"\n`; + toml += `wire_api = "${cred.wireApi ?? 'chat'}"\n`; + } + await writeWorkspaceFile(cwd, CODEX_CONFIG_PATH, toml); + + // env.json: holds the per-workspace API key codex picks up via env_key. + // composeEnv reads this and exports at spawn. + if (cred.apiKey) { + const envObj: Record = { [CODEX_KEY_ENV_NAME]: cred.apiKey }; + await writeWorkspaceFile(cwd, CODEX_ENV_PATH, JSON.stringify(envObj, null, 2) + '\n'); + } else { + await writeWorkspaceFile(cwd, CODEX_ENV_PATH, '{}\n'); + } + }, + + async readAiConfig(cwd: string): Promise { + const tomlRaw = await readWorkspaceFile(cwd, CODEX_CONFIG_PATH); + const envRaw = await readWorkspaceFile(cwd, CODEX_ENV_PATH); + if (tomlRaw === null && envRaw === null) return null; + + let baseUrl: string | null = null; + let wireApi: 'chat' | 'responses' | null = null; + let model: string | null = null; + if (tomlRaw) { + // Shape-specific extraction: we always write the provider section as + // `[model_providers.workspace]` with `base_url`, `wire_api`, plus + // top-level `model`. Regex is brittle in general but our shape is + // controlled (writer above produces deterministic output). + const providerBlock = tomlRaw.match(/\[model_providers\.workspace\][^\[]*/); + if (providerBlock) { + const block = providerBlock[0]; + const base = block.match(/base_url\s*=\s*"([^"]*)"/); + if (base) baseUrl = base[1] ?? null; + const wire = block.match(/wire_api\s*=\s*"(chat|responses)"/); + if (wire) wireApi = wire[1] as 'chat' | 'responses'; + } + const modelMatch = tomlRaw.match(/^model\s*=\s*"([^"]*)"\s*$/m); + if (modelMatch) model = modelMatch[1] ?? null; + } + + let apiKey: string | null = null; + if (envRaw) { + try { + const env = JSON.parse(envRaw) as Record; + const k = env[CODEX_KEY_ENV_NAME]; + if (typeof k === 'string') apiKey = k; + } catch { /* ignore parse error, leave apiKey null */ } + } + + if (baseUrl === null && apiKey === null && model === null && wireApi === null) return null; + return { baseUrl, apiKey, model, wireApi }; + }, + /** * Set `CODEX_HOME` only when workspace has its own `.codex/` directory * (override mode). Otherwise codex falls back to its own `~/.codex/`, @@ -160,3 +247,7 @@ async function ensureTrustedProject(cwd: string): Promise { function isENOENT(err: unknown): boolean { return typeof err === 'object' && err !== null && (err as { code?: string }).code === 'ENOENT'; } + +function tomlString(s: string): string { + return `"${s.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} diff --git a/src/workspaces/cli-adapter.ts b/src/workspaces/cli-adapter.ts index e310602f9..a478a8832 100644 --- a/src/workspaces/cli-adapter.ts +++ b/src/workspaces/cli-adapter.ts @@ -37,6 +37,26 @@ export interface BootstrapContext { readonly launcherRepoRoot: string; } +/** + * Per-workspace AI-provider override (endpoint / key / model). The launcher + * owns the *contract* — one shape, dispatched uniformly across CLIs — while + * each adapter owns the *format* (claude → `.claude/settings.local.json`, + * codex → `.codex/config.toml` + `.codex/env.json`). Superset shape: `authMode` + * is claude-only (which header carries the key), `wireApi` is codex-only + * (Responses vs Chat Completions). Fields are optional/nullable so the same + * shape serves both the write-input (absent ⇒ unset) and the read-output + * (null ⇒ not present in the file). + */ +export interface WorkspaceAiCred { + baseUrl?: string | null; + apiKey?: string | null; + model?: string | null; + /** Codex only. */ + wireApi?: 'chat' | 'responses' | null; + /** Claude only. */ + authMode?: 'x-api-key' | 'bearer'; +} + export interface EnvOverrides { /** * Substrings that, when found anywhere in an env var name, cause the var to @@ -102,6 +122,16 @@ export interface CliAdapter { */ bootstrap?(ctx: BootstrapContext): Promise; + /** + * Read/write the workspace's per-CLI AI-provider override. The launcher + * dispatches uniformly; each adapter renders the shared `WorkspaceAiCred` + * into (and parses it out of) its own native config files. An empty cred + * resets — the adapter deletes its config so the CLI falls back to global. + * Absent on adapters with no configurable provider (shell). + */ + writeAiConfig?(cwd: string, cred: WorkspaceAiCred): Promise; + readAiConfig?(cwd: string): Promise; + // ── Transcript detection (used only when capabilities.transcriptDiscovery === 'fs-watch') transcriptDir?(cwd: string): string; transcriptFileRe?: RegExp; From 286ab83bf7287a8afa3d6b0fad4d0c37540e1fb6 Mon Sep 17 00:00:00 2001 From: Ame Date: Mon, 1 Jun 2026 18:10:26 +0800 Subject: [PATCH 3/3] refactor(workspaces): launcher-owned context injection + uniform initial commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each template's bootstrap.sh hand-rolled its own context injection (MCP config, persona, skills) and initial commit. Standardize: the launcher now injects and commits uniformly, gated by template.json manifest flags; bootstrap.sh shrinks to harness-unique materialization. - Manifest flags injectMcp / injectPersona / bundledSkills (defaults preserve each template's behavior): chat = all three, finance = mcp + persona, auto-quant = none. - context-injector.ts: launcher writes the standard .mcp.json (URL placeholder kept for spawn-time resolution), composes Alice persona + the template's CLAUDE.md into CLAUDE.md/AGENTS.md, and copies bundled skills from default/skills/ into both discovery paths. Byte-identical to the old _common.sh bash (golden spec asserts it). - workspace-creator.ts: after bootstrap → inject → commit. The launcher owns the initial commit for EVERY template (no launcherCommits exemption); failure cleans up the dir and returns injection_failed. - "Harness rule": every workspace is a fresh-git repo with one clean initial commit, no inherited history, no pushable remote. auto-quant conforms by scrubbing its clone (rm -rf .git && git init), which also removes the public-remote key-leak vector Phase 0 hardened. - Moved the scan-value-chain skill (chat-bundled stopgap) → default/skills/. - Deleted the duplicated template files/mcp.json; removed the now-dead _common.sh helpers (write_mcp_config / compose_persona_claude_md / commit_initial). Verified: tsc --noEmit clean; injector golden (MCP bytes + persona composition + skills into both paths); e2e (real bootstrap → inject → commit = one clean launcher commit, clean tree); full suite 1718 passed. Co-Authored-By: Claude Opus 4.8 (1M context) --- default/skills/scan-value-chain/SKILL.md | 128 +++++++++++++ src/workspaces/context-injector.spec.ts | 110 +++++++++++ src/workspaces/context-injector.ts | 88 +++++++++ src/workspaces/service.ts | 3 + src/workspaces/template-registry.ts | 34 +++- src/workspaces/templates/_common.sh | 65 +------ .../templates/auto-quant/bootstrap.sh | 12 +- src/workspaces/templates/chat/bootstrap.sh | 21 +-- src/workspaces/templates/chat/files/mcp.json | 12 -- src/workspaces/templates/chat/template.json | 5 +- .../templates/finance-research/bootstrap.sh | 6 - .../templates/finance-research/files/mcp.json | 12 -- .../templates/finance-research/template.json | 4 +- src/workspaces/workspace-creation.e2e.spec.ts | 172 ++++++++++++++++++ src/workspaces/workspace-creator.ts | 58 ++++++ 15 files changed, 623 insertions(+), 107 deletions(-) create mode 100644 default/skills/scan-value-chain/SKILL.md create mode 100644 src/workspaces/context-injector.spec.ts create mode 100644 src/workspaces/context-injector.ts delete mode 100644 src/workspaces/templates/chat/files/mcp.json delete mode 100644 src/workspaces/templates/finance-research/files/mcp.json create mode 100644 src/workspaces/workspace-creation.e2e.spec.ts diff --git a/default/skills/scan-value-chain/SKILL.md b/default/skills/scan-value-chain/SKILL.md new file mode 100644 index 000000000..d6d9af1ac --- /dev/null +++ b/default/skills/scan-value-chain/SKILL.md @@ -0,0 +1,128 @@ +--- +name: scan-value-chain +description: > + Scan an investment theme by decomposing its value chain, then surface the + handful of names actually worth researching — each with why and the next + question. Use when the user has a theme/sector/thread but no specific + ticker yet: "what's worth looking at in semis", "scan the AI-infra space", + "I'm curious about uranium / obesity drugs / power grid", + "who are the picks-and-shovels in X", "map the supply chain for Y", + "find the names worth watching in the X value chain". This is the + have-a-theme / no-target step — + it turns "I don't know what to look at" into a short, reasoned shortlist. + Runs on OpenAlice's own MCP tools (market, equity, analysis, economy, + news) — no external data subscription needed. +--- + +# Scan a theme by its value chain + +Turn a theme the user can't yet act on into a short list of names worth +digging into. The point is NOT a data dump — it's "where is the interesting +thing, and why." + +## Data sources + +This skill is self-sufficient on OpenAlice's own MCP spine — it needs no +external data subscription to run. The agent will see the `openalice` tools +in-workspace; the ones easy to overlook and worth leaning on are the macro +series (`economyFredSeries`, `economyEnergyOutlook`, `economyPetroleumStatus`) +and the news archive (`globNews` / `grepNews`) — that top-down tie-in is the +edge a per-ticker tool can't match. + +If the workspace has other data sources wired up, use them where they help. +If the spine can't cover an angle, say so plainly rather than guessing — a +surfaced gap is more useful than a papered-over one. + +## Procedure (don't answer from memory — run the tools) + +1. **Decompose the chain, not a flat list.** Break the theme into structural + layers — upstream (inputs, equipment, IP) → midstream (manufacture, core + product) → downstream (demand, end-market). Place the real names in each + layer with `marketSearchForResearch`. The whole edge here is structural + thinking a per-ticker tool can't do: who supplies whom, where the + margin/bottleneck sits, who's a picks-and-shovels play. This is the + meta-method — apply it to ANY theme, don't hardcode one taxonomy. +2. **Quick read per node.** Across the candidates: `equityGetProfile` + (valuation snapshot), `equityGetEarningsCalendar` (near catalysts), + `calculateIndicator` (stretched vs basing on its own trend). Wide and + cheap — you're triaging, not deep-diving. +3. **Find the divergence.** Surface 3–6 names where there's something to pull + on: cheap vs its layer, margin shifting along the chain, a catalyst close, + a leader/laggard gap. Drop the rest — a scan that returns everything + returns nothing. +4. **Frame the top-down driver (OpenAlice's edge).** Is the theme live right + now? Tie it to macro: rate/capex cycle via `economyFredSeries`, energy via + the EIA tools, plus any news cluster from `grepNews`. A single-stock skill + structurally can't do this top-down tie-in — lean on it hard. +5. **Hand off to research.** For each surfaced name: one-line WHY + the next + question to answer (the "is the thesis real" question). That next question + is the baton to the deeper research step. + +## Output — persist as a file group, don't leave it in chat + +Workspace sessions can be destroyed at any time; anything not written to a +file is lost. And coding-ifying the workflow is core to this project — +research that produces no files is a contradiction. So the result of a scan +must land in files. + +- **First time on a theme:** propose a small file/directory layout and confirm + it with the user before writing — the shortlist, per-name notes, the chain + map, whatever this theme needs. Don't hardcode a layout from this skill; + settle the shape WITH the user, per theme. +- **After that:** the agreed file group IS the dossier. Every session just + CRUDs it — read it, update it, add to it. File-based, git-trackable, + survives session loss. That's the coding workflow. + +The shortlist and the per-name "next question" from the procedure above are +what get written down — so the next session starts from them, not from zero. + +## Worked example: semiconductors + +One theme, worked end to end. Decompose freshly for any other theme — don't +pattern-match these layers (a drug theme, say, layers differently: discovery +/ developer → CDMO manufacturing → distribution / PBM → payer). + +**Decompose the chain** (representative names, not exhaustive): + +- **Upstream — tools & IP** (most concentrated moats): + - EDA / IP: Cadence (CDNS), Synopsys (SNPS), Arm (ARM) + - Equipment (WFE): ASML (ASML — sole EUV supplier), Applied Materials + (AMAT), Lam (LRCX), KLA (KLAC), Tokyo Electron (8035.T) + - Materials: wafers (Shin-Etsu, SUMCO), photoresist / specialty chemicals +- **Midstream — make & design:** + - Foundry (pure-play make): TSMC (TSM), GlobalFoundries (GFS), SMIC + - IDM (design + make): Intel (INTC), Samsung, Texas Instruments (TXN) + - Fabless (design only): NVIDIA (NVDA), AMD (AMD), Broadcom (AVGO), + Marvell (MRVL), Qualcomm (QCOM) + - Memory: Micron (MU), SK Hynix, Samsung — DRAM / NAND / **HBM** + - Packaging & test (OSAT): ASE (ASX), Amkor (AMKR) — **advanced packaging** +- **Downstream — demand:** hyperscalers (MSFT / GOOGL / META / AMZN — also + rolling their own silicon: TPU, Trainium, MTIA, Maia), devices (AAPL), + auto / industrial, servers (SMCI, DELL) + +**Where the tension is right now** — this is what a scan surfaces, not the +full roster: the binding constraint for AI silicon has migrated from +leading-edge logic to **HBM + advanced packaging (CoWoS)**, so Micron / +SK Hynix and TSM's CoWoS capacity + Amkor deserve more attention than the +headline GPU names. ASML is the single most concentrated upstream choke point. + +**Top-down frame** (OpenAlice's edge): semis run on three clocks — hyperscaler +**capex**, the **rate** cycle (long-duration growth multiples), and the +**memory inventory / pricing** cycle. Tie the scan to these via the FRED +series + news archive. + +**Proposed file structure** (confirm / adjust with the user — don't impose): + +``` +semis/ + map.md # chain decomposition + where the tension sits + macro frame + shortlist.md # the 3–6 names to dig now: one-line why + next question each + notes/ # per-name research, added as you climb scan → thesis (R3+) + NVDA.md + MU.md + ... +``` + +`map.md` and `shortlist.md` are produced by this scan; `notes/.md` grow +later as specific names get researched. The next session reads `shortlist.md` +and continues — never a cold start. diff --git a/src/workspaces/context-injector.spec.ts b/src/workspaces/context-injector.spec.ts new file mode 100644 index 000000000..52e42f118 --- /dev/null +++ b/src/workspaces/context-injector.spec.ts @@ -0,0 +1,110 @@ +/** + * Golden / characterization test for launcher-owned context injection. The + * MCP bytes are asserted exactly; the persona composition is asserted to equal + * `persona + "\n\n---\n\n" +