@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.
- package/CONTRIBUTING.md +22 -6
- package/README.md +5 -1
- package/docs/README.md +19 -15
- package/docs/architecture.md +138 -17
- package/docs/assets/README.md +5 -4
- package/docs/builtin-graders-v1.md +17 -3
- package/docs/codex-host.md +155 -49
- package/docs/contracts.md +2 -1
- package/docs/creating-cases.md +9 -2
- package/docs/development-cli.md +187 -19
- package/docs/documentation-quality.md +12 -2
- package/docs/evaluation-workflow.md +110 -0
- package/docs/extension-protocol-v1.md +26 -3
- package/docs/getting-started.md +23 -2
- package/docs/guide-evaluation.md +33 -1
- package/docs/identity-v1.md +25 -2
- package/docs/installing.md +11 -11
- package/docs/native-controls.md +76 -5
- package/docs/native-hosts.md +196 -7
- package/docs/reading-results.md +102 -0
- package/docs/recipes.md +489 -0
- package/docs/releases.md +31 -12
- package/docs/report-v1.md +29 -1
- package/docs/results-v1.md +141 -4
- package/docs/run-settings-v1.md +147 -0
- package/docs/runtime-v1.md +307 -18
- package/docs/troubleshooting.md +12 -12
- package/docs/typescript-quality.md +89 -26
- package/examples/recipes/candidate.ts +26 -0
- package/examples/recipes/cases/coding-change.json +21 -0
- package/examples/recipes/cases/host-tools.json +21 -0
- package/examples/recipes/cases/mise.json +21 -0
- package/examples/recipes/cases/node.json +21 -0
- package/examples/recipes/cases/python-uv.json +21 -0
- package/examples/recipes/cases/semantic.json +17 -0
- package/examples/recipes/coding-change/check.ts +23 -0
- package/examples/recipes/extension-artifacts.ts +65 -0
- package/examples/recipes/extension-methods.ts +174 -0
- package/examples/recipes/extension.ts +37 -0
- package/examples/recipes/repository-skill/.agents/skills/startup-guide/SKILL.md +7 -0
- package/examples/recipes/repository-skill/.claude/skills/startup-guide/SKILL.md +7 -0
- package/examples/recipes/repository-skill/README.md +3 -0
- package/examples/recipes/run-inputs.ts +188 -0
- package/examples/recipes/run-options.ts +131 -0
- package/examples/recipes/run.ts +63 -0
- package/examples/recipes/runtime/empty.json +1 -0
- package/examples/recipes/runtime/goals.json +1 -0
- package/examples/recipes/runtime/mise.json +6 -0
- package/examples/recipes/runtime/path.json +1 -0
- package/examples/recipes/runtime/plugins.json +4 -0
- package/examples/recipes/runtime/python-uv.json +10 -0
- package/examples/recipes/semantic-judge.ts +29 -0
- package/examples/templates/coding/adapter.ts +17 -0
- package/examples/templates/coding/case.json +15 -0
- package/examples/templates/repository-skill/.agents/skills/startup-guide/SKILL.md +6 -0
- package/examples/templates/repository-skill/extension-data.ts +64 -0
- package/examples/templates/repository-skill/extension.ts +31 -0
- package/examples/templates/response/adapter.ts +12 -0
- package/examples/templates/response/case.json +13 -0
- package/package.json +11 -5
- package/schemas/report-v1.schema.json +9 -0
- package/schemas/run-evidence-v1.schema.json +2 -1
- package/schemas/runtime-v1.schema.json +63 -7
- package/src/advisory.ts +2 -2
- package/src/candidate-artifacts.ts +206 -0
- package/src/candidate-codex-state.ts +185 -0
- package/src/case-validation.ts +73 -50
- package/src/cli/cancellation.ts +20 -0
- package/src/cli/command.ts +87 -0
- package/src/cli/completion.ts +15 -0
- package/src/cli/demo.ts +60 -0
- package/src/cli/evaluation.ts +55 -0
- package/src/cli/extension.ts +79 -0
- package/src/cli/help.ts +198 -0
- package/src/cli/hosts.ts +55 -0
- package/src/cli/init-settings.ts +30 -0
- package/src/cli/init.ts +111 -0
- package/src/{cli-invocation.ts → cli/invocation.ts} +214 -218
- package/src/cli/loading.ts +151 -0
- package/src/cli/preparation.ts +154 -0
- package/src/cli/presentation.ts +93 -0
- package/src/cli/progress.ts +6 -0
- package/src/cli/run-manifest.ts +128 -0
- package/src/cli/run-options.ts +403 -0
- package/src/cli/run-overrides.ts +69 -0
- package/src/cli/settings.ts +154 -0
- package/src/cli/summary.ts +43 -0
- package/src/cli.ts +13 -591
- package/src/contract-types.ts +94 -0
- package/src/engine.ts +13 -5
- package/src/evaluation/advisory.ts +150 -0
- package/src/{evaluation-check-plan.ts → evaluation/check-plan.ts} +103 -67
- package/src/evaluation/collection.ts +88 -0
- package/src/evaluation/context.ts +10 -0
- package/src/{evaluation-grading-host.ts → evaluation/grading-host.ts} +5 -5
- package/src/evaluation/grading.ts +235 -0
- package/src/{evaluation-invocation-plan.ts → evaluation/invocation-plan.ts} +147 -58
- package/src/{evaluation-plan.ts → evaluation/plan.ts} +208 -141
- package/src/evaluation/progress.ts +19 -0
- package/src/evaluation/resources.ts +241 -0
- package/src/evaluation/run.ts +206 -0
- package/src/{evaluation-semantic.ts → evaluation/semantic.ts} +133 -115
- package/src/evaluation/trial.ts +800 -0
- package/src/evaluation/types.ts +207 -0
- package/src/evidence/host-results.ts +85 -0
- package/src/evidence/retained-artifacts.ts +18 -0
- package/src/evidence/trial-writer.ts +203 -0
- package/src/evidence/usage.ts +17 -0
- package/src/extension-client.ts +34 -96
- package/src/extension-session.ts +24 -48
- package/src/{advisory-fixture.ts → fixtures/advisory.ts} +15 -76
- package/src/{fixture-bin.ts → fixtures/bin.ts} +1 -1
- package/src/{fixture-cleanup.ts → fixtures/cleanup.ts} +13 -7
- package/src/fixtures/create.ts +183 -0
- package/src/{generated-fixture.ts → fixtures/generated.ts} +10 -53
- package/src/{git-hooks.ts → fixtures/git-hooks.ts} +1 -1
- package/src/{preparation.ts → fixtures/preparation.ts} +6 -16
- package/src/{repository-fixture.ts → fixtures/repository.ts} +30 -64
- package/src/{fixture-setup.ts → fixtures/setup.ts} +33 -51
- package/src/{fixture-tools.ts → fixtures/tools.ts} +1 -1
- package/src/fixtures/workspace-state.ts +212 -0
- package/src/generated/cli-result.d.cts +2 -1
- package/src/generated/extension.d.cts +2 -1
- package/src/generated/report.cjs +1 -1
- package/src/generated/report.d.cts +2 -1
- package/src/generated/run-evidence.cjs +1 -1
- package/src/generated/run-evidence.d.cts +2 -1
- package/src/generated/runtime.cjs +1 -1
- package/src/generated/runtime.d.cts +2 -1
- package/src/git.ts +93 -0
- package/src/graders/git-head.ts +29 -84
- package/src/graders/shell.ts +57 -68
- package/src/host-diagnostic-redaction.ts +52 -0
- package/src/host-diagnostics.ts +331 -0
- package/src/hosts/claude-continuation.ts +57 -40
- package/src/hosts/claude-credential.ts +160 -63
- package/src/hosts/claude-events.ts +34 -18
- package/src/hosts/claude-keychain.ts +78 -21
- package/src/hosts/claude-login-recovery.ts +85 -0
- package/src/hosts/claude-login-refresh.ts +178 -0
- package/src/hosts/claude-login-store.ts +162 -0
- package/src/hosts/claude-login.ts +107 -0
- package/src/hosts/claude-native-goal.ts +15 -12
- package/src/hosts/claude-process.ts +10 -50
- package/src/hosts/claude-refresh-lock.ts +81 -0
- package/src/hosts/claude-repository-invocation.ts +9 -34
- package/src/hosts/claude-settings.ts +91 -14
- package/src/hosts/claude-skill-activations.ts +169 -0
- package/src/hosts/claude-state.ts +46 -16
- package/src/hosts/claude-tool-calls.ts +13 -10
- package/src/hosts/claude.ts +122 -73
- package/src/hosts/codex-app-server.ts +78 -52
- package/src/hosts/codex-configuration.ts +5 -10
- package/src/hosts/codex-exec-controls.ts +516 -0
- package/src/hosts/codex-marketplace.ts +12 -10
- package/src/hosts/codex-native-calls.ts +15 -122
- package/src/hosts/codex-process.ts +10 -54
- package/src/hosts/codex-profile.ts +50 -21
- package/src/hosts/codex-session-files.ts +1 -9
- package/src/hosts/codex-state.ts +141 -19
- package/src/hosts/codex.ts +221 -179
- package/src/hosts/isolation-common.ts +15 -11
- package/src/hosts/isolation-roots.ts +13 -20
- package/src/hosts/linux-sandbox.ts +27 -3
- package/src/hosts/mac-sandbox.ts +111 -18
- package/src/hosts/native-controls.ts +72 -22
- package/src/hosts/native-request.ts +55 -0
- package/src/hosts/process-lifecycle.ts +221 -45
- package/src/hosts/protected-worktrees.ts +1 -1
- package/src/hosts/skill-hook-catalog.ts +131 -0
- package/src/hosts/skill-hook-handler.ts +374 -0
- package/src/hosts/skill-hooks.ts +577 -0
- package/src/instrumentation.ts +7 -8
- package/src/native-transcript-state.ts +22 -3
- package/src/native-transcript-view.ts +52 -13
- package/src/native-transcripts.ts +34 -15
- package/src/paths.ts +22 -0
- package/src/process-lifecycle.ts +150 -0
- package/src/provenance.ts +27 -7
- package/src/prune.ts +300 -0
- package/src/report.ts +63 -36
- package/src/resolved-case.ts +1 -1
- package/src/results-lock.ts +42 -0
- package/src/results.ts +14 -18
- package/src/run-owner.ts +4 -4
- package/src/{runtime-config.ts → runtime/config.ts} +182 -44
- package/src/{runtime-discovery-apple.ts → runtime/discovery-apple.ts} +3 -3
- package/src/{runtime-discovery-providers.ts → runtime/discovery-providers.ts} +3 -9
- package/src/{runtime-discovery.ts → runtime/discovery.ts} +4 -4
- package/src/{runtime-hook-processes.ts → runtime/hook-processes.ts} +1 -1
- package/src/{runtime-hooks.ts → runtime/hooks.ts} +33 -22
- package/src/{runtime-paths.ts → runtime/paths.ts} +6 -18
- package/src/{runtime-query.ts → runtime/query.ts} +5 -5
- package/src/{runtime-seeds.ts → runtime/seeds.ts} +3 -3
- package/src/runtime/shared-cache.ts +130 -0
- package/src/{runtime-state.ts → runtime/state.ts} +28 -11
- package/src/schema-types.ts +73 -161
- package/src/shell.ts +4 -0
- package/src/transcript-retention.ts +146 -0
- package/src/value-guards.ts +22 -0
- package/docs/assets/sevro-logo.png +0 -0
- package/docs/assets/sevro-symbol.svg +0 -1
- package/docs/assets/sevro-wordmark.svg +0 -1
- package/docs/cache-ownership-validation.md +0 -49
- package/docs/claude-continuation-validation.md +0 -47
- package/docs/concurrency-validation.md +0 -86
- package/docs/configuration-root-validation.md +0 -70
- package/docs/engine-slice.md +0 -114
- package/docs/evidence/runtime-startup-30/README.md +0 -185
- package/docs/evidence/runtime-startup-30/after.json +0 -319
- package/docs/evidence/runtime-startup-30/baseline-timeout.jsonl +0 -2
- package/docs/evidence/runtime-startup-30/before.json +0 -403
- package/docs/evidence/runtime-startup-30/consumer-smoke.json +0 -63
- package/docs/evidence/runtime-startup-30/full-policy-diagnostic.jsonl +0 -3
- package/docs/evidence/runtime-startup-30/minimal-policy-diagnostic.jsonl +0 -3
- package/docs/evidence/runtime-startup-30/plugin-template-assessment.json +0 -20
- package/docs/evidence/runtime-startup-30/seedless-timeout.jsonl +0 -2
- package/docs/evidence.md +0 -17
- package/docs/fixture-cleanup-validation.md +0 -70
- package/docs/guide-verification.json +0 -239
- package/src/evaluation-context.ts +0 -257
- package/src/evaluation-fixture.ts +0 -313
- package/src/evaluation-run.ts +0 -198
- package/src/evaluation-trial.ts +0 -946
- package/src/evaluation-types.ts +0 -149
- package/src/hosts/claude-nested-skills.ts +0 -272
- package/src/hosts/codex-command-output.ts +0 -510
- package/src/hosts/codex-executor-command.ts +0 -291
- package/src/hosts/codex-skill-reads.ts +0 -609
- /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 {
|
|
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:
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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)
|
|
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 =
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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 =
|
|
66
|
-
|
|
67
|
-
|
|
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:
|
|
80
|
-
followUp:
|
|
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
|
|
96
|
+
const stderr = [initial.err.trimEnd(), followUp.err.trimStart()]
|
|
84
97
|
.filter(Boolean)
|
|
85
98
|
.join("\n");
|
|
86
|
-
|
|
87
|
-
Buffer.
|
|
88
|
-
|
|
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:
|
|
116
|
-
followUp:
|
|
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.
|
|
122
|
-
const sessionResultsBound = boundResult(followUp.
|
|
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
|
|
1
|
+
import { mkdir } from "node:fs/promises";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
88
|
-
if (!
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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:
|
|
116
|
+
function parseResultEvents(stream: ClaudeEvents) {
|
|
93
117
|
const results: Entry[] = [];
|
|
94
118
|
let malformed = false;
|
|
95
|
-
for (const line of stream.
|
|
96
|
-
if (!line.
|
|
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
|
|
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
|
|
6
|
-
if (process.platform !== "darwin" || !existsSync(
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
},
|
|
47
|
+
}, KEYCHAIN_TIMEOUT_MS);
|
|
43
48
|
const reader = proc.stdout.getReader();
|
|
44
49
|
try {
|
|
45
50
|
const credential = await boundedCredentialStream(reader);
|
|
46
|
-
|
|
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
|
|
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
|
+
};
|