@claudexor/harness-claude 1.0.1 → 2.1.3

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,42 @@
1
- import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
2
- import { homedir, tmpdir } from "node:os";
3
- 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";
1
+ import { ConformanceReport as ConformanceReportSchema, HarnessManifest as HarnessManifestSchema, } from "@claudexor/schema";
2
+ import { abortSignalFromSpec, browserMcpCommand, HarnessUnavailableError, interactionChannelFromSpec, labelStreams, needsScopedHomeKeychainBridge, normalizeEffort, providerScrubEnv, resolveHarnessBinary, runCapture, runCliHarness, PROVIDER_SECRET_ENV, selectStrictAuthRoute, selectedAuthAvailable, selectedAuthReady, shouldVerifyApiKey, } from "@claudexor/core";
6
3
  import { resolveSecret } from "@claudexor/secrets";
7
4
  import { CLAUDEXOR_VERSION, nowIso, redactSecrets } from "@claudexor/util";
5
+ import { CLAUDE_CAPABILITY_PROFILE } from "./capability-profile.js";
6
+ import { claudeNativeLoginRemedy } from "./doctor-remedy.js";
7
+ import { claudeNativeHomeEnv, defaultNativeClaudeConfigDir } from "./native-home.js";
8
+ export { defaultNativeClaudeConfigDir } from "./native-home.js";
8
9
  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");
12
- const CLAUDE_CAPABILITY_PROFILE = HarnessCapabilityProfileSchema.parse({
13
- auth: {
14
- supported_sources: ["native_session", "api_key_env"],
15
- preferred_source: null,
16
- credential_transports: [
17
- { source: "native_session", kind: "config_file", relocatable_by: ["CONFIG_DIR"] },
18
- { source: "api_key_env", kind: "env_var", relocatable_by: ["ENV"] },
19
- ],
20
- },
21
- access_control: { readonly_mechanism: "tool_allowlist" },
22
- isolation: { supported_containment: ["env_or_file_injection"] },
23
- image_input: "base64_stream",
24
- });
10
+ import { probeClaudeCredentialProfile, resolveClaudeProfileRoute } from "./profile.js";
11
+ export { canonicalProfileConfigDir } from "./profile.js";
12
+ import { smokeIsolatedApiKey, smokeIsolatedOAuthToken } from "./smoke.js";
13
+ import { claudeAttachmentBlocks, handleControlRequestFrame, initialSessionFrames, isControlRequestFrame, isResultFrame, } from "./interactive.js";
14
+ export const BIN = process.env.CLAUDEXOR_CLAUDE_BIN || "claude";
15
+ export const CLAUDE_PROVIDER_ENV_DENYLIST = PROVIDER_SECRET_ENV.filter((k) => k !== "ANTHROPIC_API_KEY");
25
16
  /**
26
17
  * Ordered (weakest→strongest) reasoning-effort levels `claude --effort` accepts.
27
18
  * 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
19
+ * ladder is low|medium|high|max. SINGLE source for the manifest's
29
20
  * `effort_levels` and the run-time normalizer (which now clamps nothing away).
30
21
  */
