@claudexor/harness-claude 1.0.0 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,38 +1,70 @@
1
- import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
2
- import { homedir, tmpdir } from "node:os";
1
+ import { homedir } from "node:os";
3
2
  import { join } from "node:path";
4
- import { ConformanceReport as ConformanceReportSchema, HarnessCapabilityProfile as HarnessCapabilityProfileSchema, HarnessManifest as HarnessManifestSchema } from "@claudexor/schema";
5
- import { HarnessUnavailableError, interactionChannelFromSpec, normalizeEffort, playwrightMcpArgs, providerScrubEnv, readonlyMechanism, resolveNpxBin, runCapture, runCliHarness, PROVIDER_SECRET_ENV } from "@claudexor/core";
3
+ import { ConformanceReport as ConformanceReportSchema, HarnessCapabilityProfile as HarnessCapabilityProfileSchema, HarnessManifest as HarnessManifestSchema, } from "@claudexor/schema";
4
+ import { abortSignalFromSpec, browserMcpCommand, HarnessUnavailableError, interactionChannelFromSpec, labelStreams, normalizeEffort, providerScrubEnv, resolveHarnessBinary, runCapture, runCliHarness, PROVIDER_SECRET_ENV, selectStrictAuthRoute, selectedAuthAvailable, selectedAuthReady, shouldVerifyApiKey, } from "@claudexor/core";
6
5
  import { resolveSecret } from "@claudexor/secrets";
7
6
  import { CLAUDEXOR_VERSION, nowIso, redactSecrets } from "@claudexor/util";
8
7
  import { createClaudeParser } from "./parse.js";
9
- import { handleControlRequestFrame, initialSessionFrames, isControlRequestFrame, isResultFrame } from "./interactive.js";
10
- const BIN = process.env.CLAUDEXOR_CLAUDE_BIN || "claude";
11
- const CLAUDE_PROVIDER_ENV_DENYLIST = PROVIDER_SECRET_ENV.filter((k) => k !== "ANTHROPIC_API_KEY");
8
+ import { probeClaudeCredentialProfile, resolveClaudeProfileRoute } from "./profile.js";
9
+ export { canonicalProfileConfigDir } from "./profile.js";
10
+ import { smokeIsolatedApiKey, smokeIsolatedOAuthToken } from "./smoke.js";
11
+ import { claudeAttachmentBlocks, handleControlRequestFrame, initialSessionFrames, isControlRequestFrame, isResultFrame, } from "./interactive.js";
12
+ export const BIN = process.env.CLAUDEXOR_CLAUDE_BIN || "claude";
13
+ export const CLAUDE_PROVIDER_ENV_DENYLIST = PROVIDER_SECRET_ENV.filter((k) => k !== "ANTHROPIC_API_KEY");
12
14
  const CLAUDE_CAPABILITY_PROFILE = HarnessCapabilityProfileSchema.parse({
13
15
  auth: {
14
- supported_sources: ["native_session", "api_key_env"],
16
+ supported_sources: ["native_session", "oauth_token_env", "api_key_env"],
15
17
  preferred_source: null,
16
18
  credential_transports: [
17
19
  { source: "native_session", kind: "config_file", relocatable_by: ["CONFIG_DIR"] },
20
+ { source: "native_session", kind: "os_keychain", relocatable_by: [] },
21
+ { source: "oauth_token_env", kind: "oauth_token_env", relocatable_by: ["ENV"] },
18
22
  { source: "api_key_env", kind: "env_var", relocatable_by: ["ENV"] },
19
23
  ],
20
24
  },
21
25
  access_control: { readonly_mechanism: "tool_allowlist" },
22
- isolation: { supported_containment: ["env_or_file_injection"] },
23
- image_input: "base64_stream",
26
+ isolation: { supported_containment: ["host_user_context", "env_or_file_injection"] },
27
+ attachment_inputs: [
28
+ {
29
+ kind: "image",
30
+ mime_types: ["image/png", "image/jpeg", "image/gif", "image/webp"],
31
+ max_bytes: 5 * 1024 * 1024,
32
+ max_count: 20,
33
+ transport: "base64_stream",
34
+ },
35
+ {
36
+ kind: "file",
37
+ mime_types: ["text/plain", "text/markdown", "application/json"],
38
+ max_bytes: 1024 * 1024,
39
+ max_count: 10,
40
+ transport: "text_inline",
41
+ },
42
+ ],
24
43
  });
25
44
  /**
26
45
  * Ordered (weakest→strongest) reasoning-effort levels `claude --effort` accepts.
27
46
  * Verified against the installed CLI (`claude --help`, v2.1.165): the full
28
- * ladder is low|medium|high|xhigh|max. SINGLE source for the manifest's
47
+ * ladder is low|medium|high|max. SINGLE source for the manifest's
29
48
  * `effort_levels` and the run-time normalizer (which now clamps nothing away).
30
49
  */
