@bjoernrochel/sevro 0.1.0-rc.8 → 1.0.0

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.
Files changed (228) hide show
  1. package/CONTRIBUTING.md +22 -6
  2. package/README.md +5 -1
  3. package/docs/README.md +19 -15
  4. package/docs/architecture.md +138 -17
  5. package/docs/assets/README.md +5 -4
  6. package/docs/builtin-graders-v1.md +9 -2
  7. package/docs/codex-host.md +155 -57
  8. package/docs/contracts.md +2 -1
  9. package/docs/creating-cases.md +9 -2
  10. package/docs/development-cli.md +187 -19
  11. package/docs/documentation-quality.md +12 -2
  12. package/docs/evaluation-workflow.md +110 -0
  13. package/docs/extension-protocol-v1.md +26 -3
  14. package/docs/getting-started.md +23 -2
  15. package/docs/guide-evaluation.md +33 -1
  16. package/docs/identity-v1.md +25 -2
  17. package/docs/installing.md +11 -11
  18. package/docs/native-controls.md +76 -5
  19. package/docs/native-hosts.md +196 -7
  20. package/docs/reading-results.md +102 -0
  21. package/docs/recipes.md +489 -0
  22. package/docs/releases.md +31 -12
  23. package/docs/report-v1.md +29 -1
  24. package/docs/results-v1.md +122 -4
  25. package/docs/run-settings-v1.md +147 -0
  26. package/docs/runtime-v1.md +255 -22
  27. package/docs/troubleshooting.md +12 -12
  28. package/docs/typescript-quality.md +89 -26
  29. package/examples/recipes/candidate.ts +26 -0
  30. package/examples/recipes/cases/coding-change.json +21 -0
  31. package/examples/recipes/cases/host-tools.json +21 -0
  32. package/examples/recipes/cases/mise.json +21 -0
  33. package/examples/recipes/cases/node.json +21 -0
  34. package/examples/recipes/cases/python-uv.json +21 -0
  35. package/examples/recipes/cases/semantic.json +17 -0
  36. package/examples/recipes/coding-change/check.ts +23 -0
  37. package/examples/recipes/extension-artifacts.ts +65 -0
  38. package/examples/recipes/extension-methods.ts +174 -0
  39. package/examples/recipes/extension.ts +37 -0
  40. package/examples/recipes/repository-skill/.agents/skills/startup-guide/SKILL.md +7 -0
  41. package/examples/recipes/repository-skill/.claude/skills/startup-guide/SKILL.md +7 -0
  42. package/examples/recipes/repository-skill/README.md +3 -0
  43. package/examples/recipes/run-inputs.ts +188 -0
  44. package/examples/recipes/run-options.ts +131 -0
  45. package/examples/recipes/run.ts +63 -0
  46. package/examples/recipes/runtime/empty.json +1 -0
  47. package/examples/recipes/runtime/goals.json +1 -0
  48. package/examples/recipes/runtime/mise.json +6 -0
  49. package/examples/recipes/runtime/path.json +1 -0
  50. package/examples/recipes/runtime/plugins.json +4 -0
  51. package/examples/recipes/runtime/python-uv.json +10 -0
  52. package/examples/recipes/semantic-judge.ts +29 -0
  53. package/examples/templates/coding/adapter.ts +17 -0
  54. package/examples/templates/coding/case.json +15 -0
  55. package/examples/templates/repository-skill/.agents/skills/startup-guide/SKILL.md +6 -0
  56. package/examples/templates/repository-skill/extension-data.ts +64 -0
  57. package/examples/templates/repository-skill/extension.ts +31 -0
  58. package/examples/templates/response/adapter.ts +12 -0
  59. package/examples/templates/response/case.json +13 -0
  60. package/package.json +11 -5
  61. package/schemas/report-v1.schema.json +9 -0
  62. package/schemas/run-evidence-v1.schema.json +2 -1
  63. package/schemas/runtime-v1.schema.json +43 -7
  64. package/src/advisory.ts +2 -2
  65. package/src/candidate-artifacts.ts +42 -11
  66. package/src/candidate-codex-state.ts +185 -0
  67. package/src/case-validation.ts +73 -50
  68. package/src/cli/cancellation.ts +20 -0
  69. package/src/cli/command.ts +87 -0
  70. package/src/cli/completion.ts +15 -0
  71. package/src/cli/demo.ts +60 -0
  72. package/src/cli/evaluation.ts +55 -0
  73. package/src/cli/extension.ts +79 -0
  74. package/src/cli/help.ts +198 -0
  75. package/src/cli/hosts.ts +55 -0
  76. package/src/cli/init-settings.ts +30 -0
  77. package/src/cli/init.ts +111 -0
  78. package/src/{cli-invocation.ts → cli/invocation.ts} +214 -218
  79. package/src/cli/loading.ts +151 -0
  80. package/src/cli/preparation.ts +154 -0
  81. package/src/cli/presentation.ts +93 -0
  82. package/src/cli/progress.ts +6 -0
  83. package/src/cli/run-manifest.ts +128 -0
  84. package/src/cli/run-options.ts +403 -0
  85. package/src/cli/run-overrides.ts +69 -0
  86. package/src/cli/settings.ts +154 -0
  87. package/src/cli/summary.ts +43 -0
  88. package/src/cli.ts +13 -591
  89. package/src/contract-types.ts +94 -0
  90. package/src/engine.ts +9 -5
  91. package/src/evaluation/advisory.ts +150 -0
  92. package/src/{evaluation-check-plan.ts → evaluation/check-plan.ts} +103 -67
  93. package/src/evaluation/collection.ts +88 -0
  94. package/src/evaluation/context.ts +10 -0
  95. package/src/{evaluation-grading-host.ts → evaluation/grading-host.ts} +5 -5
  96. package/src/evaluation/grading.ts +235 -0
  97. package/src/{evaluation-invocation-plan.ts → evaluation/invocation-plan.ts} +147 -58
  98. package/src/{evaluation-plan.ts → evaluation/plan.ts} +208 -141
  99. package/src/evaluation/progress.ts +19 -0
  100. package/src/evaluation/resources.ts +241 -0
  101. package/src/evaluation/run.ts +206 -0
  102. package/src/{evaluation-semantic.ts → evaluation/semantic.ts} +77 -87
  103. package/src/evaluation/trial.ts +800 -0
  104. package/src/{evaluation-types.ts → evaluation/types.ts} +79 -29
  105. package/src/evidence/host-results.ts +85 -0
  106. package/src/evidence/retained-artifacts.ts +18 -0
  107. package/src/evidence/trial-writer.ts +203 -0
  108. package/src/evidence/usage.ts +17 -0
  109. package/src/extension-client.ts +34 -96
  110. package/src/extension-session.ts +24 -48
  111. package/src/{advisory-fixture.ts → fixtures/advisory.ts} +12 -72
  112. package/src/{fixture-bin.ts → fixtures/bin.ts} +1 -1
  113. package/src/{fixture-cleanup.ts → fixtures/cleanup.ts} +13 -7
  114. package/src/fixtures/create.ts +183 -0
  115. package/src/{generated-fixture.ts → fixtures/generated.ts} +10 -53
  116. package/src/{git-hooks.ts → fixtures/git-hooks.ts} +1 -1
  117. package/src/{preparation.ts → fixtures/preparation.ts} +6 -16
  118. package/src/{repository-fixture.ts → fixtures/repository.ts} +30 -64
  119. package/src/{fixture-setup.ts → fixtures/setup.ts} +33 -51
  120. package/src/{fixture-tools.ts → fixtures/tools.ts} +1 -1
  121. package/src/{workspace-state.ts → fixtures/workspace-state.ts} +5 -5
  122. package/src/generated/cli-result.d.cts +2 -1
  123. package/src/generated/extension.d.cts +2 -1
  124. package/src/generated/report.cjs +1 -1
  125. package/src/generated/report.d.cts +2 -1
  126. package/src/generated/run-evidence.cjs +1 -1
  127. package/src/generated/run-evidence.d.cts +2 -1
  128. package/src/generated/runtime.cjs +1 -1
  129. package/src/generated/runtime.d.cts +2 -1
  130. package/src/git.ts +93 -0
  131. package/src/graders/git-head.ts +29 -84
  132. package/src/graders/shell.ts +50 -68
  133. package/src/host-diagnostic-redaction.ts +52 -0
  134. package/src/host-diagnostics.ts +331 -0
  135. package/src/hosts/claude-continuation.ts +57 -40
  136. package/src/hosts/claude-credential.ts +160 -63
  137. package/src/hosts/claude-events.ts +34 -18
  138. package/src/hosts/claude-keychain.ts +78 -21
  139. package/src/hosts/claude-login-recovery.ts +85 -0
  140. package/src/hosts/claude-login-refresh.ts +178 -0
  141. package/src/hosts/claude-login-store.ts +162 -0
  142. package/src/hosts/claude-login.ts +107 -0
  143. package/src/hosts/claude-native-goal.ts +15 -12
  144. package/src/hosts/claude-process.ts +10 -50
  145. package/src/hosts/claude-refresh-lock.ts +81 -0
  146. package/src/hosts/claude-repository-invocation.ts +9 -34
  147. package/src/hosts/claude-settings.ts +46 -9
  148. package/src/hosts/claude-skill-activations.ts +169 -0
  149. package/src/hosts/claude-state.ts +28 -15
  150. package/src/hosts/claude-tool-calls.ts +13 -10
  151. package/src/hosts/claude.ts +121 -81
  152. package/src/hosts/codex-app-server.ts +78 -52
  153. package/src/hosts/codex-configuration.ts +5 -10
  154. package/src/hosts/codex-exec-controls.ts +516 -0
  155. package/src/hosts/codex-marketplace.ts +12 -10
  156. package/src/hosts/codex-native-calls.ts +15 -122
  157. package/src/hosts/codex-process.ts +10 -183
  158. package/src/hosts/codex-profile.ts +36 -21
  159. package/src/hosts/codex-session-files.ts +1 -9
  160. package/src/hosts/codex-state.ts +134 -18
  161. package/src/hosts/codex.ts +210 -183
  162. package/src/hosts/isolation-common.ts +8 -11
  163. package/src/hosts/isolation-roots.ts +2 -2
  164. package/src/hosts/linux-sandbox.ts +15 -2
  165. package/src/hosts/mac-sandbox.ts +87 -18
  166. package/src/hosts/native-controls.ts +72 -22
  167. package/src/hosts/native-request.ts +55 -0
  168. package/src/hosts/process-lifecycle.ts +216 -48
  169. package/src/hosts/protected-worktrees.ts +1 -1
  170. package/src/hosts/skill-hook-catalog.ts +131 -0
  171. package/src/hosts/skill-hook-handler.ts +374 -0
  172. package/src/hosts/skill-hooks.ts +577 -0
  173. package/src/instrumentation.ts +7 -8
  174. package/src/native-transcript-state.ts +22 -3
  175. package/src/native-transcript-view.ts +52 -13
  176. package/src/native-transcripts.ts +26 -11
  177. package/src/paths.ts +22 -0
  178. package/src/process-lifecycle.ts +150 -0
  179. package/src/provenance.ts +27 -7
  180. package/src/prune.ts +300 -0
  181. package/src/report.ts +63 -36
  182. package/src/resolved-case.ts +1 -1
  183. package/src/results-lock.ts +42 -0
  184. package/src/results.ts +14 -18
  185. package/src/{runtime-config.ts → runtime/config.ts} +160 -46
  186. package/src/{runtime-discovery-apple.ts → runtime/discovery-apple.ts} +3 -3
  187. package/src/{runtime-discovery-providers.ts → runtime/discovery-providers.ts} +3 -9
  188. package/src/{runtime-discovery.ts → runtime/discovery.ts} +4 -4
  189. package/src/{runtime-hook-processes.ts → runtime/hook-processes.ts} +1 -1
  190. package/src/{runtime-hooks.ts → runtime/hooks.ts} +32 -22
  191. package/src/{runtime-paths.ts → runtime/paths.ts} +5 -19
  192. package/src/{runtime-query.ts → runtime/query.ts} +5 -5
  193. package/src/{runtime-seeds.ts → runtime/seeds.ts} +3 -3
  194. package/src/runtime/shared-cache.ts +130 -0
  195. package/src/{runtime-state.ts → runtime/state.ts} +28 -11
  196. package/src/schema-types.ts +73 -161
  197. package/src/shell.ts +4 -0
  198. package/src/transcript-retention.ts +146 -0
  199. package/src/value-guards.ts +22 -0
  200. package/docs/assets/sevro-logo.png +0 -0
  201. package/docs/assets/sevro-symbol.svg +0 -1
  202. package/docs/assets/sevro-wordmark.svg +0 -1
  203. package/docs/cache-ownership-validation.md +0 -49
  204. package/docs/claude-continuation-validation.md +0 -47
  205. package/docs/concurrency-validation.md +0 -86
  206. package/docs/configuration-root-validation.md +0 -70
  207. package/docs/engine-slice.md +0 -114
  208. package/docs/evidence/runtime-startup-30/README.md +0 -185
  209. package/docs/evidence/runtime-startup-30/after.json +0 -319
  210. package/docs/evidence/runtime-startup-30/baseline-timeout.jsonl +0 -2
  211. package/docs/evidence/runtime-startup-30/before.json +0 -403
  212. package/docs/evidence/runtime-startup-30/consumer-smoke.json +0 -63
  213. package/docs/evidence/runtime-startup-30/full-policy-diagnostic.jsonl +0 -3
  214. package/docs/evidence/runtime-startup-30/minimal-policy-diagnostic.jsonl +0 -3
  215. package/docs/evidence/runtime-startup-30/plugin-template-assessment.json +0 -20
  216. package/docs/evidence/runtime-startup-30/seedless-timeout.jsonl +0 -2
  217. package/docs/evidence.md +0 -17
  218. package/docs/fixture-cleanup-validation.md +0 -73
  219. package/docs/guide-verification.json +0 -239
  220. package/src/evaluation-context.ts +0 -259
  221. package/src/evaluation-fixture.ts +0 -302
  222. package/src/evaluation-run.ts +0 -200
  223. package/src/evaluation-trial.ts +0 -1016
  224. package/src/hosts/claude-nested-skills.ts +0 -272
  225. package/src/hosts/codex-command-output.ts +0 -510
  226. package/src/hosts/codex-executor-command.ts +0 -291
  227. package/src/hosts/codex-skill-reads.ts +0 -783
  228. /package/src/{trial-scheduler.ts → evaluation/scheduler.ts} +0 -0
