From 91e747584fa8ac1589b4cff5a5574bdc3ae82a36 Mon Sep 17 00:00:00 2001 From: Justin Lee Date: Sun, 27 Sep 2026 14:20:50 +0800 Subject: [PATCH] fix(store): record cwd for live Claude Code 2.1.283 proxy traffic Claude Code 2.1.283 still sends a top-level `system` on context_management requests, but the env block with "Primary working directory:" moved to a role:'system' message. extractCwd returned null as soon as `system` lacked the env line, so the live proxy recorded cwd: null on every Claude entry and session. #637 was validated against _req.json files, which store `system` only as sysHash, so the saved bodies took the context_management branch and looked fixed. extractCwd now falls through to the existing messages / safeguards scan when a context_management request's `system` has no env line. A `system` that names the directory still wins. role:'system' messages are read before messages[0], because on 2.1.283 messages[0] embeds CLAUDE.md and other text that can quote an env line of its own. Subagent classification is unchanged: without context_management a cwd-less `system` still yields null, so title-generation requests and subagent kickoffs keep "no cwd"; isAnthropicSubagent and isLikelySubagent already return early on context_management. A title-generation entry takes the session's already-known cwd, as before. New e2e test drives the real proxy path with a mock upstream and checks index.ndjson (main turn cwd, title-gen isSubagent and inherited cwd) and sessions.json after shutdown. Old code: 0/3 e2e and the new unit tests fail; new code: all pass. Co-Authored-By: Claude Opus 5.5 --- docs/wire-protocol-reference.md | 3 +- server/store.js | 14 +- test/live-proxy-cwd.e2e.test.js | 310 ++++++++++++++++++++++++++++++++ test/store.test.js | 44 +++++ 4 files changed, 366 insertions(+), 5 deletions(-) create mode 100644 test/live-proxy-cwd.e2e.test.js diff --git a/docs/wire-protocol-reference.md b/docs/wire-protocol-reference.md index 21363087..3f66a771 100644 --- a/docs/wire-protocol-reference.md +++ b/docs/wire-protocol-reference.md @@ -27,6 +27,7 @@ | Date | Agent | Version | Change | |------|-------|---------|--------| +| 2026-09-27 | Claude Code / ccxray | CC 2.1.283 | **The live request keeps a top-level `system`; only the env block moved.** A 2.1.283 `context_management` request still carries `system` (billing header + "You are Claude Code…" / "You are a Claude agent…" blocks) without any `Primary working directory:` line; the env block, cwd included, is a `role:'system'` message (`messages[1]`, later ones folded to a string). The #637 fix was derived from `_req.json`, which never stores `system` (only `sysHash`), so it missed that the live `extractCwd` stopped at the cwd-less `system` block and logged `cwd: null` for every live Claude turn. `extractCwd` now falls through to the `role:'system'` / `messages[0]` / `safeguards` scan when a `context_management` request's `system` has no env line, reading `role:'system'` messages before `messages[0]` (which embeds CLAUDE.md and can quote an env line); without `context_management` a cwd-less `system` still yields null (`obs-fragile`: seen at 2.1.283 only). Title-generation requests carry no cwd of their own; their entry takes the session's already-known cwd. | | 2026-09-03 | ccxray | #611 | **Claude launch account snapshot.** `ccxray claude` reads `/.claude.json` once at launch and appends `X-Ccxray-Account: ` to `ANTHROPIC_CUSTOM_HEADERS`. The header is ccxray-internal (never forwarded upstream); the HTTP proxy normalizes and persists its email plus exact post-`@` domain as the account logged in for that launch, not as a per-request identity. Missing or malformed config is ignored. | | 2026-08-31 | Claude Code | CC 2.1.251 (absent at 2.1.206) · #601/#604 | **Local transcript event `cost-state` carries the `[1m]` window-variant declaration.** New record type `{"type":"cost-state", modelUsage: {...}}` aggregates per-model usage keyed by the **client-internal model id, `[1m]` suffix included** (`claude-fable-5[1m]` observed) — the only per-session window-variant declaration reachable by the import path (transcript `message.model` stays bare; system prompt is not stored). `obs-fragile`: positive-only — bare/empty `modelUsage` keys occur on sessions whose usage proves 1M (7/13 in the measured corpus, mechanism undetermined), so absence must never be read as 200K; coverage starts at 2.1.251 and the record is written late in a session's life (see the enrichment-timing note in `docs/solutions/history-only-1m-context-denominator.md` §F1a). Not a wire event — local transcript shape, same class as the 2026-08-25 Codex `compacted` row. | | 2026-08-25 | Codex CLI / ccxray | Codex 0.148+ · ccxray · #606 | **Compaction boundaries are observable in the local Codex transcript.** Codex emits a top-level `{"type":"compacted"}` record without a `token_count` payload. ccxray latches that marker across zero-token/non-turn records and persists `compacted: true` on the next indexed turn, so live SSE and cold-load rendering agree. This is a local transcript event rather than an OpenAI Responses API wire event (`obs-stable` in Codex CLI transcript shape; version may change). | @@ -190,7 +191,7 @@ | Agent type (Codex) | N/A | Priority: `x-openai-agent-type` / `x-codex-agent-type` header, then `x-codex-turn-metadata` JSON → `.agent_type`, then `x-openai-subagent` as fallback. Values: `explorer`, `worker`, `default` | `obs-stable` | | Subagent flag (Claude) | Heuristic: absence of `cwd` in system prompt metadata. Also: stricter `isLikelySubagent()` heuristic in store.js for session inference (multi-condition: inflight + temporal) | N/A | `obs-stable` | | Subagent flag (Codex) | N/A | Header `x-openai-subagent` (truthy, checked first) or `body.metadata.is_subagent`/`isSubagent` (fallback). WS path derives from `agentType === 'explorer' \|\| agentType === 'worker'` | `obs-stable` codex ≥0.131 | -| CWD detection (WS) | Extracted from system prompt content (regex on `cwd` path) | `response.create.metadata.cwd` / `.workspaces`, `x-codex-turn-metadata.cwd` / `.workspaces`, then `instructions` `CWD:` line. Workspace extraction uses 5 strategies: (1) `workspaces.cwd`, (2) `workspaces.current`, (3) first string value, (4) nested object with `.cwd`, (5) first key starting with `/` | `obs-fragile` (format varies across Codex versions) | +| CWD detection (WS) | `Primary working directory:` line in the `system` prompt; when a `context_management` request's `system` has none (CC 2.1.283+), a `role:'system'` message, then `messages[0]`, then `safeguards[].classifier_context.live_cwd` | `response.create.metadata.cwd` / `.workspaces`, `x-codex-turn-metadata.cwd` / `.workspaces`, then `instructions` `CWD:` line. Workspace extraction uses 5 strategies: (1) `workspaces.cwd`, (2) `workspaces.current`, (3) first string value, (4) nested object with `.cwd`, (5) first key starting with `/` | `obs-fragile` (format varies across Codex versions) | | CWD detection (HTTP) | (same as WS) | `parsedBody.metadata.cwd`, `parsedBody.metadata.workspaces`, `x-codex-turn-metadata.cwd/workspaces`, `instructions` `CWD:` line, then hub client CWD or `process.cwd()` | `obs-stable` / `obs-fragile` (`workspaces`) | | Multi-turn | Full `messages[]` history in every request | WS: `previous_response_id` + incremental `input`. HTTP: full cumulative `input[]` history; the immediately preceding turn's tool results form the trailing contiguous `*_call_output` block | `contractual` (history) / `obs-stable` (trailing output block) | diff --git a/server/store.js b/server/store.js index f0293de1..0cf1c9c2 100644 --- a/server/store.js +++ b/server/store.js @@ -445,7 +445,11 @@ function extractCwd(req) { const txt = Array.isArray(req.system) ? req.system.map(b => b.text || '').join('\n') : String(req.system); const m = txt.match(/Primary working directory: (.+)/); if (m) return m[1].trim(); - return null; + // 2.1.283+ keeps a top-level system on context_management requests but + // moved the env block out of it, so only those fall through. Without + // context_management (title-gen, subagent kickoffs) a system miss stays + // null — isAnthropicSubagent / resolveTitleGenTitle rely on "no cwd". + if (!req.context_management) return null; } // context_management format: system content lives in messages[0] (any role — // Claude Code 2.1.283+ makes it a 'user' system-prompt block) and in dedicated @@ -454,13 +458,15 @@ function extractCwd(req) { // 2.1.283 traffic, later same-session role:'system' messages fold to a string // while the first one is still a block array. Never scan other user messages: // user text can quote "Primary working directory:" and would misattribute the - // project. + // project. role:'system' messages are scanned before messages[0]: on 2.1.283+ + // messages[0] embeds CLAUDE.md and other text, which can + // quote an env line of its own. if (req?.context_management && Array.isArray(req?.messages)) { const candidates = []; - if (req.messages[0]) candidates.push(req.messages[0]); for (let i = 1; i < req.messages.length; i++) { if (req.messages[i]?.role === 'system') candidates.push(req.messages[i]); } + if (req.messages[0]) candidates.push(req.messages[0]); const msgText = (msg) => { // Only system-role messages fold to a string; a plain-string messages[0] // is user text (e.g. a paste) and is not trusted to name the project. @@ -506,7 +512,7 @@ function configDirFromText(text) { return basename.startsWith('.claude') ? dir : null; } -// Unlike extractCwd, a system miss intentionally falls through to context_management. +// Like extractCwd, a system miss falls through to context_management. function extractConfigDir(req) { if (req?.system) { const text = Array.isArray(req.system) ? req.system.map(block => block.text || '').join('\n') : String(req.system); diff --git a/test/live-proxy-cwd.e2e.test.js b/test/live-proxy-cwd.e2e.test.js new file mode 100644 index 00000000..7f17f680 --- /dev/null +++ b/test/live-proxy-cwd.e2e.test.js @@ -0,0 +1,310 @@ +'use strict'; + +// extractCwd's `system` branch used to return null as soon as it found +// a `system` block with no "Primary working directory" line, instead of +// falling through to the context_management / messages scan the way +// extractConfigDir already does. Claude Code 2.1.283+ sends exactly this +// shape live — a top-level `system` with no env line, and the cwd moved to a +// dedicated `messages[1]` `role:'system'` block — so the live proxy logged +// `cwd: null` for every turn of a real session. +// +// This test drives the real live-proxy path (spawn server + POST +// /v1/messages against a mock upstream) rather than calling extractCwd +// directly, so it also proves the fix reaches index.ndjson and sessions.json, +// and that subagent classification (isSubagent) is unaffected. + +const { describe, it, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const http = require('node:http'); +const os = require('node:os'); +const path = require('node:path'); +const { spawn } = require('node:child_process'); + +const SERVER_SCRIPT = path.join(__dirname, '..', 'server', 'index.js'); + +const SESSION_ID = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; +const CWD = '/tmp/live-proxy-cwd-test'; + +// ── Helpers copied from test/index-fields.e2e.test.js ────────────────── + +function findFreePort() { + return new Promise(resolve => { + const server = http.createServer(); + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + server.close(() => resolve(port)); + }); + }); +} + +function waitForPort(port, timeoutMs = 8000) { + return new Promise((resolve, reject) => { + const start = Date.now(); + const check = () => { + const req = http.get(`http://localhost:${port}/_api/health`, { timeout: 1000 }, res => { + res.resume(); + res.on('end', resolve); + }); + req.on('error', () => { + if (Date.now() - start > timeoutMs) return reject(new Error('proxy did not start')); + setTimeout(check, 100); + }); + req.on('timeout', () => { + req.destroy(); + if (Date.now() - start > timeoutMs) return reject(new Error('proxy did not start')); + setTimeout(check, 100); + }); + }; + check(); + }); +} + +function killAndWait(child) { + return new Promise(resolve => { + if (!child || child.exitCode !== null) return resolve(); + const timer = setTimeout(() => { + try { child.kill('SIGKILL'); } catch {} + resolve(); + }, 3000); + child.once('exit', () => { clearTimeout(timer); resolve(); }); + child.kill('SIGTERM'); + }); +} + +function isolatedEnv(home, overrides = {}, { identity = 'partial' } = {}) { + const base = { ...process.env }; + for (const k of Object.keys(base)) { + if (/^(CCXRAY_|ANTHROPIC_|OPENAI_|CHATGPT_|XAI_|GROK_)/.test(k)) delete base[k]; + } + delete base.LOGS_DIR; + delete base.STORAGE_BACKEND; + const env = { + ...base, + ...overrides, + CCXRAY_HOME: home, + CCXRAY_PRICING_CACHE: '/nonexistent/ccxray-pricing-cache.json', + RESTORE_DAYS: '0', + CCXRAY_IMPORT_DISABLE: '1', + CCXRAY_EXPORT_DISABLE: '1', + LOG_RETENTION_DAYS: '0', + BROWSER: 'none', + TZ: 'Asia/Tokyo', + }; + if (identity === 'partial' || identity === 'full') { + env.CCXRAY_AGENT_ID = 'machine-7'; + env.CCXRAY_TEAM = 'platform'; + } + if (identity === 'full') { + env.CCXRAY_USER_EMAIL = 'dev@example.test'; + env.CCXRAY_AGENT_TYPE = 'ci-bot'; + } + return env; +} + +function launchProxy(port, env) { + const child = spawn(process.execPath, [SERVER_SCRIPT, '--port', String(port), '--no-browser'], { + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + child.stdout.on('data', () => {}); + child.stderr.on('data', chunk => { stderr += chunk.toString(); }); + return { child, stderr: () => stderr }; +} + +function readIndexLines(home) { + const indexPath = path.join(home, 'logs', 'index.ndjson'); + if (!fs.existsSync(indexPath)) return []; + return fs.readFileSync(indexPath, 'utf8').split('\n').filter(Boolean) + .map(raw => ({ raw, obj: JSON.parse(raw) })); +} + +// See test/index-fields.e2e.test.js for why the timeout is this generous +// (#538 — restore + pricing warm-up run before the importer, under load). +function waitForIndexLines(home, expected, timeoutMs = 45000) { + return new Promise((resolve, reject) => { + const start = Date.now(); + const check = () => { + let lines = []; + try { lines = readIndexLines(home); } catch {} + if (lines.length >= expected) return resolve(lines); + if (Date.now() - start > timeoutMs) { + return reject(new Error(`expected ${expected} index lines, found ${lines.length}`)); + } + setTimeout(check, 50); + }; + check(); + }); +} + +function postJson(port, reqPath, body, headers) { + return new Promise((resolve, reject) => { + const payload = JSON.stringify(body); + const req = http.request({ + hostname: 'localhost', port, path: reqPath, method: 'POST', + headers: { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(payload), + ...headers, + }, + }, res => { + res.resume(); + res.on('end', () => resolve(res.statusCode)); + }); + req.on('error', reject); + req.end(payload); + }); +} + +function postMessages(port, body, headers = {}) { + return postJson(port, '/v1/messages', body, { + 'x-api-key': 'sk-test', + 'anthropic-version': '2023-06-01', + ...headers, + }); +} + +// mock upstream: a unique responseId per request so ADR 0012's read-time +// merge never folds the main turn and the title-gen turn into one entry. +function makeAnthropicUpstream() { + let counter = 0; + return http.createServer((req, res) => { + const chunks = []; + req.on('data', chunk => chunks.push(chunk)); + req.on('end', () => { + let body = {}; + try { body = JSON.parse(Buffer.concat(chunks).toString()); } catch {} + counter += 1; + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ + id: `msg_live_proxy_cwd_${counter}`, + type: 'message', + role: 'assistant', + model: body.model, + content: [{ type: 'text', text: 'ok' }], + stop_reason: 'end_turn', + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 1 }, + })); + }); + }); +} + +// ── Fixtures — real Claude Code 2.1.283 shapes ── + +const BILLING_HEADER = 'x-anthropic-billing-header: cc_version=2.1.283.b1c; cc_entrypoint=cli;'; +const CLAUDE_CODE_B1 = "You are Claude Code, Anthropic's official CLI for Claude."; + +// Case (a): main turn. Top-level `system` has no cwd line (the bug trigger); +// `context_management` is present; the cwd line moved to `messages[1]` +// (`role:'system'`), which is the format extractCwd must fall through to. +function mainTurnBody() { + return { + model: 'claude-sonnet-4-6', + max_tokens: 64, + metadata: { session_id: SESSION_ID }, + system: [ + { type: 'text', text: BILLING_HEADER }, + { type: 'text', text: CLAUDE_CODE_B1 }, + { type: 'text', text: 'You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.' }, + ], + context_management: { edits: [] }, + messages: [ + { role: 'user', content: [ + { type: 'text', text: 'Some reminder text.' }, + ] }, + { role: 'system', content: [ + { type: 'text', text: `# Environment\nPrimary working directory: ${CWD}\nShell: zsh` }, + ] }, + { role: 'user', content: [{ type: 'text', text: 'LIVE_PROXY_CWD_MAIN' }] }, + ], + }; +} + +// Case (b): title-gen request in the same session. Real shape: no +// `context_management`, a single user message, and a short system prompt +// whose b2 block ("You are naming a coding session…") makes +// isAnthropicSubagent classify it as a subagent via extractAgentType — the +// protective assertion this fixture exists for. Neither the old nor the new +// extractCwd finds a cwd in THIS body (no context_management, no +// safeguards), so this entry's cwd must come from store.sessionMeta +// (inherited from the main turn above), not from its own body. +function titleGenBody() { + return { + model: 'claude-sonnet-4-6', + max_tokens: 64, + metadata: { session_id: SESSION_ID }, + system: [ + { type: 'text', text: BILLING_HEADER }, + { type: 'text', text: CLAUDE_CODE_B1 }, + { type: 'text', text: 'You are naming a coding session so the user can pick it out of a long list of sessions. Given the user\'s first message, reply with a concise 2-4 word title and nothing else.' }, + ], + messages: [ + { role: 'user', content: 'Generate a concise title.' }, + ], + }; +} + +describe('live proxy cwd – 2.1.283 system+context_management shape', () => { + let home, upstream, proxyChild, proxyPort, upstreamPort, proxyStderr; + + before(async () => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'cwd-e2e-')); + fs.mkdirSync(path.join(home, 'logs'), { recursive: true }); + + upstream = makeAnthropicUpstream(); + await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve)); + upstreamPort = upstream.address().port; + + proxyPort = await findFreePort(); + const env = isolatedEnv(home, { + ANTHROPIC_TEST_HOST: '127.0.0.1', + ANTHROPIC_TEST_PORT: String(upstreamPort), + ANTHROPIC_TEST_PROTOCOL: 'http', + }, { identity: 'none' }); + const launched = launchProxy(proxyPort, env); + proxyChild = launched.child; + proxyStderr = launched.stderr; + await waitForPort(proxyPort); + }); + + after(async () => { + await killAndWait(proxyChild); + if (upstream) await new Promise(resolve => upstream.close(resolve)); + try { fs.rmSync(home, { recursive: true, force: true }); } catch {} + }); + + it('(a) main turn: cwd extracted from role:system message when system block has no cwd', async () => { + assert.equal(await postMessages(proxyPort, mainTurnBody()), 200, `unexpected proxy stderr: ${proxyStderr()}`); + const lines = await waitForIndexLines(home, 1); + const main = lines[0].obj; + assert.equal(main.cwd, CWD, 'main turn cwd must be extracted from role:system message'); + assert.equal(main.isSubagent, false, 'main turn is not a subagent'); + assert.equal(main.sessionId, SESSION_ID); + }); + + it('(b) title-gen: isSubagent true, cwd inherited from session', async () => { + assert.equal(await postMessages(proxyPort, titleGenBody()), 200, `unexpected proxy stderr: ${proxyStderr()}`); + const lines = await waitForIndexLines(home, 2); + const titleEntry = lines[1].obj; + assert.equal(titleEntry.isSubagent, true, 'title-gen is classified as subagent'); + assert.equal(titleEntry.cwd, CWD, 'title-gen entry inherits session cwd from sessionMeta'); + assert.equal(titleEntry.sessionId, SESSION_ID); + }); + + it('(c) sessions.json: session cwd is set after shutdown', async () => { + // gracefulExit flushes sessions.json on SIGTERM (server/index.js) before + // process.exit; killAndWait's 3s timeout + SIGKILL guarantees the process + // has exited (and thus flushed) by the time this resolves. + await killAndWait(proxyChild); + proxyChild = null; // prevent double-kill in after() + const sessionsPath = path.join(home, 'logs', 'sessions.json'); + assert.ok(fs.existsSync(sessionsPath), 'sessions.json must exist'); + const raw = fs.readFileSync(sessionsPath, 'utf8'); + const sessions = raw.split('\n').filter(Boolean).map(line => JSON.parse(line)); + const sess = sessions.find(s => s.sid === SESSION_ID); + assert.ok(sess, 'session must exist in sessions.json'); + assert.equal(sess.cwd, CWD, 'session cwd in sessions.json must match'); + }); +}); diff --git a/test/store.test.js b/test/store.test.js index 94d63eb1..f92b4c81 100644 --- a/test/store.test.js +++ b/test/store.test.js @@ -752,6 +752,50 @@ describe('store', () => { }; assert.equal(store.extractCwd(req), null); }); + + it('system block without cwd falls through to context_management (2.1.283 live shape)', () => { + const store = require('../server/store'); + const req = { + system: [{ type: 'text', text: 'You are an interactive agent that helps users.' }], + context_management: { edits: [] }, + messages: [ + { role: 'user', content: [ + { type: 'text', text: 'No path here.' }, + ] }, + { role: 'system', content: [ + { type: 'text', text: 'Primary working directory: /fallthrough/test' }, + ] }, + ], + }; + assert.equal(store.extractCwd(req), '/fallthrough/test'); + }); + + it('prefers the role:system env block over an env line quoted in messages[0]', () => { + const store = require('../server/store'); + // messages[0] embeds CLAUDE.md / pasted text via , so a + // quoted "Primary working directory:" there must not beat the real one. + const req = { + system: [{ type: 'text', text: 'You are an interactive agent that helps users.' }], + context_management: { edits: [] }, + messages: [ + { role: 'user', content: [ + { type: 'text', text: 'Contents of /real/project/CLAUDE.md:\nPrimary working directory: /quoted/elsewhere' }, + ] }, + { role: 'system', content: 'Primary working directory: /real/project' }, + ], + }; + assert.equal(store.extractCwd(req), '/real/project'); + }); + + it('a system block without cwd and without context_management stays null (subagent/title-gen shapes)', () => { + const store = require('../server/store'); + const req = { + system: [{ type: 'text', text: 'You are naming a coding session so the user can pick it out of a long list of sessions.' }], + safeguards: [{ classifier_context: { live_cwd: '/parent/project' } }], + messages: [{ role: 'user', content: 'Generate a concise title.' }], + }; + assert.equal(store.extractCwd(req), null); + }); }); describe('extractConfigDir', () => {