@bjoernrochel/sevro 0.1.0-rc.7 → 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 (230) 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 +17 -3
  7. package/docs/codex-host.md +155 -49
  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 +141 -4
  25. package/docs/run-settings-v1.md +147 -0
  26. package/docs/runtime-v1.md +307 -18
  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 +63 -7
  64. package/src/advisory.ts +2 -2
  65. package/src/candidate-artifacts.ts +206 -0
  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 +13 -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} +133 -115
  103. package/src/evaluation/trial.ts +800 -0
  104. package/src/evaluation/types.ts +207 -0
  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} +15 -76
  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/fixtures/workspace-state.ts +212 -0
  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 +57 -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 +91 -14
  148. package/src/hosts/claude-skill-activations.ts +169 -0
  149. package/src/hosts/claude-state.ts +46 -16
  150. package/src/hosts/claude-tool-calls.ts +13 -10
  151. package/src/hosts/claude.ts +122 -73
  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 -54
  158. package/src/hosts/codex-profile.ts +50 -21
  159. package/src/hosts/codex-session-files.ts +1 -9
  160. package/src/hosts/codex-state.ts +141 -19
  161. package/src/hosts/codex.ts +221 -179
  162. package/src/hosts/isolation-common.ts +15 -11
  163. package/src/hosts/isolation-roots.ts +13 -20
  164. package/src/hosts/linux-sandbox.ts +27 -3
  165. package/src/hosts/mac-sandbox.ts +111 -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 +221 -45
  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 +34 -15
  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/run-owner.ts +4 -4
  186. package/src/{runtime-config.ts → runtime/config.ts} +182 -44
  187. package/src/{runtime-discovery-apple.ts → runtime/discovery-apple.ts} +3 -3
  188. package/src/{runtime-discovery-providers.ts → runtime/discovery-providers.ts} +3 -9
  189. package/src/{runtime-discovery.ts → runtime/discovery.ts} +4 -4
  190. package/src/{runtime-hook-processes.ts → runtime/hook-processes.ts} +1 -1
  191. package/src/{runtime-hooks.ts → runtime/hooks.ts} +33 -22
  192. package/src/{runtime-paths.ts → runtime/paths.ts} +6 -18
  193. package/src/{runtime-query.ts → runtime/query.ts} +5 -5
  194. package/src/{runtime-seeds.ts → runtime/seeds.ts} +3 -3
  195. package/src/runtime/shared-cache.ts +130 -0
  196. package/src/{runtime-state.ts → runtime/state.ts} +28 -11
  197. package/src/schema-types.ts +73 -161
  198. package/src/shell.ts +4 -0
  199. package/src/transcript-retention.ts +146 -0
  200. package/src/value-guards.ts +22 -0
  201. package/docs/assets/sevro-logo.png +0 -0
  202. package/docs/assets/sevro-symbol.svg +0 -1
  203. package/docs/assets/sevro-wordmark.svg +0 -1
  204. package/docs/cache-ownership-validation.md +0 -49
  205. package/docs/claude-continuation-validation.md +0 -47
  206. package/docs/concurrency-validation.md +0 -86
  207. package/docs/configuration-root-validation.md +0 -70
  208. package/docs/engine-slice.md +0 -114
  209. package/docs/evidence/runtime-startup-30/README.md +0 -185
  210. package/docs/evidence/runtime-startup-30/after.json +0 -319
  211. package/docs/evidence/runtime-startup-30/baseline-timeout.jsonl +0 -2
  212. package/docs/evidence/runtime-startup-30/before.json +0 -403
  213. package/docs/evidence/runtime-startup-30/consumer-smoke.json +0 -63
  214. package/docs/evidence/runtime-startup-30/full-policy-diagnostic.jsonl +0 -3
  215. package/docs/evidence/runtime-startup-30/minimal-policy-diagnostic.jsonl +0 -3
  216. package/docs/evidence/runtime-startup-30/plugin-template-assessment.json +0 -20
  217. package/docs/evidence/runtime-startup-30/seedless-timeout.jsonl +0 -2
  218. package/docs/evidence.md +0 -17
  219. package/docs/fixture-cleanup-validation.md +0 -70
  220. package/docs/guide-verification.json +0 -239
  221. package/src/evaluation-context.ts +0 -257
  222. package/src/evaluation-fixture.ts +0 -313
  223. package/src/evaluation-run.ts +0 -198
  224. package/src/evaluation-trial.ts +0 -946
  225. package/src/evaluation-types.ts +0 -149
  226. package/src/hosts/claude-nested-skills.ts +0 -272
  227. package/src/hosts/codex-command-output.ts +0 -510
  228. package/src/hosts/codex-executor-command.ts +0 -291
  229. package/src/hosts/codex-skill-reads.ts +0 -609
  230. /package/src/{trial-scheduler.ts → evaluation/scheduler.ts} +0 -0
