mtech-ai-local-runner 0.1.45 → 0.1.47

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 CHANGED
@@ -1,3 +1,27 @@
1
+ ## 0.1.47: Claude security host wiring remains gated
2
+
3
+ Claude now shares the job security session and checks a live opaque executable
4
+ identity before launch. Account/control overlaps and aliases fail before control
5
+ creation; preparation failure and cancellation revoke authority. A trusted
6
+ host/probe/identity tuple is an in-process composition seam, not an environment
7
+ switch or a receipt loaded from disk. Fake-data tests do not enable production:
8
+ exact-release/native-policy, managed-input and credential/IPC/network evidence
9
+ and production verifier registration remain outstanding. Existing login, settings
10
+ and executable path are preserved. This release neither upgrades nor starts an
11
+ installed daemon. See `docs/ai-ops-gateway/claude-execution-admission-remaining.md`
12
+ in the Gateway repository for the consolidated verification boundary.
13
+
14
+ ## 0.1.46: login and execution are separate
15
+
16
+ A locally logged-in account may still be blocked by security admission. New
17
+ `login_status`, `execution_status` and `execution_reason_codes` show those facts
18
+ separately; the legacy `authenticated` field remains an eligibility conjunction.
19
+ Malformed/unreadable Claude auth output is unknown, not a request to log in again.
20
+ Claude also rechecks task source identity after MCP preparation before spawning.
21
+ This release does not enable Claude execution, update an installed daemon or
22
+ change existing account settings. See the Gateway repository's
23
+ `docs/ai-ops-gateway/claude-execution-admission-remaining.md` for remaining gates.
24
+
1
25
  ## 0.1.45: pin official Codex alpha.11 outside the app
2
26
 
3
27
  Manual and daemon Codex registration now require the reviewed official standalone
@@ -640,3 +664,13 @@ advertises `local_fix_gateway_publish_v1`. Publishing/installation/DEV activatio
640
664
  and an exact approved Fix contract/launch are still required before real use.
641
665
  An unresolved canonical PR approval or publication receipt pauses delivery; do
642
666
  not rerun the model or delete recovery state to force a PR.
667
+
668
+ ### Login and execution diagnostics
669
+
670
+ `login_status` reports local account evidence (`authenticated`, `unauthenticated`,
671
+ `unknown`); it does not certify remote token validity. `execution_status` reports
672
+ `ready` or `blocked`, with bounded `execution_reason_codes`. For compatibility,
673
+ the older `authenticated` field still means execution eligibility. A logged-in
674
+ Claude account can remain blocked by unverified security admission. Do not log out
675
+ or change credentials just because that legacy field is false. Unknown status is
676
+ not evidence of logout. These diagnostics do not bypass per-job admission.
@@ -74,45 +74,49 @@ export class ClaudeCodeAdapter {
74
74
  throw new Error("adapter has already been started");
75
75
  this.started = true;
76
76
  this.p1 = ctx.approvedTaskPackage?.verification?.mode === 'runner_observed_v1';
77
- const generation = ctx.generation;
78
- if (!Number.isSafeInteger(generation) || generation === undefined || generation < 1)
79
- throw new Error("JOB_SECURITY_REJECTED");
80
- const assertAdmission = () => {
81
- requireJobSecurity(this.options.securityPolicy, { adapterId: "claude_code_cli", adapterVersion: "1", jobId: this.options.jobId ?? ctx.bindingId, modelId: resolvedModelId, generation });
82
- if (ctx.approvedTaskPackage?.resolved_provider !== "anthropic" || ctx.approvedTaskPackage?.resolved_model_id !== resolvedModelId
83
- || ctx.approvedTaskPackage?.repository?.base_commit_sha !== this.options.securityPolicy.pin.sourceSha)
84
- throw new Error("SECURITY_REQUIREMENTS_REJECTED");
85
- requireSecurityRequirements(ctx.approvedTaskPackage?.security_requirements, { adapterId: "claude_code_cli", adapterVersion: "1", requirementsSha256: this.options.securityPolicy.pin.requirementsSha256 });
86
- };
87
- assertAdmission();
88
- if (!ctx.securityBinding)
89
- throw new Error('SECURITY_REQUIREMENTS_REJECTED');
90
- this.receipt = createJobSecurityReceipt(this.options.securityPolicy, { jobId: this.options.jobId ?? ctx.bindingId, generation, modelId: resolvedModelId, sourceSha: this.options.securityPolicy.pin.sourceSha, ...ctx.securityBinding, requirements: ctx.approvedTaskPackage?.security_requirements });
91
- const pkg = ctx.taskPackage;
92
- const repos = pkg.repositories;
93
- if (!Array.isArray(repos) || repos.length !== 1 || repos[0]?.base_commit_sha !== this.options.securityPolicy.pin.sourceSha)
94
- throw new Error("JOB_SECURITY_REJECTED");
95
- const cwd = this.options.cwd;
96
- const rel = cwd ? path.relative(path.resolve(this.options.worktreeRoot), path.resolve(cwd)) : "";
97
- if (!cwd || !path.isAbsolute(cwd) || rel === "" || (!rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel)))
98
- throw new Error("trusted cwd must live outside the assigned worktree");
99
- const unbounded = isUnboundedLocalExecution(ctx.taskPackage);
100
- if (!unbounded && (!Number.isFinite(this.options.maxBudgetUsd) || this.options.maxBudgetUsd <= 0)) {
101
- throw new Error("refusing to start: no positive remaining contract budget was supplied");
102
- }
103
- if (!this.options.worktreeRoot) {
104
- throw new Error("refusing to start: no assigned worktree was supplied");
105
- }
106
- if (!ctx.sessionId) {
107
- throw new Error("refusing to start: no runner-generated session ID was supplied");
108
- }
109
- const hookCommand = this.requireTrustedHookCommand();
110
- const prompt = compilePrompt(ctx.taskPackage, { workItemLabel: this.options.workItemLabel });
111
- if (this.cancelled)
112
- throw new Error('adapter startup cancelled');
113
77
  let acquired;