@@ -1,7 +1,12 @@
1
1
  import { isRecord } from "../value-guards";
2
2
  import { randomUUID } from "node:crypto";
3
- import { summarizeClaudeEvents } from "./claude-events";
3
+ import {
4
+ summarizeClaudeEvents,
5
+ parseClaudeEvents,
6
+ type ClaudeEvents,
7
+ } from "./claude-events";
4
8
  import { workspaceFingerprint } from "./workspace-fingerprint";
9
+ import { MAX_EVENT_BYTES, MAX_STDERR_BYTES } from "./process-lifecycle";
5
10
 
6
11
  export type ClaudeProcessResult = { code: number; out: string; err: string };
7
12
  export type ClaudeSession = { option: "--session-id" | "--resume"; id: string };
@@ -11,29 +16,32 @@ type Observation = {
11
16
  data: Record<string, unknown>;
12
17
  };
13
18
  type ClaudeTurns = ClaudeProcessResult & {
19
+ events: ClaudeEvents;
20
+ initialEvents?: ClaudeEvents;
21
+ followUpEvents?: ClaudeEvents;
14
22
  initialOut?: string;
15
23
  followUpOut?: string;
16
24
  continuation?: Observation;
17
25
  sessionResultsBound?: boolean;
18
26
  };
19
27
 
