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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion docs/wire-protocol-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_CONFIG_DIR or ~/.claude>/.claude.json` once at launch and appends `X-Ccxray-Account: <oauthAccount.emailAddress>` 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). |
Expand Down Expand Up @@ -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) |

Expand Down
14 changes: 10 additions & 4 deletions server/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 <system-reminder> 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.
Expand Down Expand Up @@ -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);
Expand Down
310 changes: 310 additions & 0 deletions test/live-proxy-cwd.e2e.test.js
Original file line number Diff line number Diff line change
@@ -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: '<system-reminder>Some reminder text.</system-reminder>' },
] },
{ 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');
});
});
Loading
Loading