114
78
  this.startupAcquisition = new Promise(resolve => { acquired = resolve; });
115
79
  try {
80
+ const generation = ctx.generation;
81
+ if (!Number.isSafeInteger(generation) || generation === undefined || generation < 1)
82
+ throw new Error("JOB_SECURITY_REJECTED");
83
+ if (this.options.prepareSecurity)
84
+ await this.options.prepareSecurity(ctx);
85
+ if (this.cancelled)
86
+ throw new Error('adapter startup cancelled');
87
+ const assertAdmission = () => {
88
+ const repositories = ctx.taskPackage?.repositories;
89
+ if (!Array.isArray(repositories) || repositories.length !== 1 || repositories[0]?.base_commit_sha !== this.options.securityPolicy?.pin.sourceSha)
90
+ throw new Error('JOB_SECURITY_REJECTED');
91
+ requireJobSecurity(this.options.securityPolicy, { adapterId: "claude_code_cli", adapterVersion: "1", jobId: this.options.jobId ?? ctx.bindingId, modelId: resolvedModelId, generation });
92
+ if (ctx.approvedTaskPackage?.resolved_provider !== "anthropic" || ctx.approvedTaskPackage?.resolved_model_id !== resolvedModelId
93
+ || ctx.approvedTaskPackage?.repository?.base_commit_sha !== this.options.securityPolicy.pin.sourceSha)
94
+ throw new Error("SECURITY_REQUIREMENTS_REJECTED");
95
+ requireSecurityRequirements(ctx.approvedTaskPackage?.security_requirements, { adapterId: "claude_code_cli", adapterVersion: "1", requirementsSha256: this.options.securityPolicy.pin.requirementsSha256 });
96
+ };
97
+ assertAdmission();
98
+ if (!ctx.securityBinding)
99
+ throw new Error('SECURITY_REQUIREMENTS_REJECTED');
100
+ this.receipt = createJobSecurityReceipt(this.options.securityPolicy, { jobId: this.options.jobId ?? ctx.bindingId, generation, modelId: resolvedModelId, sourceSha: this.options.securityPolicy.pin.sourceSha, ...ctx.securityBinding, requirements: ctx.approvedTaskPackage?.security_requirements });
101
+ const pkg = ctx.taskPackage;
102
+ const cwd = this.options.cwd;
103
+ const rel = cwd ? path.relative(path.resolve(this.options.worktreeRoot), path.resolve(cwd)) : "";
104
+ if (!cwd || !path.isAbsolute(cwd) || rel === "" || (!rel.startsWith(".." + path.sep) && rel !== ".." && !path.isAbsolute(rel)))
105
+ throw new Error("trusted cwd must live outside the assigned worktree");
106
+ const unbounded = isUnboundedLocalExecution(ctx.taskPackage);
107
+ if (!unbounded && (!Number.isFinite(this.options.maxBudgetUsd) || this.options.maxBudgetUsd <= 0)) {
108
+ throw new Error("refusing to start: no positive remaining contract budget was supplied");
109
+ }
110
+ if (!this.options.worktreeRoot) {
111
+ throw new Error("refusing to start: no assigned worktree was supplied");
112
+ }
113
+ if (!ctx.sessionId) {
114
+ throw new Error("refusing to start: no runner-generated session ID was supplied");
115
+ }
116
+ const hookCommand = this.requireTrustedHookCommand();
117
+ const prompt = compilePrompt(ctx.taskPackage, { workItemLabel: this.options.workItemLabel });
118
+ if (this.cancelled)
119
+ throw new Error('adapter startup cancelled');
116
120
  if (!this.options.createTools || !this.options.writeLocalMcpConfig)
117
121
  throw new Error("job-local tool setup is required");
118
122
  this.tools = await this.options.createTools(ctx, pkg, ctx.approvedTaskPackage ?? {});
@@ -166,6 +170,9 @@ export class ClaudeCodeAdapter {
166
170
  ...allowlistedEnv(this.options.env),
167
171
  [HOOK_POLICY_ENV_VAR]: encodeHookPolicy(hookPolicy),
168
172
  };
173
+ await this.options.assertCurrentInputs?.();
174
+ if (this.cancelled)
175
+ throw new Error('adapter startup cancelled');
169
176
  assertAdmission();
170
177
  const handle = this.options.processHost.spawn(this.options.binaryPath ?? "claude", args, {
171
178
  cwd,
@@ -259,6 +266,7 @@ export class ClaudeCodeAdapter {
259
266
  if (this.cancelPromise)
260
267
  return this.cancelPromise;
261
268
  this.localReadObservations();
269
+ this.revokeSecurity();
262
270
  this.revokeTools(this.tools);
263
271
  const cleanup = (async () => { await this.startupAcquisition; await this.cleanup(); })();
264
272
  // This bounds cleanup confirmation, never admitted model runtime. Unknown
@@ -269,6 +277,18 @@ export class ClaudeCodeAdapter {
269
277
  });
270
278
  return this.cancelPromise;
271
279
  }
280
+ securityRevoked = false;
281
+ revokeSecurity() {
282
+ if (this.securityRevoked)
283
+ return;
284
+ this.securityRevoked = true;
285
+ try {
286
+ this.options.revokeSecurity?.();
287
+ }
288
+ catch {
289
+ this.revokeFailed = true;
290
+ }
291
+ }
272
292
  revokeTools(tools) {
273
293
  if (!tools || this.revokedTools === tools)
274
294
  return;
@@ -291,6 +311,7 @@ export class ClaudeCodeAdapter {
291
311
  this.tools = undefined;
292
312
  this.handle = null;
293
313
  const operation = Promise.resolve().then(async () => {
314
+ this.revokeSecurity();
294
315
  this.revokeTools(tools);
295
316
  let failed = this.revokeFailed;
296
317
  try {
@@ -1,15 +1,52 @@
1
1
  import path from 'node:path';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { mkdir, realpath, rm } from 'node:fs/promises';
4
+ import { lstat } from 'node:fs/promises';
5
+ import { JobSecuritySession } from '../../safety/job-security-session.js';
6
+ import { assertCurrentClaudeBinary, claudeBinaryLaunchPath } from '../../safety/claude-binary-identity.js';
4
7
  import { writeFileSync } from 'node:fs';
5
8
  import { NodeProcessGroupHost } from '../../process/node-process-group-host.js';
6
9
  import { JobToolSession } from '../../safety/job-tool-session.js';
7
10
  import { jobToolPolicyOptions } from '../../safety/job-tool-policy.js';
8
11
  import { requireJobSecurity } from '../../safety/job-security-policy.js';
9
12
  const inside = (root, file) => file === root || file.startsWith(root + path.sep);
13
+ /** Resolve the existing prefix without creating a path through an account alias. */
14
+ async function canonicalFuture(file) {
15
+ let parent = file;
16
+ const tail = [];
17
+ for (;;) {
18
+ try {
19
+ await lstat(parent);
20
+ return path.join(await realpath(parent), ...tail);
21
+ }
22
+ catch (error) {
23
+ if (error.code !== 'ENOENT')
24
+ throw error;
25
+ const next = path.dirname(parent);
26
+ if (next === parent)
27
+ throw error;
28
+ tail.unshift(path.basename(parent));
29
+ parent = next;
30
+ }
31
+ }
32
+ }
10
33
  /** Wiring only. No release verifier or admission certificate exists here. */
11
34
  export function createClaudeCodeAdapterOptions(input) {
35
+ if (input.securityHost && (input.securityPolicy || input.nativeBinding || input.commandRegistry) || !!input.securityHost !== !!input.binaryIdentity)
36
+ throw Error('CLAUDE_SECURITY_AUTHORITY_CONFLICT');
37
+ const security = new JobSecuritySession(input.securityHost);
38
+ let securityPolicy = input.securityPolicy, disposed = false;
12
39
  const checkout = path.resolve(input.worktreePath), state = path.resolve(input.stateRoot);
40
+ const account = input.env.CLAUDE_CONFIG_DIR ?? (input.env.HOME ? path.join(input.env.HOME, '.claude') : undefined);
41
+ if (account !== undefined && (!path.isAbsolute(account) || /[\r\n\0]/.test(account)))
42
+ throw Error('CLAUDE_ACCOUNT_CONTEXT_UNAVAILABLE');
43
+ const assertOutsideAccount = (storage, source, auth = account) => { if (auth && [storage, source].some(root => inside(path.resolve(auth), root) || inside(root, path.resolve(auth))))
44
+ throw Error('CLAUDE_ACCOUNT_CONTROL_OVERLAP'); };
45
+ assertOutsideAccount(state, checkout);
46
+ const revoke = () => { disposed = true; security.revoke(); securityPolicy?.revoke(); };
47
+ const binary = input.binaryIdentity ? claudeBinaryLaunchPath(input.binaryIdentity) : input.binaryPath;
48
+ if (input.binaryPath && binary !== input.binaryPath)
49
+ throw Error('CLAUDE_BINARY_IDENTITY_REJECTED');
13
50
  if (inside(checkout, state))
14
51
  throw new Error('Claude job controls must be outside the checkout');
15
52
  const control = path.join(state, 'claude-jobs', randomUUID());
@@ -22,47 +59,91 @@ export function createClaudeCodeAdapterOptions(input) {
22
59
  env.TMPDIR = path.join(control, 'tmp'); // Claude owns credential access. Preserve the account location by path only;
23
60
  // never read/copy its files or send this environment to scoped workers.
24
61
  // https://code.claude.com/docs/en/authentication#log-in-with-multiple-accounts
25
- env.CLAUDE_CONFIG_DIR = input.env.CLAUDE_CONFIG_DIR ?? (input.env.HOME ? path.join(input.env.HOME, '.claude') : undefined);
62
+ env.CLAUDE_CONFIG_DIR = account;
26
63
  if (env.CLAUDE_CONFIG_DIR && !path.isAbsolute(env.CLAUDE_CONFIG_DIR))
27
64
  throw new Error('Claude account configuration path must be absolute');
28
65
  return {
29
- processHost: new NodeProcessGroupHost(), worktreeRoot: checkout, jobId: input.jobId, securityPolicy: input.securityPolicy,
30
- cwd, env, binaryPath: input.binaryPath, hookExecutablePath: input.hookExecutablePath, hookNodeExecutablePath: input.hookNodeExecutablePath,
66
+ processHost: new NodeProcessGroupHost(), worktreeRoot: checkout, jobId: input.jobId, get securityPolicy() { return securityPolicy; },
67
+ ...(input.securityHost ? { prepareSecurity: async (ctx) => {
68
+ try {
69
+ if (disposed || !ctx.securityBinding || !account)
70
+ throw Error('JOB_SECURITY_SESSION_REJECTED');
71
+ const approved = ctx.approvedTaskPackage, repo = approved?.repository;
72
+ securityPolicy = await security.authorize({ adapterId: 'claude_code_cli', adapterVersion: '1', jobId: input.jobId, generation: ctx.generation, modelId: ctx.resolvedModelId,
73
+ sourceSha: String(repo?.base_commit_sha ?? ''), requirements: approved?.security_requirements, ...ctx.securityBinding });
74
+ if (disposed)
75
+ throw Error('JOB_SECURITY_SESSION_REJECTED');
76
+ await assertCurrentClaudeBinary(input.binaryIdentity, binary, securityPolicy.pin.binarySha256);
77
+ return securityPolicy;
78
+ }
79
+ catch (error) {
80
+ revoke();
81
+ throw error;
82
+ }
83
+ } } : {}),
84
+ revokeSecurity: revoke,
85
+ assertCurrentInputs: async () => { if (disposed)
86
+ throw Error('JOB_SECURITY_SESSION_REJECTED'); if (input.binaryIdentity) {
87
+ if (!securityPolicy)
88
+ throw Error('JOB_SECURITY_SESSION_REJECTED');
89
+ await assertCurrentClaudeBinary(input.binaryIdentity, binary, securityPolicy.pin.binarySha256);
90
+ } },
91
+ cwd, env, binaryPath: binary, hookExecutablePath: input.hookExecutablePath, hookNodeExecutablePath: input.hookNodeExecutablePath,
31
92
  maxBudgetUsd: input.maxBudgetUsd ?? 1, workItemLabel: input.workItemLabel ?? input.jobId,
32
93
  registeredReadTools: [], writeMcpConfig: () => { throw new Error('Gateway relay is not a model tool endpoint'); },
33
94
  createTools: async (ctx, pkg, approved) => {
34
- await mkdir(state, { recursive: true, mode: 0o700 });
35
- const realState = await realpath(state), realCheckout = await realpath(checkout);
36
- if (realState !== state || inside(realCheckout, realState))
37
- throw new Error('Claude job controls must be outside the checkout');
38
- await mkdir(control, { recursive: true, mode: 0o700 });
39
- for (const directory of [home, cwd, env.TMPDIR])
40
- await mkdir(directory, { mode: 0o700 });
41
- let active = true;
42
- const tools = new JobToolSession({
43
- policyOptions: jobToolPolicyOptions(checkout, pkg, approved, input.commandRegistry ?? [], { runtimeAdapter: 'claude_code_cli', runtimeAdapterVersion: '1' }),
44
- binding: () => {
45
- const binding = input.nativeBinding?.();
46
- return binding && binding.jobId === input.jobId && binding.generation === input.securityPolicy?.pin.generation ? binding : null;
47
- },
48
- active: () => {
49
- if (!active)
50
- return false;
51
- try {
52
- requireJobSecurity(input.securityPolicy, { adapterId: 'claude_code_cli', adapterVersion: '1', jobId: input.jobId, modelId: ctx.resolvedModelId });
53
- return true;
95
+ if (disposed)
96
+ throw Error('JOB_SECURITY_SESSION_REJECTED');
97
+ const policyOptions = jobToolPolicyOptions(checkout, pkg, approved, input.securityHost ? security.commands() : input.commandRegistry ?? [], { runtimeAdapter: 'claude_code_cli', runtimeAdapterVersion: '1' });
98
+ const canonicalAccount = account ? await canonicalFuture(account) : undefined;
99
+ assertOutsideAccount(await canonicalFuture(state), await canonicalFuture(checkout), canonicalAccount);
100
+ assertOutsideAccount(await canonicalFuture(control), await canonicalFuture(checkout), canonicalAccount);
101
+ let owned = false;
102
+ try {
103
+ await mkdir(state, { recursive: true, mode: 0o700 });
104
+ const realState = await realpath(state), realCheckout = await realpath(checkout);
105
+ assertOutsideAccount(realState, realCheckout, account ? await canonicalFuture(account) : undefined);
106
+ if (realState !== state || inside(realCheckout, realState))
107
+ throw new Error('Claude job controls must be outside the checkout');
108
+ await mkdir(path.dirname(control), { recursive: true, mode: 0o700 });
109
+ if (await realpath(path.dirname(control)) !== path.dirname(control))
110
+ throw Error('CLAUDE_CONTROL_ALIAS_REJECTED');
111
+ await mkdir(control, { mode: 0o700 });
112
+ owned = true;
113
+ for (const directory of [home, cwd, env.TMPDIR])
114
+ await mkdir(directory, { mode: 0o700 });
115
+ let active = true;
116
+ const tools = new JobToolSession({
117
+ policyOptions,
118
+ binding: () => {
119
+ const binding = input.securityHost ? security.binding() : input.nativeBinding?.();
120
+ return binding && binding.jobId === input.jobId && binding.generation === securityPolicy?.pin.generation ? binding : null;
121
+ },
122
+ active: () => {
123
+ if (!active)
124
+ return false;
125
+ try {
126
+ requireJobSecurity(securityPolicy, { adapterId: 'claude_code_cli', adapterVersion: '1', jobId: input.jobId, modelId: ctx.resolvedModelId, generation: ctx.generation, sourceSha: String(approved.repository.base_commit_sha) });
127
+ return true;
128
+ }
129
+ catch {
130
+ return false;
131
+ }
132
+ },
133
+ });
134
+ return { start: () => tools.start(), revoke: () => { active = false; tools.revoke(); }, close: async () => { active = false; revoke(); try {
135
+ await tools.close();
54
136
  }
55
- catch {
56
- return false;
57
- }
58
- },
59
- });
60
- return { start: () => tools.start(), revoke: () => { active = false; tools.revoke(); }, close: async () => { active = false; try {
61
- await tools.close();
62
- }
63
- finally {
137
+ finally {
138
+ await rm(control, { recursive: true, force: true });
139
+ } } };
140
+ }
141
+ catch (error) {
142
+ revoke();
143
+ if (owned)
64
144
  await rm(control, { recursive: true, force: true });
65
- } } };
145
+ throw error;
146
+ }
66
147
  },
67
148
  writeLocalMcpConfig: endpoint => {
68
149
  writeFileSync(config, JSON.stringify({ mcpServers: { ai_local: { type: 'http', url: endpoint.url, headers: { Authorization: `Bearer ${endpoint.token}` } } } }), { mode: 0o600, flag: 'wx' });
@@ -1,3 +1,4 @@
1
+ import { adapterStatus } from './status.js';
1
2
  import { createCodexProductionHost } from '../safety/codex-production-host.js';
2
3
  import { execFile } from 'node:child_process';
3
4
  import { promisify } from 'node:util';
@@ -20,22 +21,51 @@ export async function discoverAdapters(env, command = run, adapterIds = ['claude
20
21
  if (!executableVersion)
21
22
  throw new Error('invalid version');
22
23
  let authenticated = false;
24
+ let loginStatus = 'unknown';
25
+ const parseClaudeStatus = (stdout) => {
26
+ try {
27
+ const value = JSON.parse(stdout);
28
+ if (value && typeof value === 'object' && !Array.isArray(value) && typeof value.loggedIn === 'boolean') {
29
+ authenticated = value.loggedIn;
30
+ loginStatus = authenticated ? 'authenticated' : 'unauthenticated';
31
+ }
32
+ }
33
+ catch { /* Unknown evidence stays closed; never expose raw output. */ }
34
+ };
23
35
  try {
24
- const auth = await command(binary, codex ? ['login', 'status'] : ['auth', 'status']);
25
- authenticated = !codex || /^Logged in using ChatGPT\s*$/i.test([auth.stdout, auth.stderr ?? ''].join('\n').trim());
36
+ const auth = await command(binary, codex ? ['login', 'status'] : ['auth', 'status', '--json']);
37
+ if (codex) {
38
+ authenticated = /^Logged in using ChatGPT\s*$/i.test([auth.stdout, auth.stderr ?? ''].join('\n').trim());
39
+ loginStatus = authenticated ? 'authenticated' : 'unauthenticated';
40
+ }
41
+ else
42
+ parseClaudeStatus(auth.stdout);
43
+ }
44
+ catch (error) {
45
+ // Claude exits 1 for a known logged-out JSON response. Other failures
46
+ // (including timeout/permission errors) do not prove logout.
47
+ if (!codex && error && typeof error === 'object' && error.code === 1) {
48
+ const stdout = error.stdout;
49
+ if (typeof stdout === 'string') {
50
+ parseClaudeStatus(stdout);
51
+ if (authenticated) {
52
+ authenticated = false;
53
+ loginStatus = 'unknown';
54
+ }
55
+ }
56
+ }
26
57
  }
27
- catch { /* Missing login is diagnostic, never an API-key fallback. */ }
28
58
  if (codex) {
29
59
  const compatibility = inspectCodexCompatibility({ cliVersion: executableVersion });
30
60
  const admission = await diagnoseCliHostAdmission(id, binary, env);
31
- return { ...base, available: compatibility.available, authenticated, executableVersion, capabilities: compatibility.capabilities, admission,
61
+ return { ...base, available: compatibility.available, authenticated, loginStatus, executableVersion, capabilities: compatibility.capabilities, admission,
32
62
  reason: !authenticated ? 'Codex requires a ChatGPT account login; API-key authentication is unsupported.' : compatibility.reason };
33
63
  }
34
64
  const admission = await diagnoseCliHostAdmission(id, binary, env);
35
- return { ...base, available: admission.available, authenticated, executableVersion, capabilities: admission.capabilities, admission, reason: !authenticated ? 'Claude CLI login is required.' : 'Claude CLI security isolation is unverified; execution is unsupported.' };
65
+ return { ...base, available: admission.available, authenticated, loginStatus, executableVersion, capabilities: admission.capabilities, admission, reason: loginStatus === 'unknown' ? 'Claude CLI login status could not be verified.' : !authenticated ? 'Claude CLI login is required.' : 'Claude CLI security isolation is unverified; execution is unsupported.' };
36
66
  }
37
67
  catch {
38
- return { ...base, available: false, authenticated: false, capabilities: [], reason: codex ? 'Codex CLI is missing or its version could not be verified.' : 'Claude CLI is missing or its version could not be verified.' };
68
+ return { ...base, available: false, authenticated: false, loginStatus: 'unknown', capabilities: [], reason: codex ? 'Codex CLI is missing or its version could not be verified.' : 'Claude CLI is missing or its version could not be verified.' };
39
69
  }
40
70
  }));
41
71
  }
@@ -56,6 +86,8 @@ export function adapterDiagnosticsWithRemedies(adapters) {
56
86
  else
57
87
  action = 'Codex is ready for an explicitly selected approved launch.';
58
88
  }
89
+ else if (adapter.loginStatus === 'unknown')
90
+ action = 'Claude login status is unknown; inspect the local status diagnostic before changing credentials. Execution remains blocked.';
59
91
  else if (!adapter.available && adapter.executableVersion && adapter.authenticated)
60
92
  action = 'Claude CLI security isolation is unverified; wait for a reviewed release. Login alone does not grant execution.';
61
93
  else if (!adapter.available)
@@ -64,7 +96,7 @@ export function adapterDiagnosticsWithRemedies(adapters) {
64
96
  action = 'Run claude auth login, then rerun doctor.';
65
97
  else
66
98
  action = 'Claude is ready for an explicitly selected approved launch.';
67
- return { ...adapter, action };
99
+ return { ...adapter, ...adapterStatus(adapter), action };
68
100
  });
69
101
  }
70
102
  /** Setup/doctor use the same reviewed Codex probe as daemon; no legacy vendor
@@ -0,0 +1,9 @@
1
+ /** Diagnostics never grant capabilities or replace the host admission gate. */
2
+ export function adapterStatus(probe) {
3
+ const login_status = probe.loginStatus ?? (probe.authenticated ? 'authenticated' : 'unknown');
4
+ const ready = probe.available && probe.authenticated && login_status === 'authenticated';
5
+ const codes = probe.admission?.checks.filter(c => c.status === 'blocked').map(c => c.code).filter(c => /^[A-Z][A-Z0-9_]{0,95}$/.test(c)).slice(0, 20) ?? [];
6
+ if (!ready && !codes.length)
7
+ codes.push(!probe.available ? 'EXECUTION_ADMISSION_UNAVAILABLE' : login_status === 'unknown' ? 'LOGIN_STATUS_UNVERIFIED' : 'LOGIN_REQUIRED');
8
+ return { authenticated: ready, login_status, execution_status: ready ? 'ready' : 'blocked', execution_reason_codes: ready ? [] : [...new Set(codes)] };
9
+ }
package/dist/src/cli.js CHANGED
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+ import { adapterStatus } from './adapters/status.js';
2
3
  import { recoverLocalFixPublication } from './publication/recovery.js';
3
4
  import { loadInstalledLocalFixHost } from './publication/installed-host.js';
4
5
  import { FileDaemonStateStore } from './state-store.js';
@@ -194,7 +195,7 @@ export async function main(argv, env, io = {}) {
194
195
  const registered = await gateway.registerLocalRunner({
195
196
  deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${config.gatewayMcpUrl}`).digest("hex"),
196
197
  displayName: runnerDisplayName(), packageVersion: "0.1.11",
197
- capabilities: adapters.map(probe => ({ adapter: probe.id, version: probe.version, capabilities: probe.capabilities, authenticated: probe.available && probe.authenticated })),
198
+ capabilities: adapters.map(probe => ({ adapter: probe.id, version: probe.version, capabilities: probe.capabilities, ...adapterStatus(probe) })),
198
199
  });
199
200
  await localStore.save({ schemaVersion: 1, gatewayMcpUrl: config.gatewayMcpUrl, runnerId: registered.runner.id, workspaceRoot: config.workspaceRoot, concurrency: 1 });
200
201
  await launchd.install(process.argv[1], config.stateRoot);
@@ -43,6 +43,25 @@ function parseWorkLoopId(args) {
43
43
  }
44
44
  return value;
45
45
  }
46
+ /*
47
+ * The LaunchAgent starts the daemon as `run --state-root=<root>`, with the root setup used (Work Item 2ae45a4f).
48
+ * launchd does not pass setup's XDG_STATE_HOME, so the argument, not the environment, decides the root whenever it
49
+ * is given. Only one absolute, normalized path is accepted.
50
+ */
51
+ function parseStateRootArgument(args) {
52
+ const prefix = "--state-root=";
53
+ const found = args.filter((arg) => arg.startsWith(prefix));
54
+ if (found.length === 0)
55
+ return undefined;
56
+ if (found.length > 1)
57
+ throw new Error("--state-root may be given only once");
58
+ const value = found[0].slice(prefix.length);
59
+ if (!value || !path.isAbsolute(value) || value === "/" || value.includes("\0")
60
+ || path.normalize(value) !== value || value.endsWith("/")) {
61
+ throw new Error("--state-root must be a normalized absolute path");
62
+ }
63
+ return value;
64
+ }
46
65
  function parseAbsolutePath(value, label, required) {
47
66
  if (!value) {
48
67
  if (required)
@@ -98,8 +117,9 @@ export function loadConfig(argv, env) {
98
117
  if (!mode || !ALL_MODES.includes(mode)) {
99
118
  throw new Error(`mode must be one of ${ALL_MODES.join(", ")}`);
100
119
  }
101
- const stateRoot = defaultStateRootFromEnv(env);
102
- if (SERVICE_MODES.includes(mode)) {
120
+ const isServiceMode = SERVICE_MODES.includes(mode);
121
+ const stateRoot = (isServiceMode ? parseStateRootArgument(rest) : undefined) ?? defaultStateRootFromEnv(env);
122
+ if (isServiceMode) {
103
123
  let manualJobId;
104
124
  if (mode === 'manual-codex' || mode === 'publication-recover') {
105
125
  if (rest.length !== 1 || !rest[0].startsWith('--job-id=') || !UUID_PATTERN.test(rest[0].slice(9)))
@@ -1,3 +1,4 @@
1
+ import { adapterStatus } from '../adapters/status.js';
1
2
  import { loadInstalledLocalFixHost } from '../publication/installed-host.js';
2
3
  import { createCodexDaemonHost } from '../safety/codex-daemon-host.js';
3
4
  import { reconcileTerminalState } from './terminal-recovery.js';
@@ -25,6 +26,9 @@ import { buildTerminalEvidenceCollector } from "../safety/terminal-evidence.js";
25
26
  import { packagedHookCommandParts } from "../safety/hook-command.js";
26
27
  import { composeReviewedP1Host } from '../verification/p1-host-composition.js';
27
28
  export async function buildDaemon(input) {
29
+ const claudeParts = [input.claudeSecurityHost, input.claudeSupportedProbe, input.claudeBinaryIdentity].filter(Boolean).length;
30
+ if (claudeParts !== 0 && claudeParts !== 3)
31
+ throw new Error('CLAUDE_SUPPORTED_HOST_REQUIRED');
28
32
  if (input.codexSupportedProbe && !input.codexSecurityHost)
29
33
  throw new Error('CODEX_SUPPORTED_HOST_REQUIRED');
30
34
  const production = !input.codexSecurityHost && !input.codexSupportedProbe ? createCodexDaemonHost({ runnerId: input.runnerId, binary: input.env.AI_GATEWAY_LOCAL_RUNNER_CODEX_BINARY ?? 'codex', home: input.env.HOME ?? '', codexHome: input.env.CODEX_HOME, searchPath: input.env.PATH }) : undefined;
@@ -51,11 +55,12 @@ export async function buildDaemon(input) {
51
55
  // The same parts the CLI self-test ran before starting this daemon (verifyPackagedHook).
52
56
  const hook = packagedHookCommandParts();
53
57
  const plugin = new ClaudeCodePlugin({
54
- probe: async () => (await discoverAdapters(input.env, undefined, ['claude_code_cli']))[0],
58
+ probe: async () => input.claudeSupportedProbe ? input.claudeSupportedProbe() : (await discoverAdapters(input.env, undefined, ['claude_code_cli']))[0],
55
59
  options: ({ worktreePath, jobId }) => createClaudeCodeAdapterOptions({
56
60
  worktreePath, jobId, stateRoot: input.stateRoot, env: input.env,
57
61
  maxBudgetUsd: Number.MAX_SAFE_INTEGER, hookExecutablePath: hook.hookScriptPath, hookNodeExecutablePath: hook.nodeExecutablePath,
58
62
  binaryPath: input.env.AI_GATEWAY_LOCAL_RUNNER_CLAUDE_BINARY,
63
+ securityHost: input.claudeSecurityHost, binaryIdentity: input.claudeBinaryIdentity,
59
64
  }),
60
65
  });
61
66
  const codex = new CodexPlugin({
@@ -64,7 +69,7 @@ export async function buildDaemon(input) {
64
69
  });
65
70
  const registry = new AdapterRegistry([plugin, codex]);
66
71
  const probes = await registry.probeAll();
67
- const versions = Object.fromEntries(probes.filter((probe) => probe.available && probe.authenticated).map((probe) => [probe.id, probe.version]));
72
+ const versions = Object.fromEntries(probes.filter((probe) => adapterStatus(probe).authenticated).map((probe) => [probe.id, probe.version]));
68
73
  const state = new FileDaemonStateStore(input.stateRoot);
69
74
  const coordinator = new ExecutionCoordinator({
70
75
  localFixHost,
@@ -108,6 +113,23 @@ export async function buildDaemon(input) {
108
113
  const poller = new JobPoller({
109
114
  reportError: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "poller_failed", ...diagnostic })}\n`); },
110
115
  heartbeat: async () => {
116
+ if (input.claudeSupportedProbe) {
117
+ let current;
118
+ try {
119
+ current = await plugin.probe();
120
+ }
121
+ catch {
122
+ current = { available: false, authenticated: false, capabilities: [] };
123
+ }
124
+ const ready = adapterStatus(current).authenticated && !!current.executableVersion && plugin.capabilities.every(c => current.capabilities.includes(c));
125
+ const index = probes.findIndex(p => p.id === 'claude_code_cli');
126
+ if (index >= 0)
127
+ probes[index] = { id: 'claude_code_cli', version: '1', ...current, available: ready };
128
+ if (ready)
129
+ versions.claude_code_cli = '1';
130
+ else
131
+ delete versions.claude_code_cli;
132
+ }
111
133
  let current;
112
134
  try {
113
135
  current = await codex.probe();
@@ -115,10 +137,10 @@ export async function buildDaemon(input) {
115
137
  catch {
116
138
  current = { available: false, authenticated: false, capabilities: [] };
117
139
  }
118
- const ready = current.available && current.authenticated && !!current.executableVersion && codex.capabilities.every(c => current.capabilities.includes(c));
140
+ const ready = adapterStatus(current).authenticated && !!current.executableVersion && codex.capabilities.every(c => current.capabilities.includes(c));
119
141
  const index = probes.findIndex(p => p.id === 'codex_cli');
120
142
  if (index >= 0)
121
- probes[index] = { id: 'codex_cli', version: '1', ...current, authenticated: ready };
143
+ probes[index] = { id: 'codex_cli', version: '1', ...current, available: ready };
122
144
  if (ready)
123
145
  versions.codex_cli = '1';
124
146
  else
@@ -222,7 +244,8 @@ export async function reconcilePreparationState(previous, deps) {
222
244
  /** Vendor capability text cannot claim a host-owned publication facility. */
223
245
  export function localRunnerCapabilities(probes, trustedLocalFixHost = false, verificationMode) {
224
246
  return probes.map(probe => {
225
- const authenticated = probe.available && probe.authenticated;
247
+ const status = adapterStatus(probe);
248
+ const authenticated = status.authenticated;
226
249
  const capabilities = probe.capabilities.filter(value => value !== 'local_fix_publication_v1' && value !== 'local_fix_gateway_ci_v1' && value !== 'local_fix_gateway_publish_v1');
227
250
  if (trustedLocalFixHost && authenticated && probe.id === 'codex_cli') {
228
251
  capabilities.push('local_fix_publication_v1');
@@ -231,6 +254,6 @@ export function localRunnerCapabilities(probes, trustedLocalFixHost = false, ver
231
254
  if (verificationMode === 'gateway_publish_v1')
232
255
  capabilities.push('local_fix_gateway_publish_v1');
233
256
  }
234
- return { adapter: probe.id, version: probe.version, authenticated, capabilities };
257
+ return { adapter: probe.id, version: probe.version, ...status, capabilities };
235
258
  });
236
259
  }
@@ -0,0 +1,81 @@
1
+ import path from 'node:path';
2
+ import { lstat, realpath, readlink } from 'node:fs/promises';
3
+ import { measureCommandFile } from './registered-command-integrity.js';
4
+ const live = new WeakMap();
5
+ const rejected = () => Error('CLAUDE_BINARY_IDENTITY_REJECTED');
6
+ const inside = (root, file) => file === root || file.startsWith(root + path.sep);
7
+ const validPath = (value) => typeof value === 'string' && path.isAbsolute(value) && path.resolve(value) === value && value !== '/' && value.length <= 4096 && !/[\r\n\0]/.test(value);
8
+ /** Measurement only, never a release certificate or atomic path-based exec.
9
+ * Explicit roots are supplied by the trusted host, not task/config JSON. */
10
+ async function measure(input) {
11
+ if (!validPath(input.launchPath) || !Array.isArray(input.allowedRoots) || !input.allowedRoots.length || input.allowedRoots.length > 8 || !input.allowedRoots.every(validPath))
12
+ throw rejected();
13
+ const roots = [...new Set(input.allowedRoots)], uid = process.getuid?.();
14
+ if (uid === undefined)
15
+ throw rejected();
16
+ for (const root of roots) {
17
+ const s = await lstat(root, { bigint: true });
18
+ if (await realpath(root) !== root || !s.isDirectory() || (s.mode & 18n) !== 0n || (s.uid !== BigInt(uid) && s.uid !== 0n))
19
+ throw rejected();
20
+ }
21
+ const allowed = (file) => roots.some(root => inside(root, file));
22
+ const target = await realpath(input.launchPath);
23
+ if (!allowed(input.launchPath) || !allowed(target))
24
+ throw rejected();
25
+ const inspect = async () => {
26
+ const stamps = [];
27
+ for (const file of [...new Set([input.launchPath, target])]) {
28
+ const root = roots.filter(r => inside(r, file)).sort((a, b) => b.length - a.length)[0];
29
+ const parts = [root];
30
+ let current = root;
31
+ for (const part of path.relative(root, file).split(path.sep).filter(Boolean)) {
32
+ current = path.join(current, part);
33
+ parts.push(current);
34
+ }
35
+ for (let i = 0; i < parts.length; i++) {
36
+ const name = parts[i], s = await lstat(name, { bigint: true }), link = s.isSymbolicLink(), last = i === parts.length - 1;
37
+ if ((s.uid !== BigInt(uid) && s.uid !== 0n) || (!link && (s.mode & 18n) !== 0n) || !allowed(await realpath(name))
38
+ || (!last && !s.isDirectory() && !link) || (last && !link && (!s.isFile() || s.nlink !== 1n || (s.mode & 73n) === 0n)))
39
+ throw rejected();
40
+ stamps.push([name, s.dev.toString(), s.ino.toString(), s.uid.toString(), s.mode.toString(), link ? await readlink(name) : null,
41
+ last || link ? s.mtimeNs.toString() : null, last || link ? s.ctimeNs.toString() : null, last ? s.nlink.toString() : null]);
42
+ }
43
+ }
44
+ if (await realpath(input.launchPath) !== target)
45
+ throw rejected();
46
+ return JSON.stringify(stamps);
47
+ };
48
+ const before = await inspect(), sha256 = await measureCommandFile(target), after = await inspect();
49
+ if (before !== after)
50
+ throw rejected();
51
+ return { sha256, stamp: after };
52
+ }
53
+ export async function captureClaudeBinaryIdentity(input) {
54
+ try {
55
+ const copy = structuredClone(input), measured = await measure(copy), identity = Object.freeze({ sha256: measured.sha256 });
56
+ live.set(identity, { input: copy, measured });
57
+ return identity;
58
+ }
59
+ catch {
60
+ throw rejected();
61
+ }
62
+ }
63
+ export function claudeBinaryLaunchPath(identity) {
64
+ const value = live.get(identity);
65
+ if (!value)
66
+ throw rejected();
67
+ return value.input.launchPath;
68
+ }
69
+ export async function assertCurrentClaudeBinary(identity, launchPath, sha256) {
70
+ try {
71
+ const value = live.get(identity);
72
+ if (!value || launchPath !== value.input.launchPath || sha256 !== value.measured.sha256)
73
+ throw rejected();
74
+ const current = await measure(value.input);
75
+ if (current.sha256 !== sha256 || current.stamp !== value.measured.stamp)
76
+ throw rejected();
77
+ }
78
+ catch {
79
+ throw rejected();
80
+ }
81
+ }
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mtech-ai-local-runner",
3
- "version": "0.1.45",
3
+ "version": "0.1.47",
4
4
  "private": false,
5
5
  "license": "UNLICENSED",
6
6
  "bin": {