@@ -0,0 +1,85 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { isRecord } from "../value-guards";
5
+ import { rotatedOAuth, tokenDigest, type ClaudeLogin } from "./claude-login";
6
+
7
+ const FORMAT = "sevro.claude-login-recovery.v1";
8
+
9
+ /** A rotated pair retained until its canonical store verifiably holds it. */
10
+ export interface PendingRotation {
11
+ /** Digest of the refresh token the rotation consumed. */
12
+ replaces: string;
13
+ oauth: Record<string, unknown>;
14
+ }
15
+
16
+ /** Private, per-origin recovery slot for one refreshed login. */
17
+ export class ClaudeLoginRecovery {
18
+ readonly path: string;
19
+ readonly workRoot: string;
20
+
21
+ constructor(root: string, origin: string) {
22
+ const key = createHash("sha256").update(origin).digest("hex");
23
+ this.path = join(root, `${key}.json`);
24
+ this.workRoot = join(root, "work");
25
+ }
26
+
27
+ async pending(): Promise<PendingRotation | undefined> {
28
+ let text: string;
29
+ try {
30
+ text = await readFile(this.path, "utf8");
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ return pendingRotation(text);
35
+ }
36
+
37
+ /** Durably retain the rotation before any canonical write is attempted. */
38
+ async retain(replaced: ClaudeLogin, rotated: ClaudeLogin): Promise<void> {
39
+ const record: PendingRotation & { format: string } = {
40
+ format: FORMAT,
41
+ replaces: tokenDigest(replaced.refreshToken),
42
+ oauth: rotatedOAuth(rotated),
43
+ };
44
+ const temporary = `${this.path}.${randomUUID()}.tmp`;
45
+ await writeFile(temporary, JSON.stringify(record), {
46
+ flag: "wx",
47
+ mode: 0o600,
48
+ });
49
+ await rename(temporary, this.path);
50
+ }
51
+
52
+ async discard(): Promise<void> {
53
+ await rm(this.path, { force: true });
54
+ }
55
+ }
56
+
57
+ function pendingRotation(text: string): PendingRotation | undefined {
58
+ let value: unknown;
59
+ try {
60
+ value = JSON.parse(text);
61
+ } catch {
62
+ return undefined;
63
+ }
64
+ return isPendingRecord(value)
65
+ ? { replaces: value.replaces, oauth: value.oauth }
66
+ : undefined;
67
+ }
68
+
69
+ function isPendingRecord(value: unknown): value is PendingRotation {
70
+ return (
71
+ isRecord(value) &&
72
+ value.format === FORMAT &&
73
+ typeof value.replaces === "string" &&
74
+ isRecord(value.oauth)
75
+ );
76
+ }
77
+
78
+ export async function claudeLoginRecovery(
79
+ stateRoot: string,
80
+ origin: string,
81
+ ): Promise<ClaudeLoginRecovery> {
82
+ const root = join(stateRoot, "claude-login-recovery");
83
+ await mkdir(root, { recursive: true, mode: 0o700 });
84
+ return new ClaudeLoginRecovery(root, origin);
85
+ }
@@ -0,0 +1,178 @@
1
+ import {
2
+ mkdir,
3
+ mkdtemp,
4
+ readFile,
5
+ rm,
6
+ stat,
7
+ writeFile,
8
+ } from "node:fs/promises";
9
+ import { delimiter, join } from "node:path";
10
+ import { parseClaudeLogin, type ClaudeLogin } from "./claude-login";
11
+ import { MAX_CREDENTIAL_BYTES } from "./claude-login-store";
12
+
13
+ /** First Claude Code release verified to provide refresh-token login. */
14
+ export const CLAUDE_REFRESH_MINIMUM_VERSION = "2.1.284";
15
+ const MINIMUM_VERSION = [2, 1, 284] as const;
16
+ const VERSION_TIMEOUT_MS = 10_000;
17
+ const HELPER_TIMEOUT_MS = 60_000;
18
+ /** Network settings the operator may need to reach Anthropic's token endpoint. */
19
+ const FORWARDED_VARIABLES = [
20
+ "HTTPS_PROXY",
21
+ "https_proxy",
22
+ "HTTP_PROXY",
23
+ "http_proxy",
24
+ "NO_PROXY",
25
+ "no_proxy",
26
+ "NODE_EXTRA_CA_CERTS",
27
+ "SSL_CERT_FILE",
28
+ ] as const;
29
+ /**
30
+ * Claude resolves `security` through PATH. Refusing it makes the helper keep
31
+ * the rotated login in its private credential file instead of a Keychain item.
32
+ */
33
+ const SECURITY_STUB =
34
+ '#!/bin/sh\n[ "$1" = find-generic-password ] && exit 44\nexit 1\n';
35
+
36
+ /**
37
+ * Exchange the login's refresh token with Claude Code's documented
38
+ * `CLAUDE_CODE_OAUTH_REFRESH_TOKEN` login in a disposable configuration.
39
+ * The caller owns the lock, durable writeback, and recovery.
40
+ */
41
+ export async function refreshClaudeLogin(
42
+ binary: string,
43
+ login: ClaudeLogin & { refreshToken: string },
44
+ workRoot: string,
45
+ ): Promise<ClaudeLogin> {
46
+ await requireRefreshCapableClaude(binary);
47
+ await mkdir(workRoot, { recursive: true, mode: 0o700 });
48
+ const helper = await mkdtemp(join(workRoot, "refresh-"));
49
+ try {
50
+ const paths = await prepareHelper(helper);
51
+ const code = await runHelper(binary, helperEnvironment(paths, login));
52
+ return await storedRotation(paths.config, login, code);
53
+ } finally {
54
+ await rm(helper, { recursive: true, force: true });
55
+ }
56
+ }
57
+
58
+ async function requireRefreshCapableClaude(binary: string): Promise<void> {
59
+ const version = await claudeVersion(binary);
60
+ if (!version || !atLeastMinimum(version))
61
+ throw new Error(
62
+ `Refreshing the saved Claude login requires Claude Code ${CLAUDE_REFRESH_MINIMUM_VERSION} or later`,
63
+ );
64
+ }
65
+
66
+ async function claudeVersion(binary: string): Promise<number[] | undefined> {
67
+ const proc = Bun.spawn([binary, "--version"], {
68
+ stdin: "ignore",
69
+ stdout: "pipe",
70
+ stderr: "ignore",
71
+ });
72
+ const timer = setTimeout(() => {
73
+ proc.kill("SIGKILL");
74
+ }, VERSION_TIMEOUT_MS);
75
+ try {
76
+ const [output, code] = await Promise.all([
77
+ new Response(proc.stdout).text(),
78
+ proc.exited,
79
+ ]);
80
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(output.trim());
81
+ return code === 0 && match ? match.slice(1).map(Number) : undefined;
82
+ } finally {
83
+ clearTimeout(timer);
84
+ }
85
+ }
86
+
87
+ export function atLeastMinimum(version: number[]): boolean {
88
+ for (const [index, minimum] of MINIMUM_VERSION.entries()) {
89
+ const part = version[index] ?? 0;
90
+ if (part !== minimum) return part > minimum;
91
+ }
92
+ return true;
93
+ }
94
+
95
+ async function prepareHelper(helper: string) {
96
+ const paths = {
97
+ config: join(helper, "config"),
98
+ home: join(helper, "home"),
99
+ bin: join(helper, "bin"),
100
+ };
101
+ await Promise.all(
102
+ Object.values(paths).map((path) => mkdir(path, { mode: 0o700 })),
103
+ );
104
+ await writeFile(join(paths.bin, "security"), SECURITY_STUB, {
105
+ flag: "wx",
106
+ mode: 0o700,
107
+ });
108
+ return paths;
109
+ }
110
+
111
+ function helperEnvironment(
112
+ paths: Awaited<ReturnType<typeof prepareHelper>>,
113
+ login: ClaudeLogin & { refreshToken: string },
114
+ ): Record<string, string> {
115
+ const forwarded = Object.fromEntries(
116
+ FORWARDED_VARIABLES.flatMap((name) => {
117
+ const value = process.env[name];
118
+ return value ? [[name, value]] : [];
119
+ }),
120
+ );
121
+ return {
122
+ ...forwarded,
123
+ PATH: [paths.bin, process.env.PATH ?? "/usr/bin:/bin"].join(delimiter),
124
+ HOME: paths.home,
125
+ TMPDIR: paths.home,
126
+ LANG: process.env.LANG ?? "C",
127
+ NO_COLOR: "1",
128
+ CLAUDE_CONFIG_DIR: paths.config,
129
+ CLAUDE_CODE_OAUTH_REFRESH_TOKEN: login.refreshToken,
130
+ CLAUDE_CODE_OAUTH_SCOPES: login.scopes.join(" "),
131
+ CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: "1",
132
+ };
133
+ }
134
+
135
+ /** Output is discarded: it is never needed and must not reach evidence. */
136
+ async function runHelper(
137
+ binary: string,
138
+ env: Record<string, string>,
139
+ ): Promise<number> {
140
+ const proc = Bun.spawn([binary, "auth", "login"], {
141
+ env,
142
+ cwd: env.HOME,
143
+ stdin: "ignore",
144
+ stdout: "ignore",
145
+ stderr: "ignore",
146
+ });
147
+ const timer = setTimeout(() => {
148
+ proc.kill("SIGKILL");
149
+ }, HELPER_TIMEOUT_MS);
150
+ try {
151
+ return await proc.exited;
152
+ } finally {
153
+ clearTimeout(timer);
154
+ }
155
+ }
156
+
157
+ /** Accept only a persisted rotation; exit status alone proves nothing. */
158
+ async function storedRotation(
159
+ config: string,
160
+ login: ClaudeLogin,
161
+ code: number,
162
+ ): Promise<ClaudeLogin> {
163
+ const rotated = await helperLogin(join(config, ".credentials.json"));
164
+ if (!rotated?.refreshToken || rotated.accessToken === login.accessToken)
165
+ throw new Error(
166
+ `Claude could not refresh the saved login (helper exit ${String(code)}); run claude and /login if it has expired`,
167
+ );
168
+ return rotated;
169
+ }
170
+
171
+ async function helperLogin(path: string): Promise<ClaudeLogin | undefined> {
172
+ try {
173
+ if ((await stat(path)).size > MAX_CREDENTIAL_BYTES) return undefined;
174
+ return parseClaudeLogin(await readFile(path));
175
+ } catch {
176
+ return undefined;
177
+ }
178
+ }
@@ -0,0 +1,162 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import {
3
+ readFile,
4
+ realpath,
5
+ rename,
6
+ rm,
7
+ stat,
8
+ writeFile,
9
+ } from "node:fs/promises";
10
+ import { homedir, userInfo } from "node:os";
11
+ import { dirname, join } from "node:path";
12
+
13
+ export const MAX_CREDENTIAL_BYTES = 1024 * 1024;
14
+
15
+ /** The authoritative store that holds one saved Claude login. */
16
+ export interface ClaudeLoginStore {
17
+ /** Credential-free identity of the store, used in errors and recovery. */
18
+ origin: string;
19
+ /** Claude configuration directory whose refresh lock guards this store. */
20
+ lockRoot: string;
21
+ read(): Promise<Buffer>;
22
+ write(bytes: Buffer): Promise<void>;
23
+ }
24
+
25
+ /** Keychain operations; the macOS module supplies them only on Darwin. */
26
+ export interface ClaudeKeychain {
27
+ read(service: string, account: string): Promise<Buffer | undefined>;
28
+ write(service: string, account: string, bytes: Buffer): Promise<void>;
29
+ }
30
+
31
+ /** Resolve the store Claude Code itself uses for the selected login. */
32
+ export async function claudeLoginStore(
33
+ sourceFile?: string,
34
+ keychain?: ClaudeKeychain,
35
+ ): Promise<ClaudeLoginStore> {
36
+ if (sourceFile) return fileLoginStore(sourceFile, dirname(sourceFile));
37
+ const configRoot = claudeConfigRoot();
38
+ const file = join(configRoot, ".credentials.json");
39
+ const saved = await keychainLoginStore(configRoot, keychain).catch(
40
+ async (cause: unknown) => {
41
+ // Claude falls back to its saved file when the Keychain cannot be read.
42
+ if (await savedFileExists(file)) return undefined;
43
+ throw cause;
44
+ },
45
+ );
46
+ if (saved) return saved;
47
+ await requireSavedFile(file);
48
+ return fileLoginStore(file, configRoot);
49
+ }
50
+
51
+ async function savedFileExists(path: string): Promise<boolean> {
52
+ try {
53
+ await stat(path);
54
+ return true;
55
+ } catch {
56
+ return false;
57
+ }
58
+ }
59
+
60
+ function claudeConfigRoot(): string {
61
+ return (
62
+ process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude")
63
+ ).normalize("NFC");
64
+ }
65
+
66
+ /** Claude keys a non-default configuration's Keychain item by its path. */
67
+ export function claudeKeychainService(): string {
68
+ const configured = process.env.CLAUDE_CONFIG_DIR;
69
+ if (!configured) return "Claude Code-credentials";
70
+ const digest = createHash("sha256")
71
+ .update(configured.normalize("NFC"))
72
+ .digest("hex");
73
+ return `Claude Code-credentials-${digest.substring(0, 8)}`;
74
+ }
75
+
76
+ export function claudeKeychainAccount(): string {
77
+ let account: string;
78
+ try {
79
+ account = process.env.USER || userInfo().username;
80
+ } catch {
81
+ return "claude-code-user";
82
+ }
83
+ return /^[a-zA-Z0-9._-]+$/.test(account) ? account : "claude-code-user";
84
+ }
85
+
86
+ async function keychainLoginStore(
87
+ configRoot: string,
88
+ keychain: ClaudeKeychain | undefined,
89
+ ): Promise<ClaudeLoginStore | undefined> {
90
+ if (!keychain) return undefined;
91
+ const service = claudeKeychainService();
92
+ const account = claudeKeychainAccount();
93
+ if (!(await keychain.read(service, account))) return undefined;
94
+ return {
95
+ origin: `macOS Keychain item "${service}"`,
96
+ lockRoot: configRoot,
97
+ read: async () => requireStoredLogin(await keychain.read(service, account)),
98
+ write: (bytes) => keychain.write(service, account, bytes),
99
+ };
100
+ }
101
+
102
+ async function requireSavedFile(path: string): Promise<void> {
103
+ try {
104
+ await stat(path);
105
+ } catch (cause) {
106
+ const code = (cause as NodeJS.ErrnoException).code;
107
+ throw new Error(
108
+ code === "ENOENT"
109
+ ? "Claude saved login is unavailable; run claude and /login"
110
+ : "Claude saved credential is unreadable or unavailable",
111
+ { cause },
112
+ );
113
+ }
114
+ }
115
+
116
+ function requireStoredLogin(bytes: Buffer | undefined): Buffer {
117
+ if (!bytes) throw new Error("Claude credential is unreadable or unavailable");
118
+ return bytes;
119
+ }
120
+
121
+ export function requireBoundedCredential(credential: Buffer): Buffer {
122
+ if (
123
+ credential.byteLength === 0 ||
124
+ credential.byteLength > MAX_CREDENTIAL_BYTES ||
125
+ !credential.toString("utf8").trim()
126
+ )
127
+ throw new Error("Claude credential is empty or oversized");
128
+ return credential;
129
+ }
130
+
131
+ function fileLoginStore(path: string, lockRoot: string): ClaudeLoginStore {
132
+ return {
133
+ origin: path,
134
+ lockRoot,
135
+ read: () => readLoginFile(path),
136
+ write: (bytes) => replaceLoginFile(path, bytes),
137
+ };
138
+ }
139
+
140
+ async function readLoginFile(path: string): Promise<Buffer> {
141
+ try {
142
+ if ((await stat(path)).size > MAX_CREDENTIAL_BYTES)
143
+ throw new Error("oversized credential");
144
+ return await readFile(path);
145
+ } catch (cause) {
146
+ throw new Error("Claude credential is unreadable or unavailable", {
147
+ cause,
148
+ });
149
+ }
150
+ }
151
+
152
+ /** Replace atomically so concurrent Claude readers never see a partial login. */
153
+ async function replaceLoginFile(path: string, bytes: Buffer): Promise<void> {
154
+ const target = await realpath(path);
155
+ const temporary = join(dirname(target), `.sevro-login-${randomUUID()}.tmp`);
156
+ try {
157
+ await writeFile(temporary, bytes, { flag: "wx", mode: 0o600 });
158
+ await rename(temporary, target);
159
+ } finally {
160
+ await rm(temporary, { force: true });
161
+ }
162
+ }
@@ -0,0 +1,107 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isRecord, isStringArray } from "../value-guards";
3
+
4
+ /** A claude.ai OAuth login in the document form Claude Code persists. */
5
+ export interface ClaudeLogin {
6
+ document: Record<string, unknown>;
7
+ oauth: Record<string, unknown>;
8
+ accessToken: string;
9
+ refreshToken: string | undefined;
10
+ expiresAt: number | undefined;
11
+ scopes: string[];
12
+ }
13
+
14
+ /** Fields replaced by a rotation; every other stored field is preserved. */
15
+ const ROTATED_FIELDS = [
16
+ "accessToken",
17
+ "refreshToken",
18
+ "expiresAt",
19
+ "scopes",
20
+ "refreshTokenExpiresAt",
21
+ ] as const;
22
+
23
+ /** Parse a stored login without echoing any of its bytes in errors. */
24
+ export function parseClaudeLogin(bytes: Buffer): ClaudeLogin {
25
+ const document = loginDocument(bytes);
26
+ const oauth = document.claudeAiOauth;
27
+ if (!isRecord(oauth) || !nonblank(oauth.accessToken))
28
+ throw new Error("Claude credential has no claude.ai login");
29
+ return {
30
+ document,
31
+ oauth,
32
+ accessToken: oauth.accessToken,
33
+ refreshToken: nonblank(oauth.refreshToken) ? oauth.refreshToken : undefined,
34
+ expiresAt: loginExpiry(oauth.expiresAt),
35
+ scopes: isStringArray(oauth.scopes) ? oauth.scopes : [],
36
+ };
37
+ }
38
+
39
+ function loginDocument(bytes: Buffer): Record<string, unknown> {
40
+ let value: unknown;
41
+ try {
42
+ value = JSON.parse(bytes.toString("utf8"));
43
+ } catch {
44
+ // Parser messages quote input, so the cause is deliberately not retained.
45
+ throw new Error("Claude credential is not a JSON login document");
46
+ }
47
+ if (!isRecord(value))
48
+ throw new Error("Claude credential is not a JSON login document");
49
+ return value;
50
+ }
51
+
52
+ function nonblank(value: unknown): value is string {
53
+ return typeof value === "string" && value.trim().length > 0;
54
+ }
55
+
56
+ function loginExpiry(value: unknown): number | undefined {
57
+ return typeof value === "number" && Number.isFinite(value)
58
+ ? value
59
+ : undefined;
60
+ }
61
+
62
+ /** A login without a recorded expiry is treated as not expiring. */
63
+ export function coversLifetime(
64
+ login: ClaudeLogin,
65
+ lifetimeMs: number,
66
+ now = Date.now(),
67
+ ): boolean {
68
+ return login.expiresAt === undefined || login.expiresAt - now >= lifetimeMs;
69
+ }
70
+
71
+ /** Apply a rotated token pair to the latest stored document. */
72
+ export function rotatedLoginBytes(
73
+ latest: ClaudeLogin,
74
+ rotated: ClaudeLogin,
75
+ ): Buffer {
76
+ const oauth = { ...latest.oauth };
77
+ for (const field of ROTATED_FIELDS)
78
+ if (Object.hasOwn(rotated.oauth, field))
79
+ oauth[field] = rotated.oauth[field];
80
+ return Buffer.from(
81
+ JSON.stringify({ ...latest.document, claudeAiOauth: oauth }),
82
+ );
83
+ }
84
+
85
+ /** Rotated token fields, as retained for recovery. */
86
+ export function rotatedOAuth(login: ClaudeLogin): Record<string, unknown> {
87
+ return Object.fromEntries(
88
+ ROTATED_FIELDS.filter((field) => Object.hasOwn(login.oauth, field)).map(
89
+ (field) => [field, login.oauth[field]],
90
+ ),
91
+ );
92
+ }
93
+
94
+ export function sameTokens(left: ClaudeLogin, right: ClaudeLogin): boolean {
95
+ return (
96
+ left.accessToken === right.accessToken &&
97
+ left.refreshToken === right.refreshToken &&
98
+ left.expiresAt === right.expiresAt
99
+ );
100
+ }
101
+
102
+ /** Identify a refresh token without retaining its value. */
103
+ export function tokenDigest(token: string | undefined): string {
104
+ return createHash("sha256")
105
+ .update(token ?? "")
106
+ .digest("hex");
107
+ }
@@ -1,17 +1,17 @@
1
+ import { parseClaudeEvents, type ClaudeEvents } from "./claude-events";
1
2
  import { lstat, readdir, readFile } from "node:fs/promises";