31
- const CLAUDE_EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
22
+ const CLAUDE_EFFORT_LEVELS = ["low", "medium", "high", "max"];
23
+ /** Exported for focused route-policy tests; runtime uses this exact selector. */
24
+ export const selectClaudeRunAuthRoute = selectStrictAuthRoute;
32
25
  function permissionArgs(access) {
33
26
  switch (access) {
34
27
  case "readonly":
35
- return [];
28
+ // Defense in depth: plan mode rejects mutation requests, setting sources
29
+ // prevent user/project policy from widening the route, strict MCP ignores
30
+ // project servers, and slash commands/Chrome are independent tool ingress.
31
+ return [
32
+ "--permission-mode",
33
+ "plan",
34
+ "--setting-sources",
35
+ "",
36
+ "--strict-mcp-config",
37
+ "--disable-slash-commands",
38
+ "--no-chrome",
39
+ ];
36
40
  case "workspace_write":
37
41
  return ["--permission-mode", "acceptEdits"];
38
42
  case "full":
@@ -42,34 +46,120 @@ function permissionArgs(access) {
42
46
  return [];
43
47
  }
44
48
  }
45
- async function detectVersion() {
49
+ const CLAUDE_READONLY_REQUIRED_FLAGS = [
50
+ "--tools",
51
+ "--setting-sources",
52
+ "--strict-mcp-config",
53
+ "--permission-mode",
54
+ "--disable-slash-commands",
55
+ "--no-chrome",
56
+ ];
57
+ let readonlyProbePromise = null;
58
+ export function probeClaudeReadonlyProfile(abortSignal) {
59
+ if (readonlyProbePromise)
60
+ return readonlyProbePromise;
61
+ readonlyProbePromise = (async () => {
62
+ try {
63
+ const result = await runCapture(BIN, ["--help"], {
64
+ timeoutMs: 10_000,
65
+ abortSignal,
66
+ cancelSignal: "SIGTERM",
67
+ cancelKillDelayMs: 0,
68
+ });
69
+ const help = `${result.stdout}\n${result.stderr}`;
70
+ const missingFlags = CLAUDE_READONLY_REQUIRED_FLAGS.filter((flag) => !help.includes(flag));
71
+ const hasPlanMode = help.includes('"plan"') || help.includes("plan,") || help.includes(", plan");
72
+ if (!hasPlanMode)
73
+ missingFlags.push("--permission-mode=plan");
74
+ return {
75
+ supported: result.code === 0 && missingFlags.length === 0,
76
+ missingFlags,
77
+ detail: result.code === 0 && missingFlags.length === 0
78
+ ? "installed Claude CLI exposes the complete restrictive readonly flag set"
79
+ : `readonly enforcement unavailable; missing ${missingFlags.join(", ") || `help exited ${result.code}`}`,
80
+ };
81
+ }
82
+ catch (error) {
83
+ return {
84
+ supported: false,
85
+ missingFlags: [...CLAUDE_READONLY_REQUIRED_FLAGS],
86
+ detail: `readonly enforcement probe failed: ${redactSecrets(error instanceof Error ? error.message : String(error))}`,
87
+ };
88
+ }
89
+ })();
90
+ return readonlyProbePromise;
91
+ }
92
+ async function detectVersion(abortSignal) {
46
93
  try {
47
- const r = await runCapture(BIN, ["--version"], { timeoutMs: 10_000 });
94
+ const r = await runCapture(BIN, ["--version"], {
95
+ timeoutMs: 10_000,
96
+ abortSignal,
97
+ cancelSignal: "SIGTERM",
98
+ cancelKillDelayMs: 0,
99
+ });
48
100
  return r.stdout.trim() || `${BIN} (version unknown)`;
49
101
  }
50
102
  catch {
51
103
  return null;
52
104
  }
53
105
  }
54
- async function authStatusOk() {
106
+ export function claudeNativeEnv(base, configDir) {
107
+ const raw = {
108
+ ...(base ?? {}),
109
+ ...providerScrubEnv(),
110
+ };
111
+ const native = needsScopedHomeKeychainBridge(CLAUDE_CAPABILITY_PROFILE)
112
+ ? claudeNativeHomeEnv(raw)
113
+ : raw;
114
+ return {
115
+ ...native,
116
+ CLAUDE_CONFIG_DIR: configDir ?? defaultNativeClaudeConfigDir(),
117
+ };
118
+ }
119
+ export async function probeAuthStatus(bin = BIN, options = {}) {
55
120
  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;
121
+ const env = claudeNativeEnv(options.env, options.configDir);
122
+ const r = await (options.runCapture ?? runCapture)(bin, ["auth", "status"], {
123
+ env,
124
+ timeoutMs: 10_000,
125
+ abortSignal: options.abortSignal,
126
+ cancelSignal: "SIGTERM",
127
+ cancelKillDelayMs: 0,
128
+ });
129
+ try {
130
+ const verdict = JSON.parse(r.stdout.trim());
131
+ if (typeof verdict.loggedIn === "boolean" && typeof verdict.authMethod === "string") {
132
+ return {
133
+ loggedIn: verdict.loggedIn,
134
+ authed: verdict.loggedIn && verdict.authMethod === "claude.ai",
135
+ authMethod: verdict.authMethod,
136
+ probeError: null,
137
+ };
138
+ }
139
+ }
140
+ catch {
141
+ /* no typed JSON verdict: fall through to probe-error disclosure */
142
+ }
143
+ const detail = labelStreams(r.stderr, r.stdout, { transform: redactSecrets }) ??
144
+ `claude auth status exited with ${r.code ?? r.signal ?? "unknown result"}`;
145
+ return { loggedIn: false, authed: false, authMethod: null, probeError: detail };
66
146
  }
67
- catch {
68
- return false;
147
+ catch (err) {
148
+ return {
149
+ loggedIn: false,
150
+ authed: false,
151
+ authMethod: null,
152
+ probeError: [...redactSecrets(err instanceof Error ? err.message : String(err))]
153
+ .slice(0, 300)
154
+ .join(""),
155
+ };
69
156
  }
70
157
  }
71
- function anthropicApiKey() {
72
- return process.env.CLAUDEXOR_ANTHROPIC_API_KEY || resolveSecret("anthropic") || process.env.ANTHROPIC_API_KEY || null;
158
+ export function anthropicApiKey() {
159
+ return (process.env.CLAUDEXOR_ANTHROPIC_API_KEY ||
160
+ resolveSecret("anthropic") ||
161
+ process.env.ANTHROPIC_API_KEY ||
162
+ null);
73
163
  }
74
164
  /** A stored/long-lived Claude Code OAuth (`claude setup-token`) for headless
75
165
  * subscription auth. The hermetic kill switch is honored inside resolveSecret
@@ -77,92 +167,75 @@ function anthropicApiKey() {
77
167
  function claudeOAuthToken() {
78
168
  return resolveSecret("claude_oauth") || process.env.CLAUDE_CODE_OAUTH_TOKEN || null;
79
169
  }
80
- /** The user's real Claude config dir (native subscription session lives here). */
81
- export function defaultNativeClaudeConfigDir() {
82
- const override = process.env.CLAUDEXOR_CLAUDE_NATIVE_DIR;
83
- if (override && override.trim())
84
- return override;
85
- return join(homedir(), ".claude");
86
- }
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
- }
170
+ export function claudeAuthSourceReadiness(input) {
171
+ const nativeReady = input.native.authed && input.native.probeError === null;
172
+ const nativeAvailability = input.native.probeError
173
+ ? "unknown"
174
+ : input.native.loggedIn
175
+ ? "available"
176
+ : "unavailable";
177
+ const nativeVerification = nativeReady
178
+ ? "passed"
179
+ : input.native.probeError || !input.native.loggedIn
180
+ ? "not_run"
181
+ : "failed";
182
+ return [
183
+ {
184
+ source: "native_session",
185
+ availability: nativeAvailability,
186
+ verification: nativeVerification,
187
+ detail: nativeReady
188
+ ? "vendor status confirmed authMethod=claude.ai in the exact run environment"
189
+ : input.native.probeError
190
+ ? `auth-status probe failed: ${redactClaudeDoctorDetail(input.native.probeError)}`
191
+ : input.native.loggedIn
192
+ ? `Claude is logged in via ${input.native.authMethod ?? "unknown"}, not claude.ai`
193
+ : "official native Claude session is not logged in",
194
+ },
195
+ {
196
+ source: "oauth_token_env",
197
+ availability: input.oauthAvailable ? "available" : "unavailable",
198
+ verification: input.oauthVerification,
199
+ detail: input.oauthDetail,
200
+ },
201
+ {
202
+ source: "api_key_env",
203
+ availability: input.apiKeyAvailable ? "available" : "unavailable",
204
+ verification: input.apiKeyVerification,
205
+ detail: input.apiKeyDetail,
206
+ },
207
+ ];
150
208
  }
151
- function redactClaudeDoctorDetail(text) {
209
+ export function redactClaudeDoctorDetail(text) {
152
210
  return redactSecrets(text).slice(0, 500);
153
211
  }
154
- export function createClaudeAdapter() {
212
+ export function createClaudeAdapter(deps = {}) {
213
+ const runtime = {
214
+ detectVersion,
215
+ probeAuthStatus,
216
+ anthropicApiKey,
217
+ claudeOAuthToken,
218
+ resolveProfileSecret: (ref) => resolveSecret(ref),
219
+ smokeIsolatedApiKey,
220
+ smokeIsolatedOAuthToken,
221
+ probeReadonlyProfile: probeClaudeReadonlyProfile,
222
+ runCliHarness,
223
+ ...deps,
224
+ };
155
225
  return {
156
226
  id: "claude",
157
227
  async discover() {
158
- const version = await detectVersion();
228
+ const version = await runtime.detectVersion();
159
229
  if (version === null) {
160
230
  throw new HarnessUnavailableError("claude CLI not found on PATH (set CLAUDEXOR_CLAUDE_BIN to override)");
161
231
  }
162
- const apiKey = anthropicApiKey() !== null;
163
- const authed = await authStatusOk();
232
+ const apiKey = runtime.anthropicApiKey() !== null;
233
+ const readonlyProfile = await runtime.probeReadonlyProfile();
234
+ const native = await runtime.probeAuthStatus(BIN, { env: claudeNativeEnv() });
235
+ const authed = native.authed;
236
+ const oauthTokenAvailable = runtime.claudeOAuthToken() !== null;
164
237
  const authModes = [
165
- ...(authed ? ["local_session"] : []),
238
+ ...(authed || oauthTokenAvailable ? ["local_session"] : []),
166
239
  ...(apiKey ? ["api_key"] : []),
167
240
  ];
168
241
  return HarnessManifestSchema.parse({
@@ -215,17 +288,31 @@ export function createClaudeAdapter() {
215
288
  },
216
289
  capability_profile: {
217
290
  ...CLAUDE_CAPABILITY_PROFILE,
291
+ access_control: {
292
+ readonly_mechanism: readonlyProfile.supported ? "tool_allowlist" : "none",
293
+ },
218
294
  auth: {
219
295
  ...CLAUDE_CAPABILITY_PROFILE.auth,
220
- preferred_source: apiKey ? "api_key_env" : authed ? "native_session" : null,
296
+ preferred_source: authed
297
+ ? "native_session"
298
+ : oauthTokenAvailable
299
+ ? "oauth_token_env"
300
+ : apiKey
301
+ ? "api_key_env"
302
+ : null,
221
303
  },
222
304
  },
223
305
  auth_modes: authModes,
224
- access_profiles_supported: ["readonly", "workspace_write", "full", "inherit_native"],
306
+ access_profiles_supported: [
307
+ ...(readonlyProfile.supported ? ["readonly"] : []),
308
+ "workspace_write",
309
+ "full",
310
+ "inherit_native",
311
+ ],
225
312
  });
226
313
  },
227
314
  async doctor(_spec) {
228
- const version = await detectVersion();
315
+ const version = await runtime.detectVersion(_spec.abortSignal);
229
316
  if (version === null) {
230
317
  return ConformanceReportSchema.parse({
231
318
  harness_id: "claude",
@@ -234,45 +321,206 @@ export function createClaudeAdapter() {
234
321
  reasons: ["claude CLI not found (install Claude Code or set CLAUDEXOR_CLAUDE_BIN)"],
235
322
  });
236
323
  }
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"];
324
+ const readonlyProfile = await runtime.probeReadonlyProfile(_spec.abortSignal);
325
+ const requestedSource = _spec.authSource;
326
+ const probeNative = requestedSource === undefined || requestedSource === "native_session";
327
+ const probeOAuth = requestedSource === undefined || requestedSource === "oauth_token_env";
328
+ const probeApi = requestedSource === undefined || requestedSource === "api_key_env";
329
+ const nativeEnv = probeNative ? claudeNativeEnv(_spec.env) : _spec.env;
330
+ const login = probeNative
331
+ ? await runtime.probeAuthStatus(BIN, {
332
+ env: nativeEnv,
333
+ abortSignal: _spec.abortSignal,
334
+ })
335
+ : { loggedIn: false, authed: false, authMethod: null, probeError: null };
336
+ const nativeCliReady = login.authed;
337
+ // Native-session and stored setup-token proofs are separate sources.
338
+ const oauthToken = probeOAuth ? runtime.claudeOAuthToken() : null;
339
+ const oauthTokenAvailable = oauthToken !== null;
340
+ const apiKey = probeApi && runtime.anthropicApiKey() !== null;
341
+ const preference = requestedSource === "native_session" || requestedSource === "oauth_token_env"
342
+ ? "subscription"
343
+ : requestedSource === "api_key_env"
344
+ ? "api_key"
345
+ : (_spec.authPreference ?? "auto");
346
+ const shouldSmokeOAuth = probeOAuth && oauthToken !== null && !nativeCliReady && preference !== "api_key";
347
+ const oauthSmoke = shouldSmokeOAuth && oauthToken
348
+ ? await runtime.smokeIsolatedOAuthToken(oauthToken, _spec.abortSignal)
349
+ : {
350
+ ok: false,
351
+ detail: oauthTokenAvailable
352
+ ? "verification not run for the unselected setup-token route"
353
+ : "no Claude setup-token available",
354
+ };
355
+ const nativeAvailable = login.loggedIn || oauthTokenAvailable;
356
+ const subscriptionReady = nativeCliReady || oauthSmoke.ok;
357
+ const shouldSmokeKey = probeApi &&
358
+ shouldVerifyApiKey({ preference, apiKeyAvailable: apiKey, nativeReady: subscriptionReady });
359
+ const apiSmoke = shouldSmokeKey
360
+ ? await runtime.smokeIsolatedApiKey(_spec.abortSignal)
361
+ : {
362
+ ok: false,
363
+ detail: apiKey
364
+ ? "verification not run for the unselected API-key route"
365
+ : "no API key fallback available",
366
+ };
367
+ const ok = selectedAuthReady({
368
+ preference,
369
+ nativeReady: subscriptionReady,
370
+ apiKeyReady: apiSmoke.ok,
371
+ });
372
+ const selectedAvailable = selectedAuthAvailable({
373
+ preference,
374
+ nativeAvailable,
375
+ apiKeyAvailable: apiKey,
376
+ });
377
+ const probeUnknown = preference !== "api_key" && login.probeError !== null && !oauthTokenAvailable;
378
+ // INV-067: name the real cause + designed remedy (see doctor-remedy.ts).
379
+ const nativeLoginRemedy = claudeNativeLoginRemedy(nativeEnv);
380
+ const allIntents = [
381
+ "plan",
382
+ "spec",
383
+ "implement",
384
+ "repair",
385
+ "create_from_scratch",
386
+ "review",
387
+ "verify",
388
+ "synthesize",
389
+ "explain",
390
+ "audit",
391
+ "orchestrate",
392
+ ];
393
+ const binPath = resolveHarnessBinary(BIN);
394
+ const producedSources = claudeAuthSourceReadiness({
395
+ native: login,
396
+ oauthAvailable: oauthTokenAvailable,
397
+ oauthVerification: oauthSmoke.ok ? "passed" : shouldSmokeOAuth ? "failed" : "not_run",
398
+ oauthDetail: oauthSmoke.detail,
399
+ apiKeyAvailable: apiKey,
400
+ apiKeyVerification: apiSmoke.ok ? "passed" : shouldSmokeKey ? "failed" : "not_run",
401
+ apiKeyDetail: apiSmoke.detail,
402
+ });
403
+ const authSources = requestedSource === undefined
404
+ ? producedSources
405
+ : producedSources.filter((source) => source.source === requestedSource);
406
+ if (requestedSource !== undefined && authSources.length === 0) {
407
+ authSources.push({
408
+ source: requestedSource,
409
+ availability: "unavailable",
410
+ verification: "not_run",
411
+ detail: `Claude does not support ${requestedSource}`,
412
+ });
413
+ }
414
+ const authReasons = ok
415
+ ? []
416
+ : preference === "subscription"
417
+ ? [
418
+ login.probeError && !oauthTokenAvailable
419
+ ? `Claude native-session probe failed: ${redactClaudeDoctorDetail(login.probeError)}`
420
+ : oauthTokenAvailable
421
+ ? `Claude setup-token verification failed: ${oauthSmoke.detail}`
422
+ : `Claude subscription route is not ready: ${nativeLoginRemedy}`,
423
+ ]
424
+ : preference === "api_key"
425
+ ? [
426
+ apiKey
427
+ ? `isolated Claude API-key smoke failed: ${apiSmoke.detail}`
428
+ : "Claude API-key route is not configured",
429
+ ]
430
+ : apiKey
431
+ ? [`isolated Claude API-key smoke failed: ${apiSmoke.detail}`]
432
+ : [`not authenticated: ${nativeLoginRemedy}`];
246
433
  return ConformanceReportSchema.parse({
247
434
  harness_id: "claude",
248
- status: ok ? "ok" : authed || apiKey ? "degraded" : "unavailable",
435
+ status: ok
436
+ ? readonlyProfile.supported
437
+ ? "ok"
438
+ : "degraded"
439
+ : selectedAvailable || probeUnknown
440
+ ? "degraded"
441
+ : "unavailable",
249
442
  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 },
443
+ {
444
+ id: "installed",
445
+ status: "pass",
446
+ detail: binPath ? `${version} at ${binPath}` : version,
447
+ },
448
+ {
449
+ id: "readonly_enforcement",
450
+ status: readonlyProfile.supported ? "pass" : "fail",
451
+ detail: readonlyProfile.detail,
452
+ },
453
+ ...(probeNative
454
+ ? [
455
+ {
456
+ id: "native_session",
457
+ status: nativeCliReady ? "pass" : "fail",
458
+ detail: nativeCliReady
459
+ ? "vendor status confirmed authMethod=claude.ai in the exact run environment"
460
+ : login.probeError
461
+ ? `auth-status probe failed (NOT an auth verdict): ${redactClaudeDoctorDetail(login.probeError)}`
462
+ : login.loggedIn
463
+ ? `logged in via ${login.authMethod ?? "unknown"}, not claude.ai`
464
+ : "not logged in (run `claude auth login --claudeai`)",
465
+ },
466
+ ]
467
+ : []),
468
+ ...(probeOAuth
469
+ ? [
470
+ {
471
+ id: "oauth_setup_token",
472
+ status: oauthSmoke.ok ? "pass" : shouldSmokeOAuth ? "fail" : "skip",
473
+ detail: oauthSmoke.detail,
474
+ },
475
+ ]
476
+ : []),
477
+ ...(probeApi
478
+ ? [
479
+ {
480
+ id: "stored_key",
481
+ status: apiKey ? "pass" : "fail",
482
+ detail: apiKey
483
+ ? "anthropic secret/env available (API-key fallback)"
484
+ : "no anthropic key fallback",
485
+ },
486
+ {
487
+ id: "isolated_api_smoke",
488
+ status: apiSmoke.ok ? "pass" : shouldSmokeKey ? "fail" : "skip",
489
+ detail: apiSmoke.detail,
490
+ },
491
+ ]
492
+ : []),
254
493
  ],
494
+ auth_sources: authSources,
255
495
  enabled_intents: ok ? allIntents : [],
256
496
  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)"],
497
+ reasons: [...authReasons, ...(readonlyProfile.supported ? [] : [readonlyProfile.detail])],
262
498
  });
263
499
  },
264
500
  run(spec) {
265
- return runClaude(spec);
501
+ return runClaude(spec, runtime);
266
502
  },
267
503
  review(spec) {
268
- return runClaude(spec);
504
+ return runClaude(spec, runtime);
505
+ },
506
+ probeCredentialProfile(profile, abortSignal) {
507
+ return probeClaudeCredentialProfile(profile, runtime, abortSignal);
269
508
  },
270
509
  };
271
510
  }
272
511
  /** Claude's native names for web-permissioned tools. This knowledge lives ONLY in the adapter. */
273
512
  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"];
513
+ const CLAUDE_READONLY_ALLOWED_TOOLS = ["Read", "Glob", "Grep"];
514
+ const CLAUDE_READONLY_BUILTIN_TOOLS = [...CLAUDE_READONLY_ALLOWED_TOOLS, ...CLAUDE_WEB_TOOLS];
515
+ const CLAUDE_READONLY_DENIED_TOOLS = [
516
+ "Bash",
517
+ "Write",
518
+ "Edit",
519
+ "MultiEdit",
520
+ "NotebookEdit",
521
+ "Agent",
522
+ "Skill",
523
+ ];
276
524
  export function claudeArgsForSpec(spec, interactive = false, suppressBare = false) {
277
525
  // Interactive sessions deliver the prompt as a stream-json user message on
278
526
  // stdin (the control protocol's transport); one-shot runs keep the prompt arg.
@@ -280,10 +528,30 @@ export function claudeArgsForSpec(spec, interactive = false, suppressBare = fals
280
528
  // permission prompts (AskUserQuestion included) onto the control channel as
281
529
  // control_request frames instead of headless auto-denial.
282
530
  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)];
531
+ ? [
532
+ "-p",
533
+ "--output-format",
534
+ "stream-json",
535
+ "--input-format",
536
+ "stream-json",
537
+ "--verbose",
538
+ "--permission-prompt-tool",
539
+ "stdio",
540
+ ...permissionArgs(spec.access),
541
+ ]
542
+ : [
543
+ "-p",
544
+ spec.prompt,
545
+ "--output-format",
546
+ "stream-json",
547
+ "--verbose",
548
+ ...permissionArgs(spec.access),
549
+ ];
285
550
  if (spec.model_hint)
286
551
  args.push("--model", spec.model_hint);
552
+ // W-C4 live deltas (engine-gated to single-candidate lanes; parser tags payload.delta).
553
+ if (spec.stream_deltas)
554
+ args.push("--include-partial-messages");
287
555
  // Clamp onto claude's declared effort ladder; null = not
288
556
  // requested OR not tunable -> pass no flag. Never sends an invalid level.
289
557
  const eff = normalizeEffort(spec.effort_hint, CLAUDE_EFFORT_LEVELS);
@@ -291,6 +559,11 @@ export function claudeArgsForSpec(spec, interactive = false, suppressBare = fals
291
559
  args.push("--effort", eff);
292
560
  if (spec.max_turns !== null && spec.max_turns > 0)
293
561
  args.push("--max-turns", String(spec.max_turns));
562
+ // Per-run caller instructions APPEND to (never replace) the default system
563
+ // prompt, current-invocation-only. The engine withholds them from synthesis,
564
+ // reviewers, and the auth smoke.
565
+ if (spec.instructions && spec.instructions.trim())
566
+ args.push("--append-system-prompt", spec.instructions);
294
567
  // Structured output: constrain the FINAL message to the caller's JSON
295
568
  // Schema. LIVE-VERIFIED (2.1.165): `--json-schema <inline JSON>` with
296
569
  // --output-format stream-json. Passed only when the engine set it (the
@@ -319,6 +592,10 @@ export function claudeArgsForSpec(spec, interactive = false, suppressBare = fals
319
592
  function toolPermissionArgs(spec) {
320
593
  const { allow, deny } = toolPermissionSets(spec);
321
594
  const args = [];
595
+ if (spec.access === "readonly") {
596
+ const builtins = CLAUDE_READONLY_BUILTIN_TOOLS.filter((tool) => allow.has(tool));
597
+ args.push("--tools", builtins.join(","));
598
+ }
322
599
  if (allow.size > 0)
323
600
  args.push("--allowedTools", [...allow].join(","));
324
601
  if (deny.size > 0)
@@ -327,9 +604,11 @@ function toolPermissionArgs(spec) {
327
604
  }
328
605
  function toolPermissionSets(spec) {
329
606
  const policy = spec.external_context_policy;
330
- const allow = new Set(spec.tool_permission_policy.allow);
607
+ // A run may narrow readonly access but can never widen it. User/project
608
+ // Claude settings are independently suppressed by the readonly argv profile.
609
+ const allow = new Set(spec.access === "readonly" ? [] : spec.tool_permission_policy.allow);
331
610
  const deny = new Set(spec.tool_permission_policy.deny);
332
- if (spec.access === "readonly" && readonlyMechanism(CLAUDE_CAPABILITY_PROFILE) === "tool_allowlist") {
611
+ if (spec.access === "readonly") {
333
612
  for (const tool of CLAUDE_READONLY_ALLOWED_TOOLS) {
334
613
  if (!deny.has(tool))
335
614
  allow.add(tool);
@@ -368,109 +647,155 @@ function toolPermissionSets(spec) {
368
647
  function claudeBrowserArgs(spec) {
369
648
  if (!spec.browser || spec.external_context_policy === "off")
370
649
  return [];
371
- const cfg = JSON.stringify({ mcpServers: { browser: { command: resolveNpxBin(), args: playwrightMcpArgs(spec.browser) } } });
650
+ const mcp = browserMcpCommand(spec.browser);
651
+ const cfg = JSON.stringify({
652
+ mcpServers: { browser: mcp },
653
+ });
372
654
  return ["--mcp-config", cfg];
373
655
  }
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.
656
+ async function* runClaude(spec, runtime) {
657
+ const abortSignal = abortSignalFromSpec(spec);
658
+ if (spec.access === "readonly") {
659
+ const readonlyProfile = await runtime.probeReadonlyProfile(abortSignal);
660
+ if (!readonlyProfile.supported) {
661
+ yield {
662
+ type: "error",
663
+ session_id: spec.session_id,
664
+ ts: nowIso(),
665
+ error: `Claude readonly enforcement is unavailable: ${readonlyProfile.detail}`,
666
+ payload: {
667
+ code: "readonly_enforcement_unavailable",
668
+ missing_flags: readonlyProfile.missingFlags,
669
+ },
670
+ };
671
+ yield { type: "completed", session_id: spec.session_id, ts: nowIso() };
672
+ return;
385
673
  }
386
674
  }
387
- return blocks;
388
- }
389
- async function* runClaude(spec) {
390
675
  const channel = interactionChannelFromSpec(spec);
391
- const imageBlocks = claudeImageBlocks(spec.attachments);
676
+ const attachmentBlocks = claudeAttachmentBlocks(spec.attachments);
392
677
  // Images ride ONLY the stdin stream-json transport, so an attachment forces
393
678
  // the interactive path even with no interaction channel (control frames then
394
679
  // 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;
680
+ const interactive = channel !== undefined || attachmentBlocks.length > 0;
681
+ const profile = spec.credential_profile;
682
+ const authPreference = spec.auth_preference ?? "auto";
683
+ let nativeEnv = claudeNativeEnv(spec.env);
684
+ let key = null;
685
+ let oauthToken = null;
686
+ let subscriptionSource = null;
687
+ let route;
688
+ if (profile) {
689
+ const resolved = await resolveClaudeProfileRoute(profile, spec.env, runtime, abortSignal);
690
+ if (resolved.refusal !== null) {
691
+ yield { type: "error", session_id: spec.session_id, ts: nowIso(), error: resolved.refusal };
692
+ yield { type: "completed", session_id: spec.session_id, ts: nowIso() };
693
+ return;
410
694
  }
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
- };
695
+ ({ nativeEnv, key, oauthToken, subscriptionSource } = resolved);
696
+ route = resolved.route;
440
697
  }
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",
698
+ else {
699
+ const native = authPreference === "api_key"
700
+ ? { loggedIn: false, authed: false, authMethod: null, probeError: null }
701
+ : await runtime.probeAuthStatus(BIN, {
702
+ env: nativeEnv,
703
+ abortSignal,
704
+ });
705
+ // Explicit routes are strict; auto is subscription-first and alone may fall
706
+ // back to API-key auth. Preserve the exact selected subscription source so a
707
+ // native session can never be silently replaced by an OAuth-token env route.
708
+ const trySub = () => {
709
+ if (native.authed) {
710
+ subscriptionSource = "native_session";
711
+ return true;
712
+ }
713
+ if (authPreference === "auto")
714
+ oauthToken ??= runtime.claudeOAuthToken();
715
+ if (authPreference === "auto" && oauthToken !== null) {
716
+ subscriptionSource = "oauth_token_env";
717
+ return true;
718
+ }
719
+ return false;
447
720
  };
448
- yield { type: "completed", session_id: spec.session_id, ts: nowIso() };
449
- return;
721
+ route = selectClaudeRunAuthRoute(authPreference, trySub, () => {
722
+ key ??= runtime.anthropicApiKey();
723
+ return key !== null;
724
+ });
725
+ // Auto selecting its API-key fallback is a paid-route switch and must remain
726
+ // typed/visible; explicit routes never fall back.
727
+ if (authPreference === "auto" && route === "api_key") {
728
+ yield {
729
+ type: "message",
730
+ session_id: spec.session_id,
731
+ ts: nowIso(),
732
+ text: "[auth] native subscription route unavailable; auto selected api_key",
733
+ payload: {
734
+ auth_switched: true,
735
+ from_auth_mode: "local_session",
736
+ to_auth_mode: "api_key",
737
+ reason: "readiness_preferred",
738
+ },
739
+ };
740
+ }
741
+ if (route === null) {
742
+ yield {
743
+ type: "error",
744
+ session_id: spec.session_id,
745
+ ts: nowIso(),
746
+ error: authPreference === "subscription"
747
+ ? "Claude subscription auth was explicitly requested but a verified claude.ai native session is not ready"
748
+ : authPreference === "api_key"
749
+ ? "Claude API-key auth was explicitly requested but no Anthropic API key route is ready"
750
+ : "no usable Claude auth: native/setup-token subscription routes and API-key fallback are unavailable",
751
+ };
752
+ yield { type: "completed", session_id: spec.session_id, ts: nowIso() };
753
+ return;
754
+ }
450
755
  }
451
756
  const useSubscription = route === "subscription";
452
757
  const args = claudeArgsForSpec(spec, interactive, useSubscription);
453
758
  // Scrub EVERY provider secret (incl. OpenAI/others — the cross-provider leak
454
759
  // fix) via the single core table, then re-add only the var this route needs.
455
- const env = { ...spec.env, ...providerScrubEnv() };
760
+ const env = subscriptionSource === "native_session" ? nativeEnv : { ...spec.env, ...providerScrubEnv() };
456
761
  if (route === "api_key" && key) {
457
762
  env.ANTHROPIC_API_KEY = key;
458
763
  }
459
- else if (route === "subscription" && !seededCreds && oauthToken) {
764
+ else if (subscriptionSource === "oauth_token_env" && oauthToken) {
460
765
  env.CLAUDE_CODE_OAUTH_TOKEN = oauthToken;
461
766
  }
462
- yield* runCliHarness({
767
+ // Route evidence: disclose the ACTUAL auth route on the started event
768
+ // (typed `auth_route` payload); quota attribution consumes it.
769
+ const credentialRoute = useSubscription
770
+ ? "vendor_native"
771
+ : "managed_api_key";
772
+ const credentialSource = useSubscription ? subscriptionSource : "api_key_env";
773
+ const baseParser = createClaudeParser({ deniedTools: toolPermissionSets(spec).deny });
774
+ yield* runtime.runCliHarness({
463
775
  bin: BIN,
464
776
  args,
465
777
  spec,
466
778
  env,
467
779
  label: "claude",
468
780
  redact: redactSecrets,
469
- parseEvent: createClaudeParser({ deniedTools: toolPermissionSets(spec).deny }),
781
+ parseEvent: (obj, sessionId) => {
782
+ const out = baseParser(obj, sessionId);
783
+ if (out) {
784
+ for (const ev of out) {
785
+ // The auth route is fixed before spawn. Carry it on every event so
786
+ // a later api_retry/quota record remains independently attributable.
787
+ ev.credential_route = credentialRoute;
788
+ ev.credential_source = credentialSource;
789
+ if (profile)
790
+ ev.credential_profile_id = profile.profile_id;
791
+ }
792
+ }
793
+ return out;
794
+ },
470
795
  ...(interactive
471
796
  ? {
472
797
  session: {
473
- initialStdin: initialSessionFrames(spec.prompt, imageBlocks),
798
+ initialStdin: initialSessionFrames(spec.prompt, attachmentBlocks),
474
799
  matches: isControlRequestFrame,
475
800
  handle: (obj, io) => handleControlRequestFrame(obj, io, spec.session_id, channel),
476
801
  closeStdinOn: isResultFrame,