mtech-ai-local-runner 0.1.102 → 0.1.104
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/README.md +14 -0
- package/dist/src/adapters/claude-code/adapter.js +12 -3
- package/dist/src/adapters/claude-code/runtime-options.js +3 -1
- package/dist/src/cli.js +6 -0
- package/dist/src/commands/claude-release.js +48 -2
- package/dist/src/commands/claude-token.js +49 -0
- package/dist/src/daemon/runtime.js +3 -1
- package/dist/src/process/node-process-host.js +4 -1
- package/dist/src/process/owned-process-guardian.js +3 -1
- package/dist/src/safety/claude-production-host.js +71 -20
- package/dist/src/safety/claude-review-campaign.js +9 -8
- package/dist/src/safety/claude-review-evidence.js +5 -5
- package/dist/src/safety/claude-review-provider.js +1 -1
- package/dist/src/safety/claude-review-run.js +4 -4
- package/dist/src/safety/claude-runner-token.js +74 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
|
+
## 0.1.104: the token prompt is hidden
|
|
2
|
+
|
|
3
|
+
`setup --claude-token` no longer prints the pasted token. If you used it on 0.1.103, revoke that token and store a
|
|
4
|
+
fresh one. Released in 0.1.104; its Local Fix receipt must be listed on the Gateway before a runner on it prepares a
|
|
5
|
+
Local Fix.
|
|
6
|
+
|
|
7
|
+
## 0.1.103: Claude logs in with the runner's own token
|
|
8
|
+
|
|
9
|
+
Run `npx mtech-ai-local-runner@latest setup --claude-token` once and paste the token from `claude setup-token`. Local
|
|
10
|
+
Claude then logs in with that token and never asks macOS for the shared Claude login, so no Keychain dialog appears
|
|
11
|
+
(Work Item ab3dcddb). `setup --claude-token-reset` removes it. Without a token, Local Claude works as before.
|
|
12
|
+
|
|
13
|
+
Released in 0.1.103. Its Local Fix receipt must be listed on the Gateway before a runner on it prepares a Local Fix.
|
|
14
|
+
|
|
1
15
|
## 0.1.102: provider tool activity is not verification
|
|
2
16
|
|
|
3
17
|
Claude Bash and tool results now appear as bounded activity. A test result is trusted only when the Runner host supplies its verification receipt; Codex and Claude use the same boundary (Work Item 7a22026b, PR #878). The event protocol and existing Work Loop authority are unchanged.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { CLAUDE_TOKEN_FD, CLAUDE_TOKEN_FD_ENV, pathWithKeychainStub } from "../../safety/claude-runner-token.js";
|
|
1
2
|
import path from "node:path";
|
|
2
3
|
import { requireSecurityRequirements } from '../../safety/security-requirements.js';
|
|
3
4
|
import { CLAUDE_LAUNCH_OPT_OUTS, CLAUDE_RELEASE_MODE, CLAUDE_TRANSPORT, claudeCanonicalSha256 } from '../../safety/claude-release.js';
|
|
@@ -64,8 +65,9 @@ export function claudeLaunchSettings(hookCommand) {
|
|
|
64
65
|
return { hooks: { PreToolUse: [{ matcher: "*", hooks: [{ type: "command", command: hookCommand }] }] } };
|
|
65
66
|
}
|
|
66
67
|
/** The allowlisted environment, the documented opt-outs and the encoded hook policy; nothing else of the runner's environment reaches the CLI. */
|
|
67
|
-
export function claudeLaunchEnv(env, hookPolicy) {
|
|
68
|
-
return { ...allowlistedEnv(env), ...CLAUDE_LAUNCH_OPT_OUTS, [HOOK_POLICY_ENV_VAR]: encodeHookPolicy(hookPolicy)
|
|
68
|
+
export function claudeLaunchEnv(env, hookPolicy, runnerToken = false) {
|
|
69
|
+
return { ...allowlistedEnv(env), ...CLAUDE_LAUNCH_OPT_OUTS, [HOOK_POLICY_ENV_VAR]: encodeHookPolicy(hookPolicy),
|
|
70
|
+
...(runnerToken ? { [CLAUDE_TOKEN_FD_ENV]: CLAUDE_TOKEN_FD } : {}) };
|
|
69
71
|
}
|
|
70
72
|
/** The one stream-json user message line written to the CLI's stdin. */
|
|
71
73
|
export function claudeUserInput(text) {
|
|
@@ -201,7 +203,13 @@ export class ClaudeCodeAdapter {
|
|
|
201
203
|
resumeSessionId: ctx.resumeSessionId,
|
|
202
204
|
toolInventory,
|
|
203
205
|
});
|
|
204
|
-
const
|
|
206
|
+
const claudeToken = await this.options.claudeToken?.() ?? null;
|
|
207
|
+
const env = claudeLaunchEnv(this.options.env, hookPolicy, claudeToken !== null);
|
|
208
|
+
if (claudeToken !== null) {
|
|
209
|
+
if (!this.options.keychainStub)
|
|
210
|
+
throw new Error("CLAUDE_KEYCHAIN_STUB_UNAVAILABLE");
|
|
211
|
+
env.PATH = pathWithKeychainStub(await this.options.keychainStub(), env.PATH);
|
|
212
|
+
}
|
|
205
213
|
await this.options.assertCurrentInputs?.();
|
|
206
214
|
if (this.cancelled)
|
|
207
215
|
throw new Error('adapter startup cancelled');
|
|
@@ -209,6 +217,7 @@ export class ClaudeCodeAdapter {
|
|
|
209
217
|
const handle = this.options.processHost.spawn(this.options.binaryPath ?? "claude", args, {
|
|
210
218
|
cwd,
|
|
211
219
|
env,
|
|
220
|
+
...(claudeToken === null ? {} : { secretFd3: claudeToken }),
|
|
212
221
|
});
|
|
213
222
|
this.handle = handle;
|
|
214
223
|
if (this.cancelled)
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ClaudeRunnerTokenStore, ensureClaudeKeychainStub } from '../../safety/claude-runner-token.js';
|
|
1
2
|
import path from 'node:path';
|
|
2
3
|
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { mkdir, realpath, rm } from 'node:fs/promises';
|
|
@@ -68,8 +69,9 @@ export function createClaudeCodeAdapterOptions(input) {
|
|
|
68
69
|
env.TMPDIR = path.join(control, 'tmp');
|
|
69
70
|
if (env.CLAUDE_CONFIG_DIR && !path.isAbsolute(env.CLAUDE_CONFIG_DIR))
|
|
70
71
|
throw new Error('Claude account configuration path must be absolute');
|
|
72
|
+
const tokenStore = new ClaudeRunnerTokenStore({ stateRoot: state });
|
|
71
73
|
return {
|
|
72
|
-
processHost: new NodeProcessGroupHost(), worktreeRoot: checkout, jobId: input.jobId, get securityPolicy() { return securityPolicy; },
|
|
74
|
+
processHost: new NodeProcessGroupHost(), worktreeRoot: checkout, jobId: input.jobId, claudeToken: () => tokenStore.load(), keychainStub: () => ensureClaudeKeychainStub(state), get securityPolicy() { return securityPolicy; },
|
|
73
75
|
...(input.securityHost ? { prepareSecurity: async (ctx) => {
|
|
74
76
|
try {
|
|
75
77
|
if (disposed || !ctx.securityBinding || !account)
|
package/dist/src/cli.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { runClaudeTokenCommand } from "./commands/claude-token.js";
|
|
2
3
|
import { adapterStatus } from './adapters/status.js';
|
|
3
4
|
import { recoverLocalFixPublication } from './publication/recovery.js';
|
|
4
5
|
import { loadInstalledLocalFixHost } from './publication/installed-host.js';
|
|
@@ -240,6 +241,11 @@ export async function main(argv, env, io = {}) {
|
|
|
240
241
|
if (config.mode === "setup") {
|
|
241
242
|
if (process.platform !== "darwin")
|
|
242
243
|
throw new Error("Local runner daemon setup requires macOS");
|
|
244
|
+
const tokenResult = await runClaudeTokenCommand({ argv: argv.slice(1), stateRoot: config.stateRoot });
|
|
245
|
+
if (tokenResult) {
|
|
246
|
+
stdout(JSON.stringify(tokenResult));
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
243
249
|
const requestedIntegrations = parseSetupIntegrations(argv.slice(1));
|
|
244
250
|
if (requestedIntegrations === null)
|
|
245
251
|
throw new Error("--integrations takes claude, codex or claude,codex");
|
|
@@ -5,11 +5,14 @@ import { lstat, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
|
5
5
|
import { packagedHookCommandParts } from '../safety/hook-command.js';
|
|
6
6
|
import { measureCommandFile } from '../safety/registered-command-integrity.js';
|
|
7
7
|
import { hostClaudePlatform, parseClaudeSyncRelease } from '../safety/claude-release.js';
|
|
8
|
-
import { privateDirectory, verifyClaudeCodeSignature } from '../safety/claude-release-copy.js';
|
|
8
|
+
import { privateDirectory, verifyClaudeCodeSignature, verifyClaudeReleaseCopy } from '../safety/claude-release-copy.js';
|
|
9
|
+
import { claudeProbeEnv, claudeTokenAuthStatus } from '../safety/claude-production-host.js';
|
|
10
|
+
import { ClaudeRunnerTokenStore, ensureClaudeKeychainStub, pathWithKeychainStub } from '../safety/claude-runner-token.js';
|
|
9
11
|
import { fetchClaudeReleaseForReview } from '../safety/claude-review-fetch.js';
|
|
10
12
|
import { runClaudeReviewCampaign } from '../safety/claude-review-campaign.js';
|
|
11
|
-
import { CLAUDE_AUTH_OBSERVATION_MAX_BYTES, CLAUDE_REVIEW_EVIDENCE_FILE, claudeReleaseCandidate, claudeReviewEvidenceSha256, parseClaudeAuthObservation } from '../safety/claude-review-evidence.js';
|
|
13
|
+
import { CLAUDE_AUTH_OBSERVATION_MAX_BYTES, CLAUDE_AUTH_OBSERVATION_VERSION, CLAUDE_REVIEW_EVIDENCE_FILE, claudeReleaseCandidate, claudeReviewEvidenceSha256, parseClaudeAuthObservation } from '../safety/claude-review-evidence.js';
|
|
12
14
|
export const CLAUDE_RELEASE_USAGE = 'Usage: mtec-ai-local-runner claude-release fetch --version <version> --platform <darwin-arm64|darwin-x64> --out <dir>\n'
|
|
15
|
+
+ ' mtec-ai-local-runner claude-release observe --version <version> --platform <platform> --sha256 <sha256> --model <id> [--model <id> ...] --decision <uuid> --state-root <dir> --out <dir>\n'
|
|
13
16
|
+ ' mtec-ai-local-runner claude-release review --binary <path> --version <version> --platform <platform> --sha256 <sha256> --model <id> [--model <id> ...] --out <dir> [--auth-observation <file>] [--tool-inventory local-tools-v3]';
|
|
14
17
|
function flags(argv, single, repeated = []) {
|
|
15
18
|
const values = new Map();
|
|
@@ -65,6 +68,49 @@ export async function runClaudeReleaseCommand(argv, io) {
|
|
|
65
68
|
return failed(error);
|
|
66
69
|
}
|
|
67
70
|
}
|
|
71
|
+
if (argv[0] === 'observe') {
|
|
72
|
+
const f = flags(argv.slice(1), ['version', 'platform', 'sha256', 'decision', 'state-root', 'out'], ['model']);
|
|
73
|
+
if (!f || !['version', 'platform', 'sha256', 'decision', 'state-root', 'out', 'model'].every(name => f.has(name)))
|
|
74
|
+
return usage();
|
|
75
|
+
try {
|
|
76
|
+
const release = { version: one(f, 'version'), platform: one(f, 'platform'), binary_sha256: one(f, 'sha256') }, models = [...f.get('model')];
|
|
77
|
+
const decision = one(f, 'decision'), stateRoot = one(f, 'state-root'), out = one(f, 'out'), home = io.home ?? process.env.HOME ?? '';
|
|
78
|
+
if (!parseClaudeSyncRelease({ ...release, model_ids: models }) || !absolute(stateRoot) || !absolute(out) || !absolute(home)
|
|
79
|
+
|| !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(decision))
|
|
80
|
+
throw Error('CLAUDE_REVIEW_INPUT_REJECTED');
|
|
81
|
+
if (release.platform !== hostClaudePlatform())
|
|
82
|
+
throw Error('CLAUDE_REVIEW_PLATFORM_MISMATCH');
|
|
83
|
+
const binary = await (io.verifyCopy ?? verifyClaudeReleaseCopy)(home, { ...release, model_ids: models });
|
|
84
|
+
const token = await (io.tokenStore ?? new ClaudeRunnerTokenStore({ stateRoot })).load();
|
|
85
|
+
if (!token)
|
|
86
|
+
throw Error('CLAUDE_RUNNER_TOKEN_MISSING');
|
|
87
|
+
const probeEnv = claudeProbeEnv(home, process.env);
|
|
88
|
+
probeEnv.PATH = pathWithKeychainStub(await ensureClaudeKeychainStub(stateRoot), probeEnv.PATH);
|
|
89
|
+
const { code, stdout } = await (io.authStatus ?? claudeTokenAuthStatus)(binary, probeEnv, token, 10_000);
|
|
90
|
+
let parsed;
|
|
91
|
+
try {
|
|
92
|
+
parsed = JSON.parse(stdout);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
parsed = undefined;
|
|
96
|
+
}
|
|
97
|
+
const status = { loggedIn: parsed?.loggedIn === true };
|
|
98
|
+
for (const key of ['authMethod', 'apiProvider'])
|
|
99
|
+
if (typeof parsed?.[key] === 'string')
|
|
100
|
+
status[key] = parsed[key];
|
|
101
|
+
const observation = { version: CLAUDE_AUTH_OBSERVATION_VERSION, release, launch: 'runner_pinned_copy', command: 'auth status --json', credential: 'runner_token',
|
|
102
|
+
exit_code: code, auth_status: status, decision_id: decision };
|
|
103
|
+
const bytes = Buffer.from(JSON.stringify(observation));
|
|
104
|
+
const checked = parseClaudeAuthObservation(bytes, release);
|
|
105
|
+
const file = path.join(out, 'auth-observation.json');
|
|
106
|
+
await writeFile(file, bytes, { mode: 0o600, flag: 'wx' });
|
|
107
|
+
io.stdout(JSON.stringify({ ok: true, written: file, sha256: checked.sha256, auth_status: checked.observation.auth_status }));
|
|
108
|
+
return 0;
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
return failed(error);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
68
114
|
if (argv[0] !== 'review')
|
|
69
115
|
return usage();
|
|
70
116
|
const f = flags(argv.slice(1), ['binary', 'version', 'platform', 'sha256', 'out', 'auth-observation', 'tool-inventory'], ['model']);
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
import { ClaudeRunnerTokenStore, normalizeClaudeRunnerToken } from "../safety/claude-runner-token.js";
|
|
4
|
+
export const CLAUDE_TOKEN_FLAG = "--claude-token";
|
|
5
|
+
export const CLAUDE_TOKEN_RESET_FLAG = "--claude-token-reset";
|
|
6
|
+
/** Reads pasted lines without ever writing them back: readline gets no output stream and runs as a non-terminal, and
|
|
7
|
+
* terminal echo is off while it reads. Lines are joined, so a wrapped paste is fine; an empty line ends the input. */
|
|
8
|
+
export async function readHiddenLines(input, output, prompt, setEcho = () => undefined) {
|
|
9
|
+
output.write(prompt);
|
|
10
|
+
setEcho(false);
|
|
11
|
+
const rl = readline.createInterface({ input, terminal: false });
|
|
12
|
+
try {
|
|
13
|
+
let value = "";
|
|
14
|
+
for await (const raw of rl) {
|
|
15
|
+
const line = raw.replace(/\s+/g, "");
|
|
16
|
+
if (line === "" && value !== "")
|
|
17
|
+
break;
|
|
18
|
+
value += line;
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
finally {
|
|
23
|
+
rl.close();
|
|
24
|
+
setEcho(true);
|
|
25
|
+
output.write("\n");
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
export const terminalHiddenReader = {
|
|
29
|
+
isInteractive: () => process.stdin.isTTY === true,
|
|
30
|
+
readHiddenLines: prompt => readHiddenLines(process.stdin, process.stdout, prompt, on => execFileSync("/bin/stty", [on ? "echo" : "-echo"], { stdio: "inherit" })),
|
|
31
|
+
};
|
|
32
|
+
export async function runClaudeTokenCommand(input) {
|
|
33
|
+
const store = input.store ?? new ClaudeRunnerTokenStore({ stateRoot: input.stateRoot });
|
|
34
|
+
if (input.argv.includes(CLAUDE_TOKEN_RESET_FLAG)) {
|
|
35
|
+
await store.remove();
|
|
36
|
+
return { claudeToken: { state: "removed" } };
|
|
37
|
+
}
|
|
38
|
+
if (!input.argv.includes(CLAUDE_TOKEN_FLAG))
|
|
39
|
+
return null;
|
|
40
|
+
const reader = input.reader ?? terminalHiddenReader;
|
|
41
|
+
if (!reader.isInteractive())
|
|
42
|
+
throw new Error("CLAUDE_RUNNER_TOKEN_NEEDS_TERMINAL: run setup --claude-token in Terminal, not through a script or pipe.");
|
|
43
|
+
const pasted = await reader.readHiddenLines("Run `claude setup-token` in Terminal, then paste the token here (hidden; a wrapped paste is fine) and press Enter on an empty line: ");
|
|
44
|
+
const token = normalizeClaudeRunnerToken(pasted);
|
|
45
|
+
if (!token)
|
|
46
|
+
throw new Error("CLAUDE_RUNNER_TOKEN_INVALID: that was not a Claude token. Copy the whole token that claude setup-token printed.");
|
|
47
|
+
const storage = await store.save(token);
|
|
48
|
+
return { claudeToken: { state: "stored", storage } };
|
|
49
|
+
}
|
|
@@ -16,6 +16,7 @@ import { buildCodexJobOptions } from "../adapters/codex/runtime-options.js";
|
|
|
16
16
|
import { AdapterRegistry } from "../adapters/registry.js";
|
|
17
17
|
import { ClaudeCodePlugin } from "../adapters/claude-code/adapter.js";
|
|
18
18
|
import { createClaudeCodeAdapterOptions } from "../adapters/claude-code/runtime-options.js";
|
|
19
|
+
import { ClaudeRunnerTokenStore, ensureClaudeKeychainStub } from "../safety/claude-runner-token.js";
|
|
19
20
|
import { WorkspaceManager } from "../workspace/workspace-manager.js";
|
|
20
21
|
import { GitClient } from "../workspace/git-client.js";
|
|
21
22
|
import { FileDaemonStateStore } from "../state-store.js";
|
|
@@ -47,7 +48,8 @@ export async function buildDaemon(input) {
|
|
|
47
48
|
if (claudeParts !== 0 && claudeParts !== 3)
|
|
48
49
|
throw new Error('CLAUDE_SUPPORTED_HOST_REQUIRED');
|
|
49
50
|
// Claude admission (decision 9095c7a8): the compiled release host, unless a test injects the whole trusted tuple.
|
|
50
|
-
const
|
|
51
|
+
const claudeToken = new ClaudeRunnerTokenStore({ stateRoot: input.stateRoot });
|
|
52
|
+
const claudeProduction = claudeParts === 0 ? createClaudeProductionHost({ home: input.env.HOME ?? '', env: input.env, fetchFn: input.fetchFn, claudeToken: () => claudeToken.load(), keychainStub: () => ensureClaudeKeychainStub(input.stateRoot),
|
|
51
53
|
report: event => { process.stderr.write(`${JSON.stringify(event)}\n`); } }) : undefined;
|
|
52
54
|
const claude = {
|
|
53
55
|
probe: input.claudeSupportedProbe ?? (() => claudeProduction.probe()),
|
|
@@ -3,7 +3,10 @@ import { createInterface } from "node:readline";
|
|
|
3
3
|
/** Spawns a real OS process; no shell is ever used, so args are never interpolated into a command string. */
|
|
4
4
|
export class NodeProcessHost {
|
|
5
5
|
spawn(command, args, options) {
|
|
6
|
-
const
|
|
6
|
+
const secret = options.secretFd3;
|
|
7
|
+
const child = spawn(command, args, { cwd: options.cwd, env: options.env, shell: false, stdio: secret === undefined ? "pipe" : ["pipe", "pipe", "pipe", "pipe"] });
|
|
8
|
+
if (secret !== undefined)
|
|
9
|
+
child.stdio[3].end(secret);
|
|
7
10
|
const stdout = createInterface({ input: child.stdout });
|
|
8
11
|
return {
|
|
9
12
|
stdin: {
|
|
@@ -53,7 +53,9 @@ commands.on('data',chunk=>{
|
|
|
53
53
|
if(!configured){
|
|
54
54
|
if(message.type!=='launch'||typeof message.command!=='string'||!Array.isArray(message.args)){lost();return;}
|
|
55
55
|
configured=true;
|
|
56
|
-
|
|
56
|
+
const secret=typeof message.secretFd3==='string'?message.secretFd3:undefined;
|
|
57
|
+
try{child=spawn(message.command,message.args,{cwd:message.cwd,env:message.env,shell:false,detached:true,stdio:secret===undefined?['pipe','pipe','pipe']:['pipe','pipe','pipe','pipe']});}catch{send({type:'spawn_failed'});finish(0);return;}
|
|
58
|
+
if(secret!==undefined){child.stdio[3].on('error',()=>{});child.stdio[3].end(secret);}
|
|
57
59
|
ready=new Promise(resolve=>{
|
|
58
60
|
child.once('spawn',()=>{ownedPid=child.pid;send({type:'spawned',pid:ownedPid,parentPid:process.pid});resolve();if(orphaned)void cleanup(false,'lost');});
|
|
59
61
|
child.once('error',()=>{send({type:'spawn_failed'});closed=true;resolve();void cleanup(true,'spawn_error');});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { execFile } from 'node:child_process';
|
|
2
|
+
import { execFile, spawn } from 'node:child_process';
|
|
3
|
+
import { CLAUDE_TOKEN_FD, CLAUDE_TOKEN_FD_ENV, pathWithKeychainStub } from './claude-runner-token.js';
|
|
3
4
|
import { lstat, realpath } from 'node:fs/promises';
|
|
4
5
|
import { isDeepStrictEqual } from 'node:util';
|
|
5
6
|
import { CLAUDE_CAPABILITIES } from '../adapters/discovery.js';
|
|
@@ -38,7 +39,8 @@ export const CLAUDE_LAUNCH_CONFIG = Object.freeze({ transport: CLAUDE_TRANSPORT,
|
|
|
38
39
|
flags: Object.freeze(['--allowedTools', '--disable-slash-commands', '--include-partial-messages', '--input-format', '--mcp-config', '--model', '--name',
|
|
39
40
|
'--no-chrome', '--output-format', '--permission-mode', '--print', '--setting-sources', '--settings', '--strict-mcp-config', '--tools', '--verbose']),
|
|
40
41
|
conditional_flags: Object.freeze(['--max-budget-usd', '--resume', '--session-id']),
|
|
41
|
-
settings_keys: Object.freeze(['hooks.PreToolUse']), env_opt_outs: CLAUDE_LAUNCH_OPT_OUTS
|
|
42
|
+
settings_keys: Object.freeze(['hooks.PreToolUse']), env_opt_outs: CLAUDE_LAUNCH_OPT_OUTS,
|
|
43
|
+
token_delivery: Object.freeze({ env: CLAUDE_TOKEN_FD_ENV, fd: CLAUDE_TOKEN_FD, when: 'runner_token_stored', keychain: 'runner_stub_not_found' }) });
|
|
42
44
|
/** The tool inventory a listed release was reviewed with (Work Decision f43e7aa0): the one its tools_sha256 names, or
|
|
43
45
|
* local-tools-v1 for an entry without it, so every job keeps the original tools until the Gateway lists another. */
|
|
44
46
|
export function claudeReleaseToolInventory(release) {
|
|
@@ -68,31 +70,80 @@ export const CLAUDE_SIGNATURE_RECHECK_MS = 5 * 60_000;
|
|
|
68
70
|
* context: the employee's HOME, USER and LOGNAME, and CLAUDE_CONFIG_DIR only when they set it (runtime-options.ts).
|
|
69
71
|
* The documented Claude Code opt-outs keep the probe from updating itself or sending other traffic.
|
|
70
72
|
* `timeoutMs` is a trusted in-process test seam only. */
|
|
71
|
-
export function pinnedClaudeLogin(home, env, timeoutMs = CLAUDE_LOGIN_PROBE_TIMEOUT_MS) {
|
|
72
|
-
return
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
73
|
+
export function pinnedClaudeLogin(home, env, timeoutMs = CLAUDE_LOGIN_PROBE_TIMEOUT_MS, claudeToken, keychainStub) {
|
|
74
|
+
return async (binary) => {
|
|
75
|
+
const token = claudeToken ? await claudeToken().catch(() => null) : null;
|
|
76
|
+
const stub = token !== null && keychainStub ? await keychainStub().catch(() => null) : null;
|
|
77
|
+
return new Promise(resolve => {
|
|
78
|
+
const account = env.CLAUDE_CONFIG_DIR;
|
|
79
|
+
if (account !== undefined && !path.isAbsolute(account) || !path.isAbsolute(binary) || !path.isAbsolute(home))
|
|
80
|
+
return resolve({ authenticated: false, loginStatus: 'unknown' });
|
|
81
|
+
const childEnv = { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C', HOME: home, ...(account === undefined ? {} : { CLAUDE_CONFIG_DIR: account }),
|
|
82
|
+
DISABLE_AUTOUPDATER: '1', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' };
|
|
83
|
+
for (const key of ['USER', 'LOGNAME']) {
|
|
84
|
+
const value = env[key];
|
|
85
|
+
if (value !== undefined)
|
|
86
|
+
childEnv[key] = value;
|
|
87
|
+
}
|
|
88
|
+
if (token !== null) {
|
|
89
|
+
if (stub === null)
|
|
90
|
+
return resolve({ authenticated: false, loginStatus: 'unknown' });
|
|
91
|
+
const probeEnv = claudeProbeEnv(home, env);
|
|
92
|
+
probeEnv.PATH = pathWithKeychainStub(stub, probeEnv.PATH);
|
|
93
|
+
return resolve(tokenLogin(binary, probeEnv, token, timeoutMs));
|
|
94
|
+
}
|
|
95
|
+
execFile(binary, ['auth', 'status', '--json'], { encoding: 'utf8', timeout: timeoutMs, maxBuffer: 65_536, env: childEnv }, (error, stdout) => {
|
|
96
|
+
if (!error)
|
|
97
|
+
return resolve(claudeLoginFromStatus(stdout));
|
|
98
|
+
// Claude exits 1 for a known logged-out JSON answer; other failures prove nothing.
|
|
99
|
+
resolve(error.code === 1 && typeof stdout === 'string' ? claudeLoginFromStatus(stdout, true) : { authenticated: false, loginStatus: 'unknown' });
|
|
100
|
+
});
|
|
88
101
|
});
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
/** `auth status --json` with the runner token on fd 3: it never reaches env, argv or logs, and no Keychain read is needed.
|
|
105
|
+
* A timeout, a spawn error or oversized output resolves with a null code. */
|
|
106
|
+
export function claudeTokenAuthStatus(binary, env, token, timeoutMs) {
|
|
107
|
+
return new Promise(resolve => {
|
|
108
|
+
let stdout = '', done = false;
|
|
109
|
+
const finish = (code) => { if (!done) {
|
|
110
|
+
done = true;
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
resolve({ code, stdout });
|
|
113
|
+
} };
|
|
114
|
+
const child = spawn(binary, ['auth', 'status', '--json'], { env: { ...env, [CLAUDE_TOKEN_FD_ENV]: CLAUDE_TOKEN_FD }, shell: false, stdio: ['ignore', 'pipe', 'ignore', 'pipe'] });
|
|
115
|
+
const timer = setTimeout(() => { child.kill('SIGKILL'); finish(null); }, timeoutMs);
|
|
116
|
+
const fd3 = child.stdio[3];
|
|
117
|
+
fd3.on('error', () => { });
|
|
118
|
+
fd3.end(token);
|
|
119
|
+
child.stdout.setEncoding('utf8');
|
|
120
|
+
child.stdout.on('data', (chunk) => { stdout += chunk; if (stdout.length > 65_536) {
|
|
121
|
+
child.kill('SIGKILL');
|
|
122
|
+
finish(null);
|
|
123
|
+
} });
|
|
124
|
+
child.once('error', () => finish(null));
|
|
125
|
+
child.once('close', code => finish(code));
|
|
89
126
|
});
|
|
90
127
|
}
|
|
128
|
+
async function tokenLogin(binary, env, token, timeoutMs) {
|
|
129
|
+
const { code, stdout } = await claudeTokenAuthStatus(binary, env, token, timeoutMs);
|
|
130
|
+
return code === 0 ? claudeLoginFromStatus(stdout) : code === 1 ? claudeLoginFromStatus(stdout, true) : { authenticated: false, loginStatus: 'unknown' };
|
|
131
|
+
}
|
|
132
|
+
/** The probe environment the daemon uses: the employee's HOME, USER and LOGNAME and the documented opt-outs. */
|
|
133
|
+
export function claudeProbeEnv(home, env) {
|
|
134
|
+
const childEnv = { PATH: '/usr/bin:/bin', LANG: 'C', LC_ALL: 'C', HOME: home, DISABLE_AUTOUPDATER: '1', CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC: '1' };
|
|
135
|
+
for (const key of ['USER', 'LOGNAME']) {
|
|
136
|
+
const value = env[key];
|
|
137
|
+
if (value !== undefined)
|
|
138
|
+
childEnv[key] = value;
|
|
139
|
+
}
|
|
140
|
+
return childEnv;
|
|
141
|
+
}
|
|
91
142
|
/** Compiled Claude composition (decision 9095c7a8). The Gateway's synced release is the only one fetched or run.
|
|
92
143
|
* No environment, task or saved JSON creates an admission capability. */
|
|
93
144
|
export function createClaudeProductionHost(input) {
|
|
94
145
|
const platform = input.platform ?? hostClaudePlatform(), now = input.now ?? Date.now, abort = new AbortController();
|
|
95
|
-
const managed = input.managedScope ?? claudeManagedScopeDeps(), login = input.login ?? pinnedClaudeLogin(input.home, input.env);
|
|
146
|
+
const managed = input.managedScope ?? claudeManagedScopeDeps(), login = input.login ?? pinnedClaudeLogin(input.home, input.env, undefined, input.claudeToken, input.keychainStub);
|
|
96
147
|
const provisioner = createClaudeReleaseProvisioner({ home: input.home, platform, fetchFn: input.fetchFn, codesign: input.codesign, signal: abort.signal });
|
|
97
148
|
const failures = new Map(), key = (r) => `${r.version}/${r.platform}/${r.binary_sha256}`;
|
|
98
149
|
let listed = null, inflight, retryKey, pending = Promise.resolve(), closed = false;
|
|
@@ -14,7 +14,8 @@ import { CLAUDE_LAUNCH_OPT_OUTS, CLAUDE_SIGNING_IDENTIFIER, CLAUDE_TEAM_ID, clau
|
|
|
14
14
|
import { CLAUDE_CODE_REQUIREMENT, verifyClaudeCodeSignature } from './claude-release-copy.js';
|
|
15
15
|
import { CLAUDE_LAUNCH_CONFIG } from './claude-production-host.js';
|
|
16
16
|
import { CLAUDE_REVIEW_PROFILE_TEMPLATE_SHA256, CLAUDE_REVIEW_PROFILE_VERSION } from './claude-review-sandbox.js';
|
|
17
|
-
import {
|
|
17
|
+
import { CLAUDE_TOKEN_FD_ENV } from './claude-runner-token.js';
|
|
18
|
+
import { CLAUDE_REVIEW_KEYCHAIN_READS, CLAUDE_REVIEW_PLANTED_SOURCES, createClaudeReviewLayout } from './claude-review-layout.js';
|
|
18
19
|
import { CLAUDE_REVIEW_FAKE_KEY, CLAUDE_REVIEW_PROMPT, CLAUDE_REVIEW_PULL_REQUEST, CLAUDE_REVIEW_PULL_REQUEST_TOOL_IDS, CLAUDE_REVIEW_RUN_TIMEOUT_MS, CLAUDE_REVIEW_SLASH_PROMPT, CLAUDE_REVIEW_TOOL_IDS, CLAUDE_REVIEW_TOOL_INVENTORY, claudeReviewEnv, claudeReviewPullRequestCalls, runClaudeReview, runClaudeVersion } from './claude-review-run.js';
|
|
19
20
|
import { LOCAL_PULL_REQUEST_READ_BOUNDS, PULL_REQUEST_REVIEW_INVENTORY } from './pull-request-evidence.js';
|
|
20
21
|
import { CLAUDE_REVIEW_PULL_REQUEST_SCENARIO, CLAUDE_REVIEW_STAGES, buildClaudeReviewEvidence } from './claude-review-evidence.js';
|
|
@@ -80,12 +81,12 @@ export function evaluateClaudeReview(input) {
|
|
|
80
81
|
scenario('slash_refused', { not_expanded: !!slash && hits('planted', [slash]) === 0, no_command_side_effect: !sentinel(['slash']), delivered_or_refused: delivery !== 'missing' }, { delivery }),
|
|
81
82
|
scenario('source_exclusion', { outside_denied: every(scripted, r => denied(r, IDS.outside)), secret_denied: every(scripted, r => denied(r, IDS.secret)),
|
|
82
83
|
no_outside_content: hits('outside') === 0, no_secret_content: hits('secret') === 0, no_implicit_source: implicit === 0 }, { runs_checked: scripted.length }),
|
|
83
|
-
scenario('fake_credentials', {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
//
|
|
88
|
-
|
|
84
|
+
scenario('fake_credentials', { fake_token_only: scripted.length > 0 && runs.every(r => messages(r).every(q => q.authorization === `Bearer ${CLAUDE_REVIEW_FAKE_KEY}`)),
|
|
85
|
+
no_api_key_header: runs.every(r => messages(r).every(q => q.apiKey === null)), no_credential_content: hits('credential') === 0,
|
|
86
|
+
fake_token_not_in_body: runs.every(r => r.fakeKeyInBody === 0), environment_exact: every(runs, r => isDeepStrictEqual(r.envKeys, [...input.expectedEnvKeys])),
|
|
87
|
+
// Token mode (Work Decision 17a1113b): the CLI's startup Keychain prefetch meets a stand-in that finds nothing, as the
|
|
88
|
+
// runner's own stand-in does in jobs, and it may only read; the requests above prove it then used the fd 3 token.
|
|
89
|
+
keychain_reads_only: runs.every(r => r.keychainLookups.every(k => CLAUDE_REVIEW_KEYCHAIN_READS.includes(k.operation))) }, { runs_checked: runs.length, environment_keys: [...input.expectedEnvKeys] }),
|
|
89
90
|
scenario('communication_boundary', { sandboxed: every(runs, r => r.sandboxed) && input.version.sandboxed, no_unlisted_destination: runs.every(r => r.decoyConnections === 0) }, { sandboxed_runs: runs.filter(r => r.sandboxed).length + (input.version.sandboxed ? 1 : 0) }),
|
|
90
91
|
scenario('cleanup', { runs_terminal: every(runs, r => r.terminal), owned_exit_confirmed: every(runs, r => r.exitConfirmed) && input.version.exitConfirmed,
|
|
91
92
|
temp_removed: input.tempRemoved }, { owned_exits: runs.length + 1 }),
|
|
@@ -125,7 +126,7 @@ export async function runClaudeReviewCampaign(input) {
|
|
|
125
126
|
await rm(layout.root, { recursive: true, force: true }).catch(() => { });
|
|
126
127
|
tempRemoved = await lstat(layout.root).then(() => false, () => true);
|
|
127
128
|
}
|
|
128
|
-
const launchEnvKeys = [...Object.keys(layout.env), ...Object.keys(CLAUDE_LAUNCH_OPT_OUTS), HOOK_POLICY_ENV_VAR].sort(), reviewEnvKeys = Object.keys(claudeReviewEnv('http://127.0.0.1:1')).sort();
|
|
129
|
+
const launchEnvKeys = [...Object.keys(layout.env), ...Object.keys(CLAUDE_LAUNCH_OPT_OUTS), HOOK_POLICY_ENV_VAR, CLAUDE_TOKEN_FD_ENV].sort(), reviewEnvKeys = Object.keys(claudeReviewEnv('http://127.0.0.1:1')).sort();
|
|
129
130
|
const scenarios = evaluateClaudeReview({ ...(input.toolInventory ? { toolInventory: input.toolInventory } : {}), release: input.release, copySha256: sha256, copyUnchanged, version, runs, canaries: layout.canaries,
|
|
130
131
|
expectedEnvKeys: [...launchEnvKeys, ...reviewEnvKeys].sort(), tempRemoved });
|
|
131
132
|
return buildClaudeReviewEvidence({
|
|
@@ -5,7 +5,7 @@ import { CLAUDE_RELEASE_GATES, claudeCanonicalSha256 } from './claude-release.js
|
|
|
5
5
|
* a release certificate: the owner reviews it and lists the release in claude_release.php through a Gateway PR that cites
|
|
6
6
|
* a Work Decision. The campaign, the gate map and this format are Runner source constants.
|
|
7
7
|
*/
|
|
8
|
-
export const CLAUDE_REVIEW_EVIDENCE_VERSION = 'claude-release-review-evidence-
|
|
8
|
+
export const CLAUDE_REVIEW_EVIDENCE_VERSION = 'claude-release-review-evidence-v2';
|
|
9
9
|
export const CLAUDE_REVIEW_CAMPAIGN = 'claude-release-campaign-v1';
|
|
10
10
|
export const CLAUDE_REVIEW_EVIDENCE_FILE = 'claude-release-evidence.json';
|
|
11
11
|
/** The only top-level key evidence_sha256 leaves out. Timestamps, temp paths, durations, the runner package version, proxy
|
|
@@ -70,9 +70,9 @@ export function claudeReleaseCandidate(evidence) {
|
|
|
70
70
|
return { version: evidence.release.version, platform: evidence.release.platform, binary_sha256: evidence.release.binary_sha256,
|
|
71
71
|
evidence_sha256: claudeReviewEvidenceSha256(evidence), model_ids: [...evidence.release.model_ids] };
|
|
72
72
|
}
|
|
73
|
-
export const CLAUDE_AUTH_OBSERVATION_VERSION = 'claude-auth-observation-
|
|
73
|
+
export const CLAUDE_AUTH_OBSERVATION_VERSION = 'claude-auth-observation-v2';
|
|
74
74
|
export const CLAUDE_AUTH_OBSERVATION_MAX_BYTES = 4096;
|
|
75
|
-
const OBSERVATION_KEYS = ['auth_status', 'command', '
|
|
75
|
+
const OBSERVATION_KEYS = ['auth_status', 'command', 'credential', 'decision_id', 'exit_code', 'launch', 'release', 'version'];
|
|
76
76
|
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, TOKEN = /^[A-Za-z0-9._-]{1,32}$/;
|
|
77
77
|
const plain = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
78
78
|
export function parseClaudeAuthObservation(bytes, release) {
|
|
@@ -90,10 +90,10 @@ export function parseClaudeAuthObservation(bytes, release) {
|
|
|
90
90
|
throw rejected();
|
|
91
91
|
const r = value.release, s = value.auth_status;
|
|
92
92
|
if (value.version !== CLAUDE_AUTH_OBSERVATION_VERSION || value.launch !== 'runner_pinned_copy' || value.command !== 'auth status --json' || value.exit_code !== 0
|
|
93
|
-
|| value.
|
|
93
|
+
|| value.credential !== 'runner_token' || typeof value.decision_id !== 'string' || !UUID.test(value.decision_id)
|
|
94
94
|
|| !plain(r) || !isDeepStrictEqual(Object.keys(r).sort(), ['binary_sha256', 'platform', 'version'])
|
|
95
95
|
|| r.version !== release.version || r.platform !== release.platform || r.binary_sha256 !== release.binary_sha256
|
|
96
|
-
|| !plain(s) || s.loggedIn !== true || Object.keys(s).some(k => !['loggedIn', 'authMethod', 'apiProvider'].includes(k))
|
|
96
|
+
|| !plain(s) || s.loggedIn !== true || s.authMethod !== 'oauth_token' || Object.keys(s).some(k => !['loggedIn', 'authMethod', 'apiProvider'].includes(k))
|
|
97
97
|
|| ['authMethod', 'apiProvider'].some(k => s[k] !== undefined && (typeof s[k] !== 'string' || !TOKEN.test(s[k]))))
|
|
98
98
|
throw rejected();
|
|
99
99
|
const observation = structuredClone(value);
|
|
@@ -84,7 +84,7 @@ export async function startClaudeReviewProvider(script) {
|
|
|
84
84
|
if (raw.includes(script.fakeKey))
|
|
85
85
|
fakeKeyInBody++;
|
|
86
86
|
const apiKey = req.headers['x-api-key'];
|
|
87
|
-
const base = { step: null, model: null, toolNames: [], toolResultIds: [], apiKey: typeof apiKey === 'string' ? apiKey : null, authorization: req.headers.authorization
|
|
87
|
+
const base = { step: null, model: null, toolNames: [], toolResultIds: [], apiKey: typeof apiKey === 'string' ? apiKey : null, authorization: typeof req.headers.authorization === 'string' ? req.headers.authorization : null, firstUserText: null, canaries: hit, promptMatched: false, reminders: 0 };
|
|
88
88
|
if (url.pathname.startsWith('/planted-mcp/')) {
|
|
89
89
|
keep({ ...base, kind: 'planted_mcp' });
|
|
90
90
|
res.writeHead(404);
|
|
@@ -13,7 +13,7 @@ import { LocalToolServer, localEndpointReadObservations } from './local-tool-ser
|
|
|
13
13
|
import { claudeReviewProfile, claudeReviewSandboxArgs } from './claude-review-sandbox.js';
|
|
14
14
|
import { startClaudeReviewDecoy, startClaudeReviewProvider } from './claude-review-provider.js';
|
|
15
15
|
import { CLAUDE_REVIEW_SOURCE_FILE, plantHostileSources, reviewHomeWrites, snapshotReviewHome, takeKeychainLookups, takeSentinels } from './claude-review-layout.js';
|
|
16
|
-
export const CLAUDE_REVIEW_FAKE_KEY = 'sk-ant-claude-release-review-fake-
|
|
16
|
+
export const CLAUDE_REVIEW_FAKE_KEY = 'sk-ant-oat01-claude-release-review-fake-token-000000000000';
|
|
17
17
|
export const CLAUDE_REVIEW_PROMPT = 'Offline Claude release review. Follow the scripted provider. No real project, account or model.';
|
|
18
18
|
export const CLAUDE_REVIEW_SLASH_PROMPT = '/review-planted now';
|
|
19
19
|
/** A review-harness bound per CLI run, not an employee job limit (RW-06). */
|
|
@@ -27,7 +27,7 @@ export const CLAUDE_REVIEW_PULL_REQUEST = Object.freeze({ repository: 'review/cl
|
|
|
27
27
|
/** Review-only keys added to the adapter's launch environment: the scripted provider, a fake key, the provider as the HTTPS proxy
|
|
28
28
|
* observer (loopback bypasses it). The opt-outs come with the launch environment itself (CLAUDE_LAUNCH_OPT_OUTS). */
|
|
29
29
|
export function claudeReviewEnv(providerUrl) {
|
|
30
|
-
return { ANTHROPIC_BASE_URL: providerUrl,
|
|
30
|
+
return { ANTHROPIC_BASE_URL: providerUrl, HTTPS_PROXY: providerUrl, HTTP_PROXY: providerUrl, NO_PROXY: '127.0.0.1,localhost' };
|
|
31
31
|
}
|
|
32
32
|
const REVIEW_KEY = 'claude-release-review', REVIEW_SHA = '0'.repeat(40);
|
|
33
33
|
const REVIEW_BINDING = Object.freeze({ jobId: REVIEW_KEY, threadId: `${REVIEW_KEY}-thread`, turnId: `${REVIEW_KEY}-turn`, generation: 1 });
|
|
@@ -146,12 +146,12 @@ export async function runClaudeReview(input) {
|
|
|
146
146
|
// Exactly the adapter's release-mode argv (unbounded employee shape: no budget flag), environment and hook settings.
|
|
147
147
|
const args = claudeLaunchArgs({ modelId: input.model, maxBudgetUsd: null, name: REVIEW_KEY, mcpConfigPath,
|
|
148
148
|
settings: claudeLaunchSettings(formatHookCommand(input.hook, { worktreeRoot: layout.source })), sessionId: randomUUID(), toolInventory: inventory });
|
|
149
|
-
const env = { ...claudeLaunchEnv(layout.env, policy), ...claudeReviewEnv(provider.url) };
|
|
149
|
+
const env = { ...claudeLaunchEnv(layout.env, policy, true), ...claudeReviewEnv(provider.url) };
|
|
150
150
|
envKeys = Object.keys(env).sort();
|
|
151
151
|
const profile = claudeReviewProfile({ root: layout.root, cliRoot: layout.cli.root, ports: [provider.port, Number(new URL(endpoint.url).port)], readRoots: input.readRoots, deniedHome: input.deniedHome });
|
|
152
152
|
const [command, argv] = input.sandbox ? claudeReviewSandboxArgs(profile, input.binary, args) : [input.binary, args];
|
|
153
153
|
homeBefore = await snapshotReviewHome(layout);
|
|
154
|
-
handle = input.processHost.spawn(command, argv, { cwd: layout.cli.cwd, env });
|
|
154
|
+
handle = input.processHost.spawn(command, argv, { cwd: layout.cli.cwd, env, secretFd3: CLAUDE_REVIEW_FAKE_KEY });
|
|
155
155
|
terminal = await drive(handle, claudeUserInput(prompt), input.timeoutMs);
|
|
156
156
|
exitConfirmed = await confirmExit(handle);
|
|
157
157
|
reads = readCounts(localEndpointReadObservations(endpoint.url, endpoint.token));
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { MacOsKeychainCredentialStore } from "../platform/macos/keychain-store.js";
|
|
4
|
+
export const CLAUDE_TOKEN_FD_ENV = "CLAUDE_CODE_OAUTH_TOKEN_FILE_DESCRIPTOR";
|
|
5
|
+
export const CLAUDE_TOKEN_FD = "3";
|
|
6
|
+
export const CLAUDE_TOKEN_KEYCHAIN_ACCOUNT = "claude-oauth-token";
|
|
7
|
+
const TOKEN_FILE = "claude-oauth-token";
|
|
8
|
+
const TOKEN_SHAPE = /^[A-Za-z0-9._-]{20,512}$/;
|
|
9
|
+
export function normalizeClaudeRunnerToken(value) {
|
|
10
|
+
const token = value.replace(/\s+/g, "");
|
|
11
|
+
return TOKEN_SHAPE.test(token) ? token : null;
|
|
12
|
+
}
|
|
13
|
+
export class ClaudeRunnerTokenStore {
|
|
14
|
+
keychain;
|
|
15
|
+
file;
|
|
16
|
+
constructor(options) {
|
|
17
|
+
this.file = path.join(path.resolve(options.stateRoot), TOKEN_FILE);
|
|
18
|
+
this.keychain = options.keychain === undefined
|
|
19
|
+
? (process.platform === "darwin" ? new MacOsKeychainCredentialStore({ account: CLAUDE_TOKEN_KEYCHAIN_ACCOUNT }) : null)
|
|
20
|
+
: options.keychain;
|
|
21
|
+
}
|
|
22
|
+
async load() {
|
|
23
|
+
const fromKeychain = this.keychain ? await this.keychain.load().catch(() => null) : null;
|
|
24
|
+
const keychainToken = fromKeychain === null ? null : normalizeClaudeRunnerToken(fromKeychain);
|
|
25
|
+
if (keychainToken)
|
|
26
|
+
return keychainToken;
|
|
27
|
+
try {
|
|
28
|
+
return normalizeClaudeRunnerToken(await readFile(this.file, "utf8"));
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async save(value) {
|
|
35
|
+
const token = normalizeClaudeRunnerToken(value);
|
|
36
|
+
if (!token)
|
|
37
|
+
throw new Error("CLAUDE_RUNNER_TOKEN_INVALID");
|
|
38
|
+
if (this.keychain) {
|
|
39
|
+
try {
|
|
40
|
+
await this.keychain.save(token);
|
|
41
|
+
await rm(this.file, { force: true });
|
|
42
|
+
return "keychain";
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
// Falls through to the owner-only file.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
await mkdir(path.dirname(this.file), { recursive: true, mode: 0o700 });
|
|
49
|
+
await writeFile(this.file, token, { mode: 0o600 });
|
|
50
|
+
await chmod(this.file, 0o600);
|
|
51
|
+
return "file";
|
|
52
|
+
}
|
|
53
|
+
async remove() {
|
|
54
|
+
await this.keychain?.remove().catch(() => undefined);
|
|
55
|
+
await rm(this.file, { force: true });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export const CLAUDE_KEYCHAIN_STUB_DIR = "claude-keychain-stub";
|
|
59
|
+
export const CLAUDE_KEYCHAIN_STUB = "#!/bin/sh\necho 'security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.' >&2\nexit 44\n";
|
|
60
|
+
/** The runner's stand-in `security`: in token mode it is first on the CLI's PATH, so the CLI's startup Keychain prefetch
|
|
61
|
+
* finds nothing and the shared Keychain is never consulted (Work Decision 17a1113b). */
|
|
62
|
+
export async function ensureClaudeKeychainStub(stateRoot) {
|
|
63
|
+
const dir = path.join(path.resolve(stateRoot), CLAUDE_KEYCHAIN_STUB_DIR), file = path.join(dir, "security");
|
|
64
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
65
|
+
await chmod(dir, 0o700);
|
|
66
|
+
const current = await readFile(file, "utf8").catch(() => null);
|
|
67
|
+
if (current !== CLAUDE_KEYCHAIN_STUB)
|
|
68
|
+
await writeFile(file, CLAUDE_KEYCHAIN_STUB, { mode: 0o700 });
|
|
69
|
+
await chmod(file, 0o700);
|
|
70
|
+
return dir;
|
|
71
|
+
}
|
|
72
|
+
export function pathWithKeychainStub(stubDir, searchPath) {
|
|
73
|
+
return searchPath ? `${stubDir}:${searchPath}` : stubDir;
|
|
74
|
+
}
|