31
- const CLAUDE_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
50
+ const CLAUDE_EFFORT_LEVELS = ["low", "medium", "high", "max"];
51
+ /** Exported for focused route-policy tests; runtime uses this exact selector. */
52
+ export const selectClaudeRunAuthRoute = selectStrictAuthRoute;
32
53
  function permissionArgs(access) {
33
54
  switch (access) {
34
55
  case "readonly":
35
- return [];
56
+ // Defense in depth: plan mode rejects mutation requests, setting sources
57
+ // prevent user/project policy from widening the route, strict MCP ignores
58
+ // project servers, and slash commands/Chrome are independent tool ingress.
59
+ return [
60
+ "--permission-mode",
61
+ "plan",
62
+ "--setting-sources",
63
+ "",
64
+ "--strict-mcp-config",
65
+ "--disable-slash-commands",
66
+ "--no-chrome",
67
+ ];
36
68
  case "workspace_write":
37
69
  return ["--permission-mode", "acceptEdits"];
38
70
  case "full":
@@ -42,34 +74,114 @@ function permissionArgs(access) {
42
74
  return [];
43
75
  }
44
76
  }
45
- async function detectVersion() {
77
+ const CLAUDE_READONLY_REQUIRED_FLAGS = [
78
+ "--tools",
79
+ "--setting-sources",
80
+ "--strict-mcp-config",
81
+ "--permission-mode",
82
+ "--disable-slash-commands",
83
+ "--no-chrome",
84
+ ];
85
+ let readonlyProbePromise = null;
86
+ export function probeClaudeReadonlyProfile(abortSignal) {
87
+ if (readonlyProbePromise)
88
+ return readonlyProbePromise;
89
+ readonlyProbePromise = (async () => {
90
+ try {
91
+ const result = await runCapture(BIN, ["--help"], {
92
+ timeoutMs: 10_000,
93
+ abortSignal,
94
+ cancelSignal: "SIGTERM",
95
+ cancelKillDelayMs: 0,
96
+ });
97
+ const help = `${result.stdout}\n${result.stderr}`;
98
+ const missingFlags = CLAUDE_READONLY_REQUIRED_FLAGS.filter((flag) => !help.includes(flag));
99
+ const hasPlanMode = help.includes('"plan"') || help.includes("plan,") || help.includes(", plan");
100
+ if (!hasPlanMode)
101
+ missingFlags.push("--permission-mode=plan");
102
+ return {
103
+ supported: result.code === 0 && missingFlags.length === 0,
104
+ missingFlags,
105
+ detail: result.code === 0 && missingFlags.length === 0
106
+ ? "installed Claude CLI exposes the complete restrictive readonly flag set"
107
+ : `readonly enforcement unavailable; missing ${missingFlags.join(", ") || `help exited ${result.code}`}`,
108
+ };
109
+ }
110
+ catch (error) {
111
+ return {
112
+ supported: false,
113
+ missingFlags: [...CLAUDE_READONLY_REQUIRED_FLAGS],
114
+ detail: `readonly enforcement probe failed: ${redactSecrets(error instanceof Error ? error.message : String(error))}`,
115
+ };
116
+ }
117
+ })();
118
+ return readonlyProbePromise;
119
+ }
120
+ async function detectVersion(abortSignal) {
46
121
  try {
47
- const r = await runCapture(BIN, ["--version"], { timeoutMs: 10_000 });
122
+ const r = await runCapture(BIN, ["--version"], {
123
+ timeoutMs: 10_000,
124
+ abortSignal,
125
+ cancelSignal: "SIGTERM",
126
+ cancelKillDelayMs: 0,
127
+ });
48
128
  return r.stdout.trim() || `${BIN} (version unknown)`;
49
129
  }
50
130
  catch {
51
131
  return null;
52
132
  }
53
133
  }
54
- async function authStatusOk() {
134
+ export function claudeNativeEnv(base, configDir) {
135
+ return {
136
+ ...(base ?? {}),
137
+ ...providerScrubEnv(),
138
+ CLAUDE_CONFIG_DIR: configDir ?? defaultNativeClaudeConfigDir(),
139
+ };
140
+ }
141
+ export async function probeAuthStatus(bin = BIN, options = {}) {
55
142
  try {
56
- const env = Object.fromEntries(CLAUDE_PROVIDER_ENV_DENYLIST.map((name) => [name, null]));
57
- // Probe a REAL native/subscription session only: `claude auth status` reports
58
- // loggedIn:true for a bare ANTHROPIC_API_KEY (authMethod:api_key), which would
59
- // make the run path mistake an API key for a native session and pick the
60
- // subscription route (scrubs the key -> "Not logged in"). Scrub it here so this
61
- // probe reflects native-session readiness alone; the api_key route is chosen
62
- // separately by runClaude when no native session exists.
63
- env.ANTHROPIC_API_KEY = null;
64
- const r = await runCapture(BIN, ["auth", "status"], { env, timeoutMs: 10_000 });
65
- return r.code === 0;
143
+ const env = claudeNativeEnv(options.env, options.configDir);
144
+ const r = await (options.runCapture ?? runCapture)(bin, ["auth", "status"], {
145
+ env,
146
+ timeoutMs: 10_000,
147
+ abortSignal: options.abortSignal,
148
+ cancelSignal: "SIGTERM",
149
+ cancelKillDelayMs: 0,
150
+ });
151
+ try {
152
+ const verdict = JSON.parse(r.stdout.trim());
153
+ if (typeof verdict.loggedIn === "boolean" && typeof verdict.authMethod === "string") {
154
+ return {
155
+ loggedIn: verdict.loggedIn,
156
+ authed: verdict.loggedIn && verdict.authMethod === "claude.ai",
157
+ authMethod: verdict.authMethod,
158
+ probeError: null,
159
+ };
160
+ }
161
+ }
162
+ catch {
163
+ /* no typed JSON verdict: fall through to probe-error disclosure */
164
+ }
165
+ const detail = labelStreams(r.stderr, r.stdout, { transform: redactSecrets }) ??
166
+ `claude auth status exited with ${r.code ?? r.signal ?? "unknown result"}`;
167
+ return { loggedIn: false, authed: false, authMethod: null, probeError: detail };
66
168
  }
67
- catch {
68
- return false;
169
+ catch (err) {
170
+ return {
171
+ loggedIn: false,
172
+ authed: false,
173
+ authMethod: null,
174
+ probeError: [...redactSecrets(err instanceof Error ? err.message : String(err))]
175
+ .slice(0, 300)
176
+ .join(""),
177
+ };
69
178
  }
70
179
  }
71
- function anthropicApiKey() {
72
- return process.env.CLAUDEXOR_ANTHROPIC_API_KEY || resolveSecret("anthropic") || process.env.ANTHROPIC_API_KEY || null;
180
+ export function anthropicApiKey() {
181
+ return (process.env.CLAUDEXOR_ANTHROPIC_API_KEY ||
182
+ resolveSecret("anthropic") ||
183
+ process.env.ANTHROPIC_API_KEY ||
184
+ null);
73
185
  }
74
186
  /** A stored/long-lived Claude Code OAuth (`claude setup-token`) for headless
75
187
  * subscription auth. The hermetic kill switch is honored inside resolveSecret
@@ -84,85 +196,75 @@ export function defaultNativeClaudeConfigDir() {
84
196
  return override;
85
197
  return join(homedir(), ".claude");
86
198
  }
87
- /**
88
- * Seed the user's NATIVE Claude session (`.credentials.json`, subscription OAuth)
89
- * into an isolated CLAUDE_CONFIG_DIR so a Pro/Max subscriber with NO API key can
90
- * run inside a Claudexor envelope. Copies only if scoped creds are absent and a
91
- * native credentials file exists; never overwrites. Returns true when scoped
92
- * creds are present afterwards.
93
- *
94
- * Caveats encoded by the caller: `ANTHROPIC_API_KEY` takes precedence over the
95
- * OAuth session, and `--bare` disables it — so the subscription route must set
96
- * neither.
97
- */
98
- export function ensureClaudeNativeAuth(env, nativeDir = defaultNativeClaudeConfigDir()) {
99
- const dir = env?.["CLAUDE_CONFIG_DIR"];
100
- if (!dir)
101
- return false;
102
- const dest = join(dir, ".credentials.json");
103
- if (existsSync(dest))
104
- return true;
105
- const src = join(nativeDir, ".credentials.json");
106
- if (!existsSync(src))
107
- return false;
108
- try {
109
- mkdirSync(dir, { recursive: true });
110
- copyFileSync(src, dest);
111
- try {
112
- chmodSync(dest, 0o600);
113
- }
114
- catch {
115
- /* best-effort perms */
116
- }
117
- return existsSync(dest);
118
- }
119
- catch {
120
- return false;
121
- }
122
- }
123
- /** True when a native Claude session exists and can be seeded into an envelope. */
124
- function nativeClaudeSeedable() {
125
- return existsSync(join(defaultNativeClaudeConfigDir(), ".credentials.json"));
126
- }
127
- async function smokeIsolatedApiKey() {
128
- const key = anthropicApiKey();
129
- if (!key)
130
- return { ok: false, detail: "no API key fallback available" };
131
- const dir = mkdtempSync(`${tmpdir()}/claudexor-claude-smoke-`);
132
- try {
133
- const env = Object.fromEntries(CLAUDE_PROVIDER_ENV_DENYLIST.map((name) => [name, null]));
134
- env.HOME = dir;
135
- env.XDG_CONFIG_HOME = `${dir}/.config`;
136
- env.CLAUDE_CONFIG_DIR = dir;
137
- env.ANTHROPIC_API_KEY = key;
138
- const r = await runCapture(BIN, ["-p", "Reply exactly OK", "--output-format", "stream-json", "--verbose", "--permission-mode", "plan"], { cwd: dir, env, timeoutMs: 60_000 });
139
- const text = `${r.stdout}\n${r.stderr}`;
140
- if (r.code === 0 && text.includes("OK"))
141
- return { ok: true, detail: "isolated CLAUDE_CONFIG_DIR smoke passed" };
142
- return { ok: false, detail: redactClaudeDoctorDetail(text || `claude exited with code ${r.code}`) };
143
- }
144
- catch (err) {
145
- return { ok: false, detail: redactClaudeDoctorDetail(err instanceof Error ? err.message : String(err)) };
146
- }
147
- finally {
148
- rmSync(dir, { recursive: true, force: true });
149
- }
199
+ export function claudeAuthSourceReadiness(input) {
200
+ const nativeReady = input.native.authed && input.native.probeError === null;
201
+ const nativeAvailability = input.native.probeError
202
+ ? "unknown"
203
+ : input.native.loggedIn
204
+ ? "available"
205
+ : "unavailable";
206
+ const nativeVerification = nativeReady
207
+ ? "passed"
208
+ : input.native.probeError || !input.native.loggedIn
209
+ ? "not_run"
210
+ : "failed";
211
+ return [
212
+ {
213
+ source: "native_session",
214
+ availability: nativeAvailability,
215
+ verification: nativeVerification,
216
+ detail: nativeReady
217
+ ? "vendor status confirmed authMethod=claude.ai in the exact run environment"
218
+ : input.native.probeError
219
+ ? `auth-status probe failed: ${redactClaudeDoctorDetail(input.native.probeError)}`
220
+ : input.native.loggedIn
221
+ ? `Claude is logged in via ${input.native.authMethod ?? "unknown"}, not claude.ai`
222
+ : "official native Claude session is not logged in",
223
+ },
224
+ {
225
+ source: "oauth_token_env",
226
+ availability: input.oauthAvailable ? "available" : "unavailable",
227
+ verification: input.oauthVerification,
228
+ detail: input.oauthDetail,
229
+ },
230
+ {
231
+ source: "api_key_env",
232
+ availability: input.apiKeyAvailable ? "available" : "unavailable",
233
+ verification: input.apiKeyVerification,
234
+ detail: input.apiKeyDetail,
235
+ },
236
+ ];
150
237
  }
151
- function redactClaudeDoctorDetail(text) {
238
+ export function redactClaudeDoctorDetail(text) {
152
239
  return redactSecrets(text).slice(0, 500);
153
240
  }
154
- export function createClaudeAdapter() {
241
+ export function createClaudeAdapter(deps = {}) {
242
+ const runtime = {
243
+ detectVersion,
244
+ probeAuthStatus,
245
+ anthropicApiKey,
246
+ claudeOAuthToken,
247
+ resolveProfileSecret: (ref) => resolveSecret(ref),
248
+ smokeIsolatedApiKey,
249
+ smokeIsolatedOAuthToken,
250
+ probeReadonlyProfile: probeClaudeReadonlyProfile,
251
+ runCliHarness,
252
+ ...deps,
253
+ };
155
254
  return {
156
255
  id: "claude",
157
256
  async discover() {
158
- const version = await detectVersion();
257
+ const version = await runtime.detectVersion();
159
258
  if (version === null) {
160
259
  throw new HarnessUnavailableError("claude CLI not found on PATH (set CLAUDEXOR_CLAUDE_BIN to override)");
161
260
  }
162
- const apiKey = anthropicApiKey() !== null;
163
- const authed = await authStatusOk();
261
+ const apiKey = runtime.anthropicApiKey() !== null;
262
+ const readonlyProfile = await runtime.probeReadonlyProfile();
263
+ const native = await runtime.probeAuthStatus(BIN, { env: claudeNativeEnv() });
264
+ const authed = native.authed;
265
+ const oauthTokenAvailable = runtime.claudeOAuthToken() !== null;
164
266
  const authModes = [
165
- ...(authed ? ["local_session"] : []),
267
+ ...(authed || oauthTokenAvailable ? ["local_session"] : []),
166
268
  ...(apiKey ? ["api_key"] : []),
167
269
  ];
168
270
  return HarnessManifestSchema.parse({
@@ -215,17 +317,31 @@ export function createClaudeAdapter() {
215
317
  },
216
318
  capability_profile: {
217
319
  ...CLAUDE_CAPABILITY_PROFILE,
320
+ access_control: {
321
+ readonly_mechanism: readonlyProfile.supported ? "tool_allowlist" : "none",
322
+ },
218
323
  auth: {
219
324
  ...CLAUDE_CAPABILITY_PROFILE.auth,
220
- preferred_source: apiKey ? "api_key_env" : authed ? "native_session" : null,
325
+ preferred_source: authed
326
+ ? "native_session"
327
+ : oauthTokenAvailable
328
+ ? "oauth_token_env"
329
+ : apiKey
330
+ ? "api_key_env"
331
+ : null,
221
332
  },
222
333
  },
223
334
  auth_modes: authModes,
224
- access_profiles_supported: ["readonly", "workspace_write", "full", "inherit_native"],
335
+ access_profiles_supported: [
336
+ ...(readonlyProfile.supported ? ["readonly"] : []),
337
+ "workspace_write",
338
+ "full",
339
+ "inherit_native",
340
+ ],
225
341
  });
226
342
  },
227
343
  async doctor(_spec) {
228
- const version = await detectVersion();
344
+ const version = await runtime.detectVersion(_spec.abortSignal);
229
345
  if (version === null) {
230
346
  return ConformanceReportSchema.parse({
231
347
  harness_id: "claude",
@@ -234,45 +350,206 @@ export function createClaudeAdapter() {
234
350
  reasons: ["claude CLI not found (install Claude Code or set CLAUDEXOR_CLAUDE_BIN)"],
235
351
  });
236
352
  }
237
- const apiKey = anthropicApiKey() !== null;
238
- const authed = await authStatusOk();
239
- // Native subscription readiness is FIRST-CLASS: a logged-in session whose
240
- // .credentials.json we can seed into the envelope is `ok` with no paid API
241
- // smoke. A stored OAuth token (claude setup-token) is also native-ready.
242
- const nativeReady = (authed && nativeClaudeSeedable()) || claudeOAuthToken() !== null;
243
- const smoke = !nativeReady && apiKey ? await smokeIsolatedApiKey() : { ok: false, detail: nativeReady ? "skipped (native session ready)" : "no API key fallback available" };
244
- const ok = nativeReady || smoke.ok;
245
- const allIntents = ["plan", "spec", "implement", "repair", "create_from_scratch", "review", "verify", "synthesize", "explain", "audit", "orchestrate"];
353
+ const readonlyProfile = await runtime.probeReadonlyProfile(_spec.abortSignal);
354
+ const requestedSource = _spec.authSource;
355
+ const probeNative = requestedSource === undefined || requestedSource === "native_session";
356
+ const probeOAuth = requestedSource === undefined || requestedSource === "oauth_token_env";
357
+ const probeApi = requestedSource === undefined || requestedSource === "api_key_env";
358
+ const login = probeNative
359
+ ? await runtime.probeAuthStatus(BIN, {
360
+ env: claudeNativeEnv(_spec.env),
361
+ abortSignal: _spec.abortSignal,
362
+ })
363
+ : { loggedIn: false, authed: false, authMethod: null, probeError: null };
364
+ const nativeCliReady = login.authed;
365
+ // Official native-session proof and stored setup-token proof are separate
366
+ // sources. A targeted native probe resolves neither stored source.
367
+ const oauthToken = probeOAuth ? runtime.claudeOAuthToken() : null;
368
+ const oauthTokenAvailable = oauthToken !== null;
369
+ const apiKey = probeApi && runtime.anthropicApiKey() !== null;
370
+ const preference = requestedSource === "native_session" || requestedSource === "oauth_token_env"
371
+ ? "subscription"
372
+ : requestedSource === "api_key_env"
373
+ ? "api_key"
374
+ : (_spec.authPreference ?? "auto");
375
+ const shouldSmokeOAuth = probeOAuth && oauthToken !== null && !nativeCliReady && preference !== "api_key";
376
+ const oauthSmoke = shouldSmokeOAuth && oauthToken
377
+ ? await runtime.smokeIsolatedOAuthToken(oauthToken, _spec.abortSignal)
378
+ : {
379
+ ok: false,
380
+ detail: oauthTokenAvailable
381
+ ? "verification not run for the unselected setup-token route"
382
+ : "no Claude setup-token available",
383
+ };
384
+ const nativeAvailable = login.loggedIn || oauthTokenAvailable;
385
+ const subscriptionReady = nativeCliReady || oauthSmoke.ok;
386
+ const shouldSmokeKey = probeApi &&
387
+ shouldVerifyApiKey({ preference, apiKeyAvailable: apiKey, nativeReady: subscriptionReady });
388
+ const apiSmoke = shouldSmokeKey
389
+ ? await runtime.smokeIsolatedApiKey(_spec.abortSignal)
390
+ : {
391
+ ok: false,
392
+ detail: apiKey
393
+ ? "verification not run for the unselected API-key route"
394
+ : "no API key fallback available",
395
+ };
396
+ const ok = selectedAuthReady({
397
+ preference,
398
+ nativeReady: subscriptionReady,
399
+ apiKeyReady: apiSmoke.ok,
400
+ });
401
+ const selectedAvailable = selectedAuthAvailable({
402
+ preference,
403
+ nativeAvailable,
404
+ apiKeyAvailable: apiKey,
405
+ });
406
+ const probeUnknown = preference !== "api_key" && login.probeError !== null && !oauthTokenAvailable;
407
+ const allIntents = [
408
+ "plan",
409
+ "spec",
410
+ "implement",
411
+ "repair",
412
+ "create_from_scratch",
413
+ "review",
414
+ "verify",
415
+ "synthesize",
416
+ "explain",
417
+ "audit",
418
+ "orchestrate",
419
+ ];
420
+ const binPath = resolveHarnessBinary(BIN);
421
+ const producedSources = claudeAuthSourceReadiness({
422
+ native: login,
423
+ oauthAvailable: oauthTokenAvailable,
424
+ oauthVerification: oauthSmoke.ok ? "passed" : shouldSmokeOAuth ? "failed" : "not_run",
425
+ oauthDetail: oauthSmoke.detail,
426
+ apiKeyAvailable: apiKey,
427
+ apiKeyVerification: apiSmoke.ok ? "passed" : shouldSmokeKey ? "failed" : "not_run",
428
+ apiKeyDetail: apiSmoke.detail,
429
+ });
430
+ const authSources = requestedSource === undefined
431
+ ? producedSources
432
+ : producedSources.filter((source) => source.source === requestedSource);
433
+ if (requestedSource !== undefined && authSources.length === 0) {
434
+ authSources.push({
435
+ source: requestedSource,
436
+ availability: "unavailable",
437
+ verification: "not_run",
438
+ detail: `Claude does not support ${requestedSource}`,
439
+ });
440
+ }
441
+ const authReasons = ok
442
+ ? []
443
+ : preference === "subscription"
444
+ ? [
445
+ login.probeError && !oauthTokenAvailable
446
+ ? `Claude native-session probe failed: ${redactClaudeDoctorDetail(login.probeError)}`
447
+ : oauthTokenAvailable
448
+ ? `Claude setup-token verification failed: ${oauthSmoke.detail}`
449
+ : "Claude subscription route is not ready (run `claude auth login --claudeai`)",
450
+ ]
451
+ : preference === "api_key"
452
+ ? [
453
+ apiKey
454
+ ? `isolated Claude API-key smoke failed: ${apiSmoke.detail}`
455
+ : "Claude API-key route is not configured",
456
+ ]
457
+ : apiKey
458
+ ? [`isolated Claude API-key smoke failed: ${apiSmoke.detail}`]
459
+ : [
460
+ "not authenticated (run `claude auth login --claudeai` for native/subscription use, or store an anthropic API key fallback)",
461
+ ];
246
462
  return ConformanceReportSchema.parse({
247
463
  harness_id: "claude",
248
- status: ok ? "ok" : authed || apiKey ? "degraded" : "unavailable",
464
+ status: ok
465
+ ? readonlyProfile.supported
466
+ ? "ok"
467
+ : "degraded"
468
+ : selectedAvailable || probeUnknown
469
+ ? "degraded"
470
+ : "unavailable",
249
471
  checks: [
250
- { id: "installed", status: "pass", detail: version },
251
- { id: "native_session", status: nativeReady ? "pass" : "fail", detail: nativeReady ? "native Claude session seedable into envelope" : authed ? "logged in but ~/.claude/.credentials.json not found" : "not logged in (run `claude /login` or store a setup-token)" },
252
- { id: "stored_key", status: apiKey ? "pass" : "fail", detail: apiKey ? "anthropic secret/env available (api-key fallback)" : "no anthropic key fallback" },
253
- { id: "isolated_api_smoke", status: smoke.ok ? "pass" : nativeReady ? "skip" : apiKey ? "fail" : "skip", detail: smoke.detail },
472
+ {
473
+ id: "installed",
474
+ status: "pass",
475
+ detail: binPath ? `${version} at ${binPath}` : version,
476
+ },
477
+ {
478
+ id: "readonly_enforcement",
479
+ status: readonlyProfile.supported ? "pass" : "fail",
480
+ detail: readonlyProfile.detail,
481
+ },
482
+ ...(probeNative
483
+ ? [
484
+ {
485
+ id: "native_session",
486
+ status: nativeCliReady ? "pass" : "fail",
487
+ detail: nativeCliReady
488
+ ? "vendor status confirmed authMethod=claude.ai in the exact run environment"
489
+ : login.probeError
490
+ ? `auth-status probe failed (NOT an auth verdict): ${redactClaudeDoctorDetail(login.probeError)}`
491
+ : login.loggedIn
492
+ ? `logged in via ${login.authMethod ?? "unknown"}, not claude.ai`
493
+ : "not logged in (run `claude auth login --claudeai`)",
494
+ },
495
+ ]
496
+ : []),
497
+ ...(probeOAuth
498
+ ? [
499
+ {
500
+ id: "oauth_setup_token",
501
+ status: oauthSmoke.ok ? "pass" : shouldSmokeOAuth ? "fail" : "skip",
502
+ detail: oauthSmoke.detail,
503
+ },
504
+ ]
505
+ : []),
506
+ ...(probeApi
507
+ ? [
508
+ {
509
+ id: "stored_key",
510
+ status: apiKey ? "pass" : "fail",
511
+ detail: apiKey
512
+ ? "anthropic secret/env available (API-key fallback)"
513
+ : "no anthropic key fallback",
514
+ },
515
+ {
516
+ id: "isolated_api_smoke",
517
+ status: apiSmoke.ok ? "pass" : shouldSmokeKey ? "fail" : "skip",
518
+ detail: apiSmoke.detail,
519
+ },
520
+ ]
521
+ : []),
254
522
  ],
523
+ auth_sources: authSources,
255
524
  enabled_intents: ok ? allIntents : [],
256
525
  disabled_intents: ok ? [] : allIntents,
257
- reasons: ok
258
- ? []
259
- : apiKey
260
- ? [`isolated Claude API-key smoke failed: ${smoke.detail}`]
261
- : ["not authenticated (run `claude /login` for native/subscription use, or store an anthropic API key fallback)"],
526
+ reasons: [...authReasons, ...(readonlyProfile.supported ? [] : [readonlyProfile.detail])],
262
527
  });
263
528
  },
264
529
  run(spec) {
265
- return runClaude(spec);
530
+ return runClaude(spec, runtime);
266
531
  },
267
532
  review(spec) {
268
- return runClaude(spec);
533
+ return runClaude(spec, runtime);
534
+ },
535
+ probeCredentialProfile(profile, abortSignal) {
536
+ return probeClaudeCredentialProfile(profile, runtime, abortSignal);
269
537
  },
270
538
  };
271
539
  }
272
540
  /** Claude's native names for web-permissioned tools. This knowledge lives ONLY in the adapter. */
273
541
  const CLAUDE_WEB_TOOLS = ["WebSearch", "WebFetch"];
274
- const CLAUDE_READONLY_ALLOWED_TOOLS = ["Read", "Glob", "Grep", "LS"];
275
- const CLAUDE_READONLY_DENIED_TOOLS = ["Write", "Edit", "MultiEdit", "NotebookEdit"];
542
+ const CLAUDE_READONLY_ALLOWED_TOOLS = ["Read", "Glob", "Grep"];
543
+ const CLAUDE_READONLY_BUILTIN_TOOLS = [...CLAUDE_READONLY_ALLOWED_TOOLS, ...CLAUDE_WEB_TOOLS];
544
+ const CLAUDE_READONLY_DENIED_TOOLS = [
545
+ "Bash",
546
+ "Write",
547
+ "Edit",
548
+ "MultiEdit",
549
+ "NotebookEdit",
550
+ "Agent",
551
+ "Skill",
552
+ ];
276
553
  export function claudeArgsForSpec(spec, interactive = false, suppressBare = false) {
277
554
  // Interactive sessions deliver the prompt as a stream-json user message on
278
555
  // stdin (the control protocol's transport); one-shot runs keep the prompt arg.
@@ -280,10 +557,30 @@ export function claudeArgsForSpec(spec, interactive = false, suppressBare = fals
280
557
  // permission prompts (AskUserQuestion included) onto the control channel as
281
558
  // control_request frames instead of headless auto-denial.
282
559
  const args = interactive
283
- ? ["-p", "--output-format", "stream-json", "--input-format", "stream-json", "--verbose", "--permission-prompt-tool", "stdio", ...permissionArgs(spec.access)]
284
- : ["-p", spec.prompt, "--output-format", "stream-json", "--verbose", ...permissionArgs(spec.access)];
560
+ ? [
561
+ "-p",
562
+ "--output-format",
563
+ "stream-json",
564
+ "--input-format",
565
+ "stream-json",
566
+ "--verbose",
567
+ "--permission-prompt-tool",
568
+ "stdio",
569
+ ...permissionArgs(spec.access),
570
+ ]
571
+ : [
572
+ "-p",
573
+ spec.prompt,
574
+ "--output-format",
575
+ "stream-json",
576
+ "--verbose",
577
+ ...permissionArgs(spec.access),
578
+ ];
285
579
  if (spec.model_hint)
286
580
  args.push("--model", spec.model_hint);
581
+ // W-C4 live deltas (engine-gated to single-candidate lanes; parser tags payload.delta).
582
+ if (spec.stream_deltas)
583
+ args.push("--include-partial-messages");
287
584
  // Clamp onto claude's declared effort ladder; null = not
288
585
  // requested OR not tunable -> pass no flag. Never sends an invalid level.
289
586
  const eff = normalizeEffort(spec.effort_hint, CLAUDE_EFFORT_LEVELS);
@@ -291,6 +588,11 @@ export function claudeArgsForSpec(spec, interactive = false, suppressBare = fals
291
588
  args.push("--effort", eff);
292
589
  if (spec.max_turns !== null && spec.max_turns > 0)
293
590
  args.push("--max-turns", String(spec.max_turns));
591
+ // Per-run caller instructions APPEND to (never replace) the default system
592
+ // prompt, current-invocation-only. The engine withholds them from synthesis,
593
+ // reviewers, and the auth smoke.
594
+ if (spec.instructions && spec.instructions.trim())
595
+ args.push("--append-system-prompt", spec.instructions);
294
596
  // Structured output: constrain the FINAL message to the caller's JSON
295
597
  // Schema. LIVE-VERIFIED (2.1.165): `--json-schema <inline JSON>` with
296
598
  // --output-format stream-json. Passed only when the engine set it (the
@@ -319,6 +621,10 @@ export function claudeArgsForSpec(spec, interactive = false, suppressBare = fals
319
621
  function toolPermissionArgs(spec) {
320
622
  const { allow, deny } = toolPermissionSets(spec);
321
623
  const args = [];
624
+ if (spec.access === "readonly") {
625
+ const builtins = CLAUDE_READONLY_BUILTIN_TOOLS.filter((tool) => allow.has(tool));
626
+ args.push("--tools", builtins.join(","));
627
+ }
322
628
  if (allow.size > 0)
323
629
  args.push("--allowedTools", [...allow].join(","));
324
630
  if (deny.size > 0)
@@ -327,9 +633,11 @@ function toolPermissionArgs(spec) {
327
633
  }
328
634
  function toolPermissionSets(spec) {
329
635
  const policy = spec.external_context_policy;
330
- const allow = new Set(spec.tool_permission_policy.allow);
636
+ // A run may narrow readonly access but can never widen it. User/project
637
+ // Claude settings are independently suppressed by the readonly argv profile.
638
+ const allow = new Set(spec.access === "readonly" ? [] : spec.tool_permission_policy.allow);
331
639
  const deny = new Set(spec.tool_permission_policy.deny);
332
- if (spec.access === "readonly" && readonlyMechanism(CLAUDE_CAPABILITY_PROFILE) === "tool_allowlist") {
640
+ if (spec.access === "readonly") {
333
641
  for (const tool of CLAUDE_READONLY_ALLOWED_TOOLS) {
334
642
  if (!deny.has(tool))
335
643
  allow.add(tool);
@@ -368,109 +676,155 @@ function toolPermissionSets(spec) {
368
676
  function claudeBrowserArgs(spec) {
369
677
  if (!spec.browser || spec.external_context_policy === "off")
370
678
  return [];
371
- const cfg = JSON.stringify({ mcpServers: { browser: { command: resolveNpxBin(), args: playwrightMcpArgs(spec.browser) } } });
679
+ const mcp = browserMcpCommand(spec.browser);
680
+ const cfg = JSON.stringify({
681
+ mcpServers: { browser: mcp },
682
+ });
372
683
  return ["--mcp-config", cfg];
373
684
  }
374
- /** Build Claude base64 image blocks from image attachments (read at spec time). */
375
- function claudeImageBlocks(attachments) {
376
- const blocks = [];
377
- for (const a of attachments ?? []) {
378
- if (a.kind !== "image")
379
- continue;
380
- try {
381
- blocks.push({ type: "image", source: { type: "base64", media_type: a.mime, data: readFileSync(a.path).toString("base64") } });
382
- }
383
- catch {
384
- // A late-deleted attachment is non-fatal: proceed with the text prompt.
685
+ async function* runClaude(spec, runtime) {
686
+ const abortSignal = abortSignalFromSpec(spec);
687
+ if (spec.access === "readonly") {
688
+ const readonlyProfile = await runtime.probeReadonlyProfile(abortSignal);
689
+ if (!readonlyProfile.supported) {
690
+ yield {
691
+ type: "error",
692
+ session_id: spec.session_id,
693
+ ts: nowIso(),
694
+ error: `Claude readonly enforcement is unavailable: ${readonlyProfile.detail}`,
695
+ payload: {
696
+ code: "readonly_enforcement_unavailable",
697
+ missing_flags: readonlyProfile.missingFlags,
698
+ },
699
+ };
700
+ yield { type: "completed", session_id: spec.session_id, ts: nowIso() };
701
+ return;
385
702
  }
386
703
  }
387
- return blocks;
388
- }
389
- async function* runClaude(spec) {
390
704
  const channel = interactionChannelFromSpec(spec);
391
- const imageBlocks = claudeImageBlocks(spec.attachments);
705
+ const attachmentBlocks = claudeAttachmentBlocks(spec.attachments);
392
706
  // Images ride ONLY the stdin stream-json transport, so an attachment forces
393
707
  // the interactive path even with no interaction channel (control frames then
394
708
  // auto-decline). claudeArgsForSpec(interactive) selects --input-format stream-json.
395
- const interactive = channel !== undefined || imageBlocks.length > 0;
396
- const nativeAuthed = await authStatusOk();
397
- const key = anthropicApiKey();
398
- const oauthToken = claudeOAuthToken();
399
- const preferApi = spec.auth_preference === "api_key";
400
- const scopedConfig = Boolean(spec.env?.["CLAUDE_CONFIG_DIR"]);
401
- // Choose the auth route (BOTH supported, auto-fallback). Subscription seeds the
402
- // native session (credentials copy) or uses a stored OAuth token; api_key sets
403
- // ANTHROPIC_API_KEY. ANTHROPIC_API_KEY overrides OAuth and --bare disables it,
404
- // so the subscription route sets neither (and suppresses --bare).
405
- let seededCreds = false;
406
- const trySub = () => {
407
- if (scopedConfig && nativeAuthed && ensureClaudeNativeAuth(spec.env)) {
408
- seededCreds = true;
409
- return true;
709
+ const interactive = channel !== undefined || attachmentBlocks.length > 0;
710
+ const profile = spec.credential_profile;
711
+ const authPreference = spec.auth_preference ?? "auto";
712
+ let nativeEnv = claudeNativeEnv(spec.env);
713
+ let key = null;
714
+ let oauthToken = null;
715
+ let subscriptionSource = null;
716
+ let route;
717
+ if (profile) {
718
+ const resolved = await resolveClaudeProfileRoute(profile, spec.env, runtime, abortSignal);
719
+ if (resolved.refusal !== null) {
720
+ yield { type: "error", session_id: spec.session_id, ts: nowIso(), error: resolved.refusal };
721
+ yield { type: "completed", session_id: spec.session_id, ts: nowIso() };
722
+ return;
410
723
  }
411
- return (!scopedConfig && nativeAuthed) || oauthToken !== null;
412
- };
413
- const canKey = key !== null;
414
- const route = preferApi
415
- ? canKey
416
- ? "api_key"
417
- : trySub()
418
- ? "subscription"
419
- : null
420
- : trySub()
421
- ? "subscription"
422
- : canKey
423
- ? "api_key"
424
- : null;
425
- // An EXPLICIT auth preference that could not be honored is disclosed as a
426
- // typed marker; the orchestrator lifts it into route.fallback.auth_switched.
427
- const preferredRoute = preferApi ? "api_key" : "subscription";
428
- if (spec.auth_preference !== "auto" && route !== null && route !== preferredRoute) {
429
- yield {
430
- type: "message",
431
- session_id: spec.session_id,
432
- ts: nowIso(),
433
- text: `[auth] ${preferredRoute} route unavailable; fell back to ${route}`,
434
- payload: {
435
- auth_switched: true,
436
- from_auth_mode: preferredRoute === "subscription" ? "local_session" : "api_key",
437
- to_auth_mode: route === "subscription" ? "local_session" : "api_key",
438
- },
439
- };
724
+ ({ nativeEnv, key, oauthToken, subscriptionSource } = resolved);
725
+ route = resolved.route;
440
726
  }
441
- if (route === null) {
442
- yield {
443
- type: "error",
444
- session_id: spec.session_id,
445
- ts: nowIso(),
446
- error: "no usable claude auth for this envelope: native session not seedable (run `claude /login` or store a setup-token) and no Anthropic API key fallback available",
727
+ else {
728
+ const native = authPreference === "api_key"
729
+ ? { loggedIn: false, authed: false, authMethod: null, probeError: null }
730
+ : await runtime.probeAuthStatus(BIN, {
731
+ env: nativeEnv,
732
+ abortSignal,
733
+ });
734
+ // Explicit routes are strict; auto is subscription-first and alone may fall
735
+ // back to API-key auth. Preserve the exact selected subscription source so a
736
+ // native session can never be silently replaced by an OAuth-token env route.
737
+ const trySub = () => {
738
+ if (native.authed) {
739
+ subscriptionSource = "native_session";
740
+ return true;
741
+ }
742
+ if (authPreference === "auto")
743
+ oauthToken ??= runtime.claudeOAuthToken();
744
+ if (authPreference === "auto" && oauthToken !== null) {
745
+ subscriptionSource = "oauth_token_env";
746
+ return true;
747
+ }
748
+ return false;
447
749
  };
448
- yield { type: "completed", session_id: spec.session_id, ts: nowIso() };
449
- return;
750
+ route = selectClaudeRunAuthRoute(authPreference, trySub, () => {
751
+ key ??= runtime.anthropicApiKey();
752
+ return key !== null;
753
+ });
754
+ // Auto selecting its API-key fallback is a paid-route switch and must remain
755
+ // typed/visible; explicit routes never fall back.
756
+ if (authPreference === "auto" && route === "api_key") {
757
+ yield {
758
+ type: "message",
759
+ session_id: spec.session_id,
760
+ ts: nowIso(),
761
+ text: "[auth] native subscription route unavailable; auto selected api_key",
762
+ payload: {
763
+ auth_switched: true,
764
+ from_auth_mode: "local_session",
765
+ to_auth_mode: "api_key",
766
+ reason: "readiness_preferred",
767
+ },
768
+ };
769
+ }
770
+ if (route === null) {
771
+ yield {
772
+ type: "error",
773
+ session_id: spec.session_id,
774
+ ts: nowIso(),
775
+ error: authPreference === "subscription"
776
+ ? "Claude subscription auth was explicitly requested but a verified claude.ai native session is not ready"
777
+ : authPreference === "api_key"
778
+ ? "Claude API-key auth was explicitly requested but no Anthropic API key route is ready"
779
+ : "no usable Claude auth: native/setup-token subscription routes and API-key fallback are unavailable",
780
+ };
781
+ yield { type: "completed", session_id: spec.session_id, ts: nowIso() };
782
+ return;
783
+ }
450
784
  }
451
785
  const useSubscription = route === "subscription";
452
786
  const args = claudeArgsForSpec(spec, interactive, useSubscription);
453
787
  // Scrub EVERY provider secret (incl. OpenAI/others — the cross-provider leak
454
788
  // fix) via the single core table, then re-add only the var this route needs.
455
- const env = { ...spec.env, ...providerScrubEnv() };
789
+ const env = subscriptionSource === "native_session" ? nativeEnv : { ...spec.env, ...providerScrubEnv() };
456
790
  if (route === "api_key" && key) {
457
791
  env.ANTHROPIC_API_KEY = key;
458
792
  }
459
- else if (route === "subscription" && !seededCreds && oauthToken) {
793
+ else if (subscriptionSource === "oauth_token_env" && oauthToken) {
460
794
  env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;
461
795
  }
462
- yield* runCliHarness({
796
+ // Route evidence: disclose the ACTUAL auth route on the started event
797
+ // (typed `auth_route` payload); quota attribution consumes it.
798
+ const credentialRoute = useSubscription
799
+ ? "vendor_native"
800
+ : "managed_api_key";
801
+ const credentialSource = useSubscription ? subscriptionSource : "api_key_env";
802
+ const baseParser = createClaudeParser({ deniedTools: toolPermissionSets(spec).deny });
803
+ yield* runtime.runCliHarness({
463
804
  bin: BIN,
464
805
  args,
465
806
  spec,
466
807
  env,
467
808
  label: "claude",
468
809
  redact: redactSecrets,
469
- parseEvent: createClaudeParser({ deniedTools: toolPermissionSets(spec).deny }),
810
+ parseEvent: (obj, sessionId) => {
811
+ const out = baseParser(obj, sessionId);
812
+ if (out) {
813
+ for (const ev of out) {
814
+ // The auth route is fixed before spawn. Carry it on every event so
815
+ // a later api_retry/quota record remains independently attributable.
816
+ ev.credential_route = credentialRoute;
817
+ ev.credential_source = credentialSource;
818
+ if (profile)
819
+ ev.credential_profile_id = profile.profile_id;
820
+ }
821
+ }
822
+ return out;
823
+ },
470
824
  ...(interactive
471
825
  ? {
472
826
  session: {
473
- initialStdin: initialSessionFrames(spec.prompt, imageBlocks),
827
+ initialStdin: initialSessionFrames(spec.prompt, attachmentBlocks),
474
828
  matches: isControlRequestFrame,
475
829
  handle: (obj, io) => handleControlRequestFrame(obj, io, spec.session_id, channel),
476
830
  closeStdinOn: isResultFrame,