20
- function boundResult(stream: string, session: string): boolean {
21
- try {
22
- const results = stream
23
- .split("\n")
24
- .filter((line) => line.trim())
25
- .map((line): unknown => JSON.parse(line))
26
- .filter(isRecord)
27
- .filter((entry) => entry.type === "result");
28
- const [result] = results;
29
- return (
30
- results.length === 1 &&
31
- result !== undefined &&
32
- result.session_id === session
33
- );
34
- } catch {
35
- return false;
36
- }
28
+ function boundResult(stream: ClaudeEvents, session: string): boolean {
29
+ if (stream.lines.some((line) => !line.parsed)) return false;
30
+ const results = stream.lines
31
+ .filter((line) => line.parsed)
32
+ .map((line) => line.value)
33
+ .filter(isRecord)
34
+ .filter((entry) => entry.type === "result");
35
+ const [result] = results;
36
+ return (
37
+ results.length === 1 &&
38
+ result !== undefined &&
39
+ result.session_id === session
40
+ );
41
+ }
42
+
43
+ function parsedTurn(result: ClaudeProcessResult) {
44
+ return { ...result, events: parseClaudeEvents(result.out) };
37
45
  }
38
46
 
39
47
  /** Resume a declared follow-up in the same isolated native Claude session. */
@@ -46,15 +54,18 @@ export async function runClaudeTurns(options: {
46
54
  session?: ClaudeSession,
47
55
  ) => Promise<ClaudeProcessResult>;
48
56
  }): Promise<ClaudeTurns> {
49
- if (options.followUpPrompt === undefined) return options.run(options.prompt);
57
+ if (options.followUpPrompt === undefined)
58
+ return parsedTurn(await options.run(options.prompt));
50
59
  const sessionId = randomUUID();
51
60
  const initialFingerprint = await workspaceFingerprint(options.workspace);
52
- const initial = await options.run(options.prompt, {
53
- option: "--session-id",
54
- id: sessionId,
55
- });
56
- const summary = summarizeClaudeEvents(initial.out, initial.code);
57
- const initialBound = boundResult(initial.out, sessionId);
61
+ const initial = parsedTurn(
62
+ await options.run(options.prompt, {
63
+ option: "--session-id",
64
+ id: sessionId,
65
+ }),
66
+ );
67
+ const summary = summarizeClaudeEvents(initial.events, initial.code);
68
+ const initialBound = boundResult(initial.events, sessionId);
58
69
  if (!summary.complete || !initialBound)
59
70
  return {
60
71
  ...initial,
@@ -62,10 +73,12 @@ export async function runClaudeTurns(options: {
62
73
  sessionResultsBound: initialBound,
63
74
  };
64
75
  const beforeFollowUp = await workspaceFingerprint(options.workspace);
65
- const followUp = await options.run(options.followUpPrompt, {
66
- option: "--resume",
67
- id: sessionId,
68
- });
76
+ const followUp = parsedTurn(
77
+ await options.run(options.followUpPrompt, {
78
+ option: "--resume",
79
+ id: sessionId,
80
+ }),
81
+ );
69
82
  return combinedTurns(
70
83
  initial,
71
84
  followUp,
@@ -76,17 +89,18 @@ export async function runClaudeTurns(options: {
76
89
  }
77
90
 
78
91
  function combinedOutput(
79
- initial: ClaudeProcessResult,
80
- followUp: ClaudeProcessResult,
92
+ initial: ReturnType<typeof parsedTurn>,
93
+ followUp: ReturnType<typeof parsedTurn>,
81
94
  ) {
82
95
  const out = `${initial.out.trimEnd()}\n${followUp.out.trimStart()}`;
83
- const err = [initial.err.trimEnd(), followUp.err.trimStart()]
96
+ const stderr = [initial.err.trimEnd(), followUp.err.trimStart()]
84
97
  .filter(Boolean)
85
98
  .join("\n");
86
- if (
87
- Buffer.byteLength(out, "utf8") > 8 * 1024 * 1024 ||
88
- Buffer.byteLength(err, "utf8") > 64 * 1024
89
- )
99
+ const err = new TextDecoder("utf-8", { ignoreBOM: true }).decode(
100
+ Buffer.from(stderr, "utf8").subarray(0, MAX_STDERR_BYTES),
101
+ { stream: true },
102
+ );
103
+ if (Buffer.byteLength(out, "utf8") > MAX_EVENT_BYTES)
90
104
  throw new Error("Claude combined process output exceeds its limit");
91
105
  return { out, err };
92
106
  }
@@ -112,14 +126,14 @@ function continuationObservation(
112
126
  }
113
127
 
114
128
  function combinedTurns(
115
- initial: ClaudeProcessResult,
116
- followUp: ClaudeProcessResult,
129
+ initial: ReturnType<typeof parsedTurn>,
130
+ followUp: ReturnType<typeof parsedTurn>,
117
131
  sessionId: string,
118
132
  initialFingerprint: string | null,
119
133
  beforeFollowUp: string | null,
120
134
  ): ClaudeTurns {
121
- const followUpSummary = summarizeClaudeEvents(followUp.out, followUp.code);
122
- const sessionResultsBound = boundResult(followUp.out, sessionId);
135
+ const followUpSummary = summarizeClaudeEvents(followUp.events, followUp.code);
136
+ const sessionResultsBound = boundResult(followUp.events, sessionId);
123
137
  const bound = followUpSummary.complete && sessionResultsBound;
124
138
  const { out, err } = combinedOutput(initial, followUp);
125
139
  return {
@@ -128,6 +142,9 @@ function combinedTurns(
128
142
  err,
129
143
  initialOut: initial.out,
130
144
  followUpOut: followUp.out,
145
+ events: { lines: [...initial.events.lines, ...followUp.events.lines] },
146
+ initialEvents: initial.events,
147
+ followUpEvents: followUp.events,
131
148
  sessionResultsBound,
132
149
  continuation: continuationObservation(
133
150
  bound,
@@ -1,45 +1,60 @@
1
- import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
1
+ import { mkdir } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { homedir } from "node:os";
4
-
5
- const MAX_CREDENTIAL_BYTES = 1024 * 1024;
6
-
7
- async function savedClaudeCredential(): Promise<string | undefined> {
8
- const path = join(
9
- process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude"),
10
- ".credentials.json",
11
- );
12
- try {
13
- await stat(path);
14
- return path;
15
- } catch (error) {
16
- if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
17
- throw new Error("Claude saved credential is unreadable or unavailable", {
18
- cause: error,
19
- });
20
- }
21
- }
3
+ import { nativeStateParent } from "../native-transcript-state";
4
+ import {
5
+ coversLifetime,
6
+ parseClaudeLogin,
7
+ rotatedLoginBytes,
8
+ sameTokens,
9
+ tokenDigest,
10
+ type ClaudeLogin,
11
+ } from "./claude-login";
12
+ import {
13
+ claudeLoginRecovery,
14
+ type ClaudeLoginRecovery,
15
+ } from "./claude-login-recovery";
16
+ import { refreshClaudeLogin } from "./claude-login-refresh";
17
+ import {
18
+ claudeLoginStore,
19
+ MAX_CREDENTIAL_BYTES,
20
+ requireBoundedCredential,
21
+ type ClaudeKeychain,
22
+ type ClaudeLoginStore,
23
+ } from "./claude-login-store";
24
+ import { withClaudeRefreshLock } from "./claude-refresh-lock";
22
25
 
23
26
  export const CLAUDE_AUTH_VARIABLES = [
24
27
  "ANTHROPIC_API_KEY",
25
28
  "CLAUDE_CODE_OAUTH_TOKEN",
26
29
  ] as const;
27
30
 
31
+ /** Margin beyond the execution budget, matching Claude's own refresh window. */
32
+ export const CLAUDE_LOGIN_MARGIN_MS = 5 * 60_000;
33
+
34
+ /** What a saved login must provide before a trial may use it. */
35
+ export interface ClaudeLoginRequirement {
36
+ /** Claude binary used for parent-owned refresh. */
37
+ binary: string;
38
+ /** Remaining access-token lifetime the complete execution needs. */
39
+ lifetimeMs: number;
40
+ }
41
+
28
42
  /** Keep the CLI's authentication separate from the candidate tool environment. */
29
43
  export async function stageClaudeAuthentication(
30
44
  configRoot: string,
31
- sourceFile?: string,
45
+ sourceFile: string | undefined,
46
+ requirement: ClaudeLoginRequirement,
32
47
  ) {
48
+ await mkdir(configRoot, { recursive: true, mode: 0o700 });
33
49
  const environment = authenticationEnvironment(sourceFile);
34
- if (Object.keys(environment).length) {
35
- await mkdir(configRoot, { recursive: true, mode: 0o700 });
36
- return {
37
- credentialFile: join(configRoot, ".credentials.json"),
38
- environment,
39
- };
40
- }
50
+ if (!Object.keys(environment).length)
51
+ environment.CLAUDE_CODE_OAUTH_TOKEN = await claudeAccessToken(
52
+ sourceFile,
53
+ requirement,
54
+ );
41
55
  return {
42
- credentialFile: await stageClaudeCredential(configRoot, sourceFile),
56
+ // Trials never receive a saved login; the path only anchors private state.
57
+ credentialFile: join(configRoot, ".credentials.json"),
43
58
  environment,
44
59
  };
45
60
  }
@@ -63,44 +78,126 @@ function authenticationEnvironment(sourceFile: string | undefined) {
63
78
  return environment;
64
79
  }
65
80
 
66
- /** Copy only a bounded login credential into one private host state. */
67
- export async function stageClaudeCredential(
68
- configRoot: string,
69
- sourceFile?: string,
81
+ async function platformKeychain(): Promise<ClaudeKeychain | undefined> {
82
+ if (process.platform !== "darwin") return undefined;
83
+ const { claudeKeychain } = await import("./claude-keychain");
84
+ return claudeKeychain;
85
+ }
86
+
87
+ /**
88
+ * Provide an access token from the selected saved login. The parent alone
89
+ * refreshes it, so a rotating refresh token never enters disposable state.
90
+ */
91
+ export async function claudeAccessToken(
92
+ sourceFile: string | undefined,
93
+ requirement: ClaudeLoginRequirement,
70
94
  ): Promise<string> {
71
- sourceFile ??= await savedClaudeCredential();
72
- await mkdir(configRoot, { recursive: true, mode: 0o700 });
73
- let credential: Buffer;
74
- try {
75
- credential = await readCredential(sourceFile);
76
- } catch (error) {
77
- throw new Error("Claude credential is unreadable or unavailable", {
78
- cause: error,
79
- });
80
- }
81
- requireBoundedCredential(credential);
82
- const target = join(configRoot, ".credentials.json");
83
- await writeFile(target, credential, { flag: "wx", mode: 0o600 });
84
- return target;
95
+ const store = await claudeLoginStore(sourceFile, await platformKeychain());
96
+ const recovery = await claudeLoginRecovery(
97
+ await nativeStateParent(),
98
+ store.origin,
99
+ );
100
+ const login = await storedLogin(store);
101
+ if (
102
+ coversLifetime(login, requirement.lifetimeMs) &&
103
+ !(await recovery.pending())
104
+ )
105
+ return login.accessToken;
106
+ return withClaudeRefreshLock(store.lockRoot, () =>
107
+ lockedAccessToken(store, recovery, requirement),
108
+ );
109
+ }
110
+
111
+ async function storedLogin(store: ClaudeLoginStore): Promise<ClaudeLogin> {
112
+ return parseClaudeLogin(requireBoundedCredential(await store.read()));
113
+ }
114
+
115
+ /** Re-read under the lock: another process may already have refreshed. */
116
+ async function lockedAccessToken(
117
+ store: ClaudeLoginStore,
118
+ recovery: ClaudeLoginRecovery,
119
+ requirement: ClaudeLoginRequirement,
120
+ ): Promise<string> {
121
+ const current = await recoveredLogin(store, recovery);
122
+ if (coversLifetime(current, requirement.lifetimeMs))
123
+ return current.accessToken;
124
+ const rotated = await refreshClaudeLogin(
125
+ requirement.binary,
126
+ refreshableLogin(current),
127
+ recovery.workRoot,
128
+ );
129
+ await recovery.retain(current, rotated);
130
+ await persistRotation(store, recovery, current, rotated);
131
+ await recovery.discard();
132
+ return usableAccessToken(rotated, requirement.lifetimeMs);
133
+ }
134
+
135
+ function refreshableLogin(
136
+ login: ClaudeLogin,
137
+ ): ClaudeLogin & { refreshToken: string } {
138
+ const { refreshToken } = login;
139
+ if (!refreshToken || !login.scopes.length)
140
+ throw new Error(
141
+ "Claude saved login has expired and cannot be refreshed; run claude and /login",
142
+ );
143
+ return { ...login, refreshToken };
85
144
  }
86
145
 
87
- async function readCredential(sourceFile: string | undefined) {
88
- if (!sourceFile) {
89
- if (process.platform !== "darwin")
90
- throw new Error("Claude keychain credential is unavailable");
91
- const { keychainCredential } = await import("./claude-keychain");
92
- return keychainCredential();
146
+ function usableAccessToken(login: ClaudeLogin, lifetimeMs: number): string {
147
+ if (!coversLifetime(login, lifetimeMs))
148
+ throw new Error(
149
+ `Claude login lifetime cannot cover the ${String(Math.ceil(lifetimeMs / 60_000))}-minute execution budget`,
150
+ );
151
+ return login.accessToken;
152
+ }
153
+
154
+ /** Complete a rotation left pending when its store could not be updated. */
155
+ async function recoveredLogin(
156
+ store: ClaudeLoginStore,
157
+ recovery: ClaudeLoginRecovery,
158
+ ): Promise<ClaudeLogin> {
159
+ const current = await storedLogin(store);
160
+ const pending = await recovery.pending();
161
+ if (!pending) return current;
162
+ if (pending.replaces !== tokenDigest(current.refreshToken)) {
163
+ // The store moved on through another login; never overwrite it.
164
+ await recovery.discard();
165
+ return current;
93
166
  }
94
- if ((await stat(sourceFile)).size > MAX_CREDENTIAL_BYTES)
95
- throw new Error("oversized credential");
96
- return readFile(sourceFile);
167
+ const rotated = parseClaudeLogin(
168
+ rotatedLoginBytes(current, { ...current, oauth: pending.oauth }),
169
+ );
170
+ await writeVerified(store, recovery, current, rotated);
171
+ await recovery.discard();
172
+ return rotated;
97
173
  }
98
174
 
99
- function requireBoundedCredential(credential: Buffer): void {
100
- if (
101
- credential.byteLength === 0 ||
102
- credential.byteLength > MAX_CREDENTIAL_BYTES ||
103
- !credential.toString("utf8").trim()
104
- )
105
- throw new Error("Claude credential is empty or oversized");
175
+ /** Compare-and-swap against the refresh token the rotation consumed. */
176
+ async function persistRotation(
177
+ store: ClaudeLoginStore,
178
+ recovery: ClaudeLoginRecovery,
179
+ replaced: ClaudeLogin,
180
+ rotated: ClaudeLogin,
181
+ ): Promise<void> {
182
+ const latest = await storedLogin(store);
183
+ if (latest.refreshToken !== replaced.refreshToken) return;
184
+ await writeVerified(store, recovery, latest, rotated);
185
+ }
186
+
187
+ async function writeVerified(
188
+ store: ClaudeLoginStore,
189
+ recovery: ClaudeLoginRecovery,
190
+ latest: ClaudeLogin,
191
+ rotated: ClaudeLogin,
192
+ ): Promise<void> {
193
+ try {
194
+ await store.write(rotatedLoginBytes(latest, rotated));
195
+ if (!sameTokens(await storedLogin(store), rotated))
196
+ throw new Error("the stored login does not match the rotation");
197
+ } catch (cause) {
198
+ throw new Error(
199
+ `Claude refreshed the saved login but could not update ${store.origin}; Sevro retained the refreshed login at ${recovery.path} and restores it on the next run once the store is writable`,
200
+ { cause },
201
+ );
202
+ }
106
203
  }
@@ -9,6 +9,32 @@ export class ClaudeEventError extends Error {
9
9
  }
10
10
 
11
11
  type Entry = Record<string, unknown>;
12
+ export type ClaudeEventLine =
13
+ { parsed: true; value: unknown } | { parsed: false; error: unknown };
14
+ export interface ClaudeEvents {
15
+ lines: readonly ClaudeEventLine[];
16
+ }
17
+
18
+ /** Decode JSON once; consumers retain their own event validity rules. */
19
+ export function parseClaudeEvents(stream: string | ClaudeEvents): ClaudeEvents {
20
+ if (typeof stream !== "string") return stream;
21
+ if (Buffer.byteLength(stream, "utf8") > MAX_EVENT_STREAM_BYTES)
22
+ throw new ClaudeEventError("Claude event stream exceeds 8 MiB");
23
+ return {
24
+ lines: stream
25
+ .split("\n")
26
+ .filter((line) => line.trim())
27
+ .map(parseLine),
28
+ };
29
+ }
30
+
31
+ function parseLine(line: string): ClaudeEventLine {
32
+ try {
33
+ return { parsed: true, value: JSON.parse(line) as unknown };
34
+ } catch (error) {
35
+ return { parsed: false, error };
36
+ }
37
+ }
12
38
 
13
39
  function nonnegative(value: unknown): value is number {
14
40
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
@@ -78,38 +104,28 @@ export interface ClaudeEventSummary {
78
104
  usageComplete: boolean;
79
105
  }
80
106
 
81
- /** Parse bounded Claude stream JSON without retaining its private event bodies. */
107
+ /** Summarize decoded events without exposing their private bodies. */
82
108
  export function summarizeClaudeEvents(
83
- stream: string,
109
+ stream: string | ClaudeEvents,
84
110
  exitCode: number,
85
111
  ): ClaudeEventSummary {
86
- if (Buffer.byteLength(stream, "utf8") > MAX_EVENT_STREAM_BYTES)
87
- throw new ClaudeEventError("Claude event stream exceeds 8 MiB");
88
- const { results, malformed } = parseResultEvents(stream);
112
+ const { results, malformed } = parseResultEvents(parseClaudeEvents(stream));
89
113
  return resultSummary(results, malformed, exitCode);
90
114
  }
91
115
 
92
- function parseResultEvents(stream: string) {
116
+ function parseResultEvents(stream: ClaudeEvents) {
93
117
  const results: Entry[] = [];
94
118
  let malformed = false;
95
- for (const line of stream.split("\n")) {
96
- if (!line.trim()) continue;
97
- try {
98
- const event = parseValidEvent(line);
99
- if (event.type === "result") results.push(event);
100
- } catch {
119
+ for (const line of stream.lines) {
120
+ if (!line.parsed || !validEvent(line.value)) {
101
121
  malformed = true;
122
+ continue;
102
123
  }
124
+ if (line.value.type === "result") results.push(line.value);
103
125
  }
104
126
  return { results, malformed };
105
127
  }
106
128
 
107
- function parseValidEvent(line: string): Entry {
108
- const event: unknown = JSON.parse(line);
109
- if (!validEvent(event)) throw new ClaudeEventError("Malformed Claude event");
110
- return event;
111
- }
112
-
113
129
  function summarizeUsage(results: Entry[], malformed: boolean) {
114
130
  const usages = results.map((result) => usage(result.usage));
115
131
  const validUsages = usages.filter((value): value is Usage => value !== null);
@@ -1,22 +1,20 @@
1
1
  import { existsSync } from "node:fs";
2
+ import {
3
+ MAX_CREDENTIAL_BYTES,
4
+ requireBoundedCredential,
5
+ type ClaudeKeychain,
6
+ } from "./claude-login-store";
2
7
 
3
- const MAX_CREDENTIAL_BYTES = 1024 * 1024;
8
+ const SECURITY = "/usr/bin/security";
9
+ /** `security` reports errSecItemNotFound with this exit status. */
10
+ const ITEM_NOT_FOUND = 44;
11
+ /** Claude Code's bound for one `security -i` command line. */
12
+ const INTERACTIVE_LINE_LIMIT = 4032;
13
+ const KEYCHAIN_TIMEOUT_MS = 10_000;
4
14
 
5
- function keychainProcess() {
6
- if (process.platform !== "darwin" || !existsSync("/usr/bin/security"))
15
+ function requireSecurity(): void {
16
+ if (process.platform !== "darwin" || !existsSync(SECURITY))
7
17
  throw new Error("Claude keychain credential is unavailable");
8
- const account = process.env.USER ?? process.env.LOGNAME;
9
- return Bun.spawn(
10
- [
11
- "/usr/bin/security",
12
- "find-generic-password",
13
- "-s",
14
- "Claude Code-credentials",
15
- ...(account ? ["-a", account] : []),
16
- "-w",
17
- ],
18
- { stdout: "pipe", stderr: "ignore" },
19
- );
20
18
  }
21
19
 
22
20
  async function boundedCredentialStream(
@@ -35,21 +33,30 @@ async function boundedCredentialStream(
35
33
  return Buffer.concat(chunks);
36
34
  }
37
35
 
38
- export async function keychainCredential(): Promise<Buffer> {
39
- const proc = keychainProcess();
36
+ async function findItem(
37
+ service: string,
38
+ account: string,
39
+ ): Promise<Buffer | undefined> {
40
+ requireSecurity();
41
+ const proc = Bun.spawn(
42
+ [SECURITY, "find-generic-password", "-a", account, "-s", service, "-w"],
43
+ { stdout: "pipe", stderr: "ignore" },
44
+ );
40
45
  const timer = setTimeout(() => {
41
46
  proc.kill("SIGKILL");
42
- }, 10_000);
47
+ }, KEYCHAIN_TIMEOUT_MS);
43
48
  const reader = proc.stdout.getReader();
44
49
  try {
45
50
  const credential = await boundedCredentialStream(reader);
46
- if ((await proc.exited) !== 0)
51
+ const code = await proc.exited;
52
+ if (code === ITEM_NOT_FOUND) return undefined;
53
+ if (code !== 0)
47
54
  throw new Error("Claude keychain credential is unavailable");
48
- return credential;
55
+ return requireBoundedCredential(credential);
49
56
  } catch (error) {
50
57
  proc.kill("SIGKILL");
51
58
  await proc.exited;
52
- throw new Error("Claude keychain credential is unavailable", {
59
+ throw new Error("Claude credential is unreadable or unavailable", {
53
60
  cause: error,
54
61
  });
55
62
  } finally {
@@ -57,3 +64,53 @@ export async function keychainCredential(): Promise<Buffer> {
57
64
  reader.releaseLock();
58
65
  }
59
66
  }
67
+
68
+ /** Mirror Claude Code's update so the secret stays off argv when it fits. */
69
+ function updateCommand(service: string, account: string, bytes: Buffer) {
70
+ const hex = bytes.toString("hex");
71
+ const line = `add-generic-password -U -a "${account}" -s "${service}" -X "${hex}"\n`;
72
+ return line.length <= INTERACTIVE_LINE_LIMIT
73
+ ? { command: [SECURITY, "-i"], stdin: Buffer.from(line) }
74
+ : {
75
+ command: [
76
+ SECURITY,
77
+ "add-generic-password",
78
+ "-U",
79
+ "-a",
80
+ account,
81
+ "-s",
82
+ service,
83
+ "-X",
84
+ hex,
85
+ ],
86
+ stdin: undefined,
87
+ };
88
+ }
89
+
90
+ async function updateItem(
91
+ service: string,
92
+ account: string,
93
+ bytes: Buffer,
94
+ ): Promise<void> {
95
+ requireSecurity();
96
+ const { command, stdin } = updateCommand(service, account, bytes);
97
+ const proc = Bun.spawn(command, {
98
+ stdin: stdin ?? "ignore",
99
+ stdout: "ignore",
100
+ stderr: "ignore",
101
+ });
102
+ const timer = setTimeout(() => {
103
+ proc.kill("SIGKILL");
104
+ }, KEYCHAIN_TIMEOUT_MS);
105
+ try {
106
+ if ((await proc.exited) !== 0)
107
+ throw new Error("Claude keychain login could not be updated");
108
+ } finally {
109
+ clearTimeout(timer);
110
+ }
111
+ }
112
+
113
+ export const claudeKeychain: ClaudeKeychain = {
114
+ read: findItem,
115
+ write: updateItem,
116
+ };