2
3
  import { basename, join } from "node:path";
4
+ import { requireRecord } from "../value-guards";
3
5
 
4
6
  type RecordValue = Record<string, unknown>;
5
7
  function record(value: unknown): RecordValue {
6
- if (!value || typeof value !== "object" || Array.isArray(value))
7
- throw new Error("Malformed native session record");
8
- return value as RecordValue;
8
+ return requireRecord(value, "Malformed native session record");
9
9
  }
10
- function records(text: string): RecordValue[] {
11
- return text
12
- .split("\n")
13
- .filter((line) => line.trim())
14
- .map((line) => record(JSON.parse(line)));
10
+ function records(text: string | ClaudeEvents): RecordValue[] {
11
+ return parseClaudeEvents(text).lines.map((line) => {
12
+ if (!line.parsed) throw line.error;
13
+ return record(line.value);
14
+ });
15
15
  }
16
16
 
17
17
  function goalAttachment(event: RecordValue) {
@@ -44,7 +44,10 @@ function attachmentStatus(attachment: RecordValue): string {
44
44
  return attachment.sentinel === true ? "cleared" : "complete";
45
45
  }
46
46
 
47
- function requireSuccessfulResult(stream: string, sessionId: string): void {
47
+ function requireSuccessfulResult(
48
+ stream: string | ClaudeEvents,
49
+ sessionId: string,
50
+ ): void {
48
51
  const result = records(stream)
49
52
  .filter((event) => event.type === "result")
50
53
  .at(-1);
@@ -59,7 +62,7 @@ function requireSuccessfulResult(stream: string, sessionId: string): void {
59
62
 
60
63
  /** Claude's native persisted goal attachments; sentinel clearance is not success. */
61
64
  export function claudeNativeGoalEvidence(
62
- stream: string,
65
+ stream: string | ClaudeEvents,
63
66
  transcript: string,
64
67
  sessionId: string,
65
68
  ) {
@@ -111,7 +114,7 @@ async function sessionPaths(
111
114
  /** Read only this trial's original native session; never manufacture a goal. */
112
115
  export async function observeClaudeNativeGoal(
113
116
  configRoot: string,
114
- stream: string,
117
+ stream: string | ClaudeEvents,
115
118
  ): Promise<{
116
119
  id: string;
117
120
  completeness: "complete" | "unavailable";
@@ -144,7 +147,7 @@ export async function observeClaudeNativeGoal(
144
147
  }
145
148
  }
146
149
 
147
- function originalSessionId(stream: string): string {
150
+ function originalSessionId(stream: string | ClaudeEvents): string {
148
151
  const initializations = records(stream).filter(
149
152
  (event) => event.type === "system" && event.subtype === "init",
150
153
  );
@@ -1,52 +1,12 @@
1
- import {
2
- awaitHostProcess,
3
- boundedProcessText,
4
- MAX_EVENT_BYTES,
5
- } from "./process-lifecycle";
1
+ import { runHostProcess, type HostProcessOptions } from "./process-lifecycle";
6
2
 
7
- const MAX_STDERR_BYTES = 64 * 1024;
8
-
9
- function stop(proc: Bun.Subprocess): void {
10
- try {
11
- if (process.platform !== "win32") process.kill(-proc.pid, "SIGKILL");
12
- else proc.kill("SIGKILL");
13
- } catch {
14
- proc.kill("SIGKILL");
15
- }
16
- }
17
-
18
- export async function runClaudeProcess(options: {
19
- argv: string[];
20
- cwd: string;
21
- env: Record<string, string>;
22
- timeoutMs: number;
23
- signal?: AbortSignal;
24
- }): Promise<{ code: number; out: string; err: string }> {
25
- if (options.signal?.aborted) throw new Error("Claude run cancelled");
26
- const proc = Bun.spawn(options.argv, {
27
- cwd: options.cwd,
28
- env: options.env,
29
- detached: process.platform !== "win32",
30
- stdin: "ignore",
31
- stdout: "pipe",
32
- stderr: "pipe",
33
- });
34
- const completed = Promise.all([
35
- boundedProcessText(
36
- proc.stdout,
37
- MAX_EVENT_BYTES,
38
- "Claude process output exceeds its limit",
39
- ),
40
- boundedProcessText(
41
- proc.stderr,
42
- MAX_STDERR_BYTES,
43
- "Claude process output exceeds its limit",
44
- ),
45
- proc.exited,
46
- ]).then(([out, err, code]) => ({ out, err, code }));
47
- return awaitHostProcess(proc, completed, {
48
- ...options,
49
- host: "Claude",
50
- stop,
51
- });
3
+ export async function runClaudeProcess(
4
+ options: Omit<HostProcessOptions, "host" | "input" | "phase">,
5
+ ) {
6
+ const result = await runHostProcess({ ...options, host: "Claude" });
7
+ return {
8
+ code: result.code,
9
+ out: result.out,
10
+ err: Buffer.from(result.stderr).toString("utf8"),
11
+ };
52
12
  }