mtech-ai-local-runner 0.1.61 → 0.1.62
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 +10 -4
- package/dist/src/adapters/claude-code/adapter.js +24 -2
- package/dist/src/adapters/claude-code/login-status.js +15 -0
- package/dist/src/adapters/discovery.js +4 -18
- package/dist/src/daemon/runtime.js +55 -23
- package/dist/src/gateway-client.js +2 -0
- package/dist/src/publication/local-fix-adapters.js +4 -0
- package/dist/src/publication/production-host.js +2 -1
- package/dist/src/publication/server-ci-host.js +2 -1
- package/dist/src/safety/claude-managed-scope.js +87 -0
- package/dist/src/safety/claude-production-host.js +261 -0
- package/dist/src/safety/claude-release-copy.js +233 -0
- package/dist/src/safety/claude-release.js +89 -0
- package/dist/src/safety/job-security-policy.js +6 -2
- package/dist/src/safety/job-security-receipt.js +9 -2
- package/dist/src/safety/job-security-session.js +3 -2
- package/dist/src/safety/security-requirements.js +4 -0
- package/dist/src/transport/sync-dispatch.js +9 -0
- package/package.json +1 -1
- package/dist/src/daemon/session-open-poller.js +0 -123
package/README.md
CHANGED
|
@@ -889,10 +889,12 @@ The reviewed manual Codex path preserves the existing CLI and ChatGPT login. It
|
|
|
889
889
|
### Local Fix with disclosed validation
|
|
890
890
|
|
|
891
891
|
The existing daemon can use a reviewed `gateway_publish_v1` installation for
|
|
892
|
-
approved local Codex fixes
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
892
|
+
approved local Codex fixes, and Claude fixes once the Gateway lists a reviewed
|
|
893
|
+
Claude release and the runner reports Claude ready. It freezes the owned model
|
|
894
|
+
and sends only sealed, bounded changed text files to Gateway's existing
|
|
895
|
+
publication service. Server credentials create the generated branch and
|
|
896
|
+
canonical development PR; no local Git token, ECR, new CI installation or native
|
|
897
|
+
PHP verifier is required.
|
|
896
898
|
|
|
897
899
|
Validation is explicitly **UNVERIFIED** in this mode, with CI **not run**.
|
|
898
900
|
Executor-reported checks, failures and checks not run stay attributed to the
|
|
@@ -915,6 +917,10 @@ the older `authenticated` field still means execution eligibility. A logged-in
|
|
|
915
917
|
Claude account can remain blocked by unverified security admission. Do not log out
|
|
916
918
|
or change credentials just because that legacy field is false. Unknown status is
|
|
917
919
|
not evidence of logout. These diagnostics do not bypass per-job admission.
|
|
920
|
+
Claude reports `CLAUDE_RELEASE_NOT_LISTED` while the Gateway lists no reviewed
|
|
921
|
+
Claude release. Claude readiness may lag a Claude login or logout by up to 5
|
|
922
|
+
minutes, because the runner asks the pinned copy for the login state at most that
|
|
923
|
+
often.
|
|
918
924
|
|
|
919
925
|
### 0.1.50: small edits and publication-only Fix
|
|
920
926
|
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { requireSecurityRequirements } from '../../safety/security-requirements.js';
|
|
3
|
+
import { CLAUDE_RELEASE_MODE, CLAUDE_TRANSPORT } from '../../safety/claude-release.js';
|
|
3
4
|
import { createJobSecurityReceipt } from '../../safety/job-security-receipt.js';
|
|
4
5
|
import { isTerminalEventType } from "../../protocol.js";
|
|
5
6
|
import { parseClaudeLine } from "./stream-parser.js";
|
|
@@ -92,7 +93,11 @@ export class ClaudeCodeAdapter {
|
|
|
92
93
|
if (ctx.approvedTaskPackage?.resolved_provider !== "anthropic" || ctx.approvedTaskPackage?.resolved_model_id !== resolvedModelId
|
|
93
94
|
|| ctx.approvedTaskPackage?.repository?.base_commit_sha !== this.options.securityPolicy.pin.sourceSha)
|
|
94
95
|
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
|
+
const requirements = requireSecurityRequirements(ctx.approvedTaskPackage?.security_requirements, { adapterId: "claude_code_cli", adapterVersion: "1", requirementsSha256: this.options.securityPolicy.pin.requirementsSha256 });
|
|
97
|
+
// Release mode (decision 9095c7a8): the approved package carries the fixed stream-json transport and a model of its release.
|
|
98
|
+
if (requirements.security_mode === CLAUDE_RELEASE_MODE && (ctx.approvedTaskPackage?.runtime_transport !== CLAUDE_TRANSPORT
|
|
99
|
+
|| !requirements.model_ids.includes(resolvedModelId)))
|
|
100
|
+
throw new Error("SECURITY_REQUIREMENTS_REJECTED");
|
|
96
101
|
};
|
|
97
102
|
assertAdmission();
|
|
98
103
|
if (!ctx.securityBinding)
|
|
@@ -277,6 +282,23 @@ export class ClaudeCodeAdapter {
|
|
|
277
282
|
});
|
|
278
283
|
return this.cancelPromise;
|
|
279
284
|
}
|
|
285
|
+
/** Owned exit before credential-bearing publication, with Codex semantics (decision 9095c7a8). It revokes the job
|
|
286
|
+
* security session and the MCP endpoint, stops the owned process group and confirms it within 10 seconds. */
|
|
287
|
+
async freezeForPublication() {
|
|
288
|
+
if (!this.started || !this.handle?.terminateOwned)
|
|
289
|
+
throw new Error('CLAUDE_PUBLICATION_FREEZE_UNCONFIRMED');
|
|
290
|
+
let timer;
|
|
291
|
+
try {
|
|
292
|
+
await Promise.race([this.cancel(), new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('CLAUDE_PUBLICATION_FREEZE_UNCONFIRMED')), 10_000); })]);
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
throw new Error('CLAUDE_PUBLICATION_FREEZE_UNCONFIRMED');
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
if (timer)
|
|
299
|
+
clearTimeout(timer);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
280
302
|
securityRevoked = false;
|
|
281
303
|
revokeSecurity() {
|
|
282
304
|
if (this.securityRevoked)
|
|
@@ -369,6 +391,6 @@ export class ClaudeCodePlugin {
|
|
|
369
391
|
return { ...result, capabilities: result.capabilities ?? this.capabilities };
|
|
370
392
|
}
|
|
371
393
|
async create(input) {
|
|
372
|
-
return new ClaudeCodeAdapter(this.pluginOptions.options(input));
|
|
394
|
+
return new ClaudeCodeAdapter(await this.pluginOptions.options(input));
|
|
373
395
|
}
|
|
374
396
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Reads `claude auth status --json` stdout without returning raw output. `loggedOutExit` marks Claude's exit 1,
|
|
2
|
+
* which never proves a login. Anything unparsable stays unknown. */
|
|
3
|
+
export function claudeLoginFromStatus(stdout, loggedOutExit = false) {
|
|
4
|
+
try {
|
|
5
|
+
const value = JSON.parse(stdout);
|
|
6
|
+
if (value && typeof value === 'object' && !Array.isArray(value) && typeof value.loggedIn === 'boolean') {
|
|
7
|
+
const loggedIn = value.loggedIn;
|
|
8
|
+
if (loggedOutExit && loggedIn)
|
|
9
|
+
return { authenticated: false, loginStatus: 'unknown' };
|
|
10
|
+
return { authenticated: loggedIn, loginStatus: loggedIn ? 'authenticated' : 'unauthenticated' };
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
catch { /* Unknown evidence stays closed; never expose raw output. */ }
|
|
14
|
+
return { authenticated: false, loginStatus: 'unknown' };
|
|
15
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { adapterStatus } from './status.js';
|
|
2
|
+
import { claudeLoginFromStatus } from './claude-code/login-status.js';
|
|
2
3
|
import { createCodexProductionHost } from '../safety/codex-production-host.js';
|
|
3
4
|
import { execFile } from 'node:child_process';
|
|
4
5
|
import { promisify } from 'node:util';
|
|
@@ -22,16 +23,6 @@ export async function discoverAdapters(env, command = run, adapterIds = ['claude
|
|
|
22
23
|
throw new Error('invalid version');
|
|
23
24
|
let authenticated = false;
|
|
24
25
|
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
|
-
};
|
|
35
26
|
try {
|
|
36
27
|
const auth = await command(binary, codex ? ['login', 'status'] : ['auth', 'status', '--json']);
|
|
37
28
|
if (codex) {
|
|
@@ -39,20 +30,15 @@ export async function discoverAdapters(env, command = run, adapterIds = ['claude
|
|
|
39
30
|
loginStatus = authenticated ? 'authenticated' : 'unauthenticated';
|
|
40
31
|
}
|
|
41
32
|
else
|
|
42
|
-
|
|
33
|
+
({ authenticated, loginStatus } = claudeLoginFromStatus(auth.stdout));
|
|
43
34
|
}
|
|
44
35
|
catch (error) {
|
|
45
36
|
// Claude exits 1 for a known logged-out JSON response. Other failures
|
|
46
37
|
// (including timeout/permission errors) do not prove logout.
|
|
47
38
|
if (!codex && error && typeof error === 'object' && error.code === 1) {
|
|
48
39
|
const stdout = error.stdout;
|
|
49
|
-
if (typeof stdout === 'string')
|
|
50
|
-
|
|
51
|
-
if (authenticated) {
|
|
52
|
-
authenticated = false;
|
|
53
|
-
loginStatus = 'unknown';
|
|
54
|
-
}
|
|
55
|
-
}
|
|
40
|
+
if (typeof stdout === 'string')
|
|
41
|
+
({ authenticated, loginStatus } = claudeLoginFromStatus(stdout, true));
|
|
56
42
|
}
|
|
57
43
|
}
|
|
58
44
|
if (codex) {
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import { createClaudeProductionHost } from '../safety/claude-production-host.js';
|
|
1
2
|
import { adapterStatus } from '../adapters/status.js';
|
|
2
3
|
import { loadInstalledLocalFixHost } from '../publication/installed-host.js';
|
|
4
|
+
import { isLocalFixAdapter } from '../publication/local-fix-adapters.js';
|
|
3
5
|
import { createCodexDaemonHost } from '../safety/codex-daemon-host.js';
|
|
4
6
|
import { reconcileTerminalState } from './terminal-recovery.js';
|
|
5
7
|
import { packageVersion } from '../package-version.js';
|
|
@@ -36,6 +38,15 @@ export async function buildDaemon(input) {
|
|
|
36
38
|
const claudeParts = [input.claudeSecurityHost, input.claudeSupportedProbe, input.claudeBinaryIdentity].filter(Boolean).length;
|
|
37
39
|
if (claudeParts !== 0 && claudeParts !== 3)
|
|
38
40
|
throw new Error('CLAUDE_SUPPORTED_HOST_REQUIRED');
|
|
41
|
+
// Claude admission (decision 9095c7a8): the compiled release host, unless a test injects the whole trusted tuple.
|
|
42
|
+
const claudeProduction = claudeParts === 0 ? createClaudeProductionHost({ home: input.env.HOME ?? '', env: input.env, fetchFn: input.fetchFn,
|
|
43
|
+
report: event => { process.stderr.write(`${JSON.stringify(event)}\n`); } }) : undefined;
|
|
44
|
+
const claude = {
|
|
45
|
+
probe: input.claudeSupportedProbe ?? (() => claudeProduction.probe()),
|
|
46
|
+
securityHost: input.claudeSecurityHost ?? claudeProduction.securityHost,
|
|
47
|
+
// Per job: a release change moves the pinned launch path, so the identity is captured at adapter creation.
|
|
48
|
+
binaryIdentity: async () => input.claudeBinaryIdentity ?? claudeProduction.binaryIdentity(),
|
|
49
|
+
};
|
|
39
50
|
if (input.codexSupportedProbe && !input.codexSecurityHost)
|
|
40
51
|
throw new Error('CODEX_SUPPORTED_HOST_REQUIRED');
|
|
41
52
|
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;
|
|
@@ -70,12 +81,11 @@ export async function buildDaemon(input) {
|
|
|
70
81
|
// The same parts the CLI self-test ran before starting this daemon (verifyPackagedHook).
|
|
71
82
|
const hook = packagedHookCommandParts();
|
|
72
83
|
const plugin = new ClaudeCodePlugin({
|
|
73
|
-
probe:
|
|
74
|
-
options: ({ worktreePath, jobId }) => createClaudeCodeAdapterOptions({
|
|
84
|
+
probe: () => claude.probe(),
|
|
85
|
+
options: async ({ worktreePath, jobId }) => createClaudeCodeAdapterOptions({
|
|
75
86
|
worktreePath, jobId, stateRoot: input.stateRoot, env: input.env,
|
|
76
87
|
maxBudgetUsd: Number.MAX_SAFE_INTEGER, hookExecutablePath: hook.hookScriptPath, hookNodeExecutablePath: hook.nodeExecutablePath,
|
|
77
|
-
|
|
78
|
-
securityHost: input.claudeSecurityHost, binaryIdentity: input.claudeBinaryIdentity,
|
|
88
|
+
securityHost: claude.securityHost, binaryIdentity: await claude.binaryIdentity(),
|
|
79
89
|
}),
|
|
80
90
|
});
|
|
81
91
|
const codex = new CodexPlugin({
|
|
@@ -162,24 +172,26 @@ export async function buildDaemon(input) {
|
|
|
162
172
|
snapshot: async () => {
|
|
163
173
|
// Adapter probes run only between jobs, as they did when the job loop sent the heartbeat.
|
|
164
174
|
if (!poller.running) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
catch {
|
|
172
|
-
current = { available: false, authenticated: false, capabilities: [] };
|
|
173
|
-
}
|
|
174
|
-
const ready = adapterStatus(current).authenticated && !!current.executableVersion && plugin.capabilities.every(c => current.capabilities.includes(c));
|
|
175
|
-
const index = probes.findIndex(p => p.id === 'claude_code_cli');
|
|
176
|
-
if (index >= 0)
|
|
177
|
-
probes[index] = { id: 'claude_code_cli', version: '1', ...current, available: ready };
|
|
178
|
-
if (ready)
|
|
179
|
-
versions.claude_code_cli = '1';
|
|
180
|
-
else
|
|
181
|
-
delete versions.claude_code_cli;
|
|
175
|
+
// Claude readiness follows the synced release list, so it is refreshed like Codex. The two refreshes run
|
|
176
|
+
// concurrently and each keeps its own failure handling, so Claude neither delays nor changes the Codex result.
|
|
177
|
+
const refreshClaude = async () => {
|
|
178
|
+
let current;
|
|
179
|
+
try {
|
|
180
|
+
current = await plugin.probe();
|
|
182
181
|
}
|
|
182
|
+
catch {
|
|
183
|
+
current = { available: false, authenticated: false, capabilities: [] };
|
|
184
|
+
}
|
|
185
|
+
const ready = adapterStatus(current).authenticated && !!current.executableVersion && plugin.capabilities.every(c => current.capabilities.includes(c));
|
|
186
|
+
const index = probes.findIndex(p => p.id === 'claude_code_cli');
|
|
187
|
+
if (index >= 0)
|
|
188
|
+
probes[index] = { id: 'claude_code_cli', version: '1', ...current, available: ready };
|
|
189
|
+
if (ready)
|
|
190
|
+
versions.claude_code_cli = '1';
|
|
191
|
+
else
|
|
192
|
+
delete versions.claude_code_cli;
|
|
193
|
+
};
|
|
194
|
+
const refreshCodex = async () => {
|
|
183
195
|
let current;
|
|
184
196
|
try {
|
|
185
197
|
current = await codex.probe();
|
|
@@ -195,6 +207,14 @@ export async function buildDaemon(input) {
|
|
|
195
207
|
versions.codex_cli = '1';
|
|
196
208
|
else
|
|
197
209
|
delete versions.codex_cli;
|
|
210
|
+
};
|
|
211
|
+
// Both refreshes always run to the end, so a failure outside one never leaves the other writing under a claim;
|
|
212
|
+
// the first failure then surfaces as it did before.
|
|
213
|
+
const probe = (async () => {
|
|
214
|
+
const settled = await Promise.allSettled([refreshClaude(), refreshCodex()]);
|
|
215
|
+
const failed = settled.find((result) => result.status === "rejected");
|
|
216
|
+
if (failed)
|
|
217
|
+
throw failed.reason;
|
|
198
218
|
})();
|
|
199
219
|
probing = probe.catch(() => undefined);
|
|
200
220
|
await probe;
|
|
@@ -231,6 +251,7 @@ export async function buildDaemon(input) {
|
|
|
231
251
|
failureCode: result.status === "failed" ? result.failureCode : undefined }, signal);
|
|
232
252
|
},
|
|
233
253
|
guide,
|
|
254
|
+
...(claudeProduction ? { claude: claudeProduction } : {}),
|
|
234
255
|
asks,
|
|
235
256
|
closureAcks,
|
|
236
257
|
jobs: poller,
|
|
@@ -273,7 +294,18 @@ export async function buildDaemon(input) {
|
|
|
273
294
|
reportRecoveryRequired: reason => { process.stderr.write(`${JSON.stringify({ event: 'runner_recovery_required', reason })}\n`); },
|
|
274
295
|
});
|
|
275
296
|
// Model tools have no Gateway relay. Each adapter owns and closes its job endpoint.
|
|
276
|
-
|
|
297
|
+
// The Claude host always closes; a failing Claude close never masks a Codex close failure.
|
|
298
|
+
return { daemon, close: async () => {
|
|
299
|
+
daemon.stop();
|
|
300
|
+
try {
|
|
301
|
+
await production?.close();
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
await claudeProduction?.close().catch(() => undefined);
|
|
305
|
+
throw error;
|
|
306
|
+
}
|
|
307
|
+
await claudeProduction?.close();
|
|
308
|
+
} };
|
|
277
309
|
}
|
|
278
310
|
export function runnerDisplayName() { return `${hostname()} local AI`; }
|
|
279
311
|
/** The execution blocker a full runner reports for every adapter while it takes no jobs. */
|
|
@@ -326,7 +358,7 @@ export function localRunnerCapabilities(probes, trustedLocalFixHost = false, ver
|
|
|
326
358
|
const status = adapterStatus(probe);
|
|
327
359
|
const authenticated = status.authenticated;
|
|
328
360
|
const capabilities = probe.capabilities.filter(value => value !== 'local_fix_publication_v1' && value !== 'local_fix_gateway_ci_v1' && value !== 'local_fix_gateway_publish_v1');
|
|
329
|
-
if (trustedLocalFixHost && authenticated && probe.id
|
|
361
|
+
if (trustedLocalFixHost && authenticated && isLocalFixAdapter(probe.id)) {
|
|
330
362
|
capabilities.push('local_fix_publication_v1');
|
|
331
363
|
if (verificationMode === 'gateway_ci_v1')
|
|
332
364
|
capabilities.push('local_fix_gateway_ci_v1');
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { publicationError } from './publication/failure.js';
|
|
2
2
|
import { issueTransportDiagnostic } from './safety/transport-diagnostic.js';
|
|
3
|
+
import { parseClaudeSyncRelease } from "./safety/claude-release.js";
|
|
3
4
|
import { createHash } from "node:crypto";
|
|
4
5
|
import { v5 as uuidv5 } from "uuid";
|
|
5
6
|
import { bindingApprovalIdFromEnv, redact } from "./config.js";
|
|
@@ -33,6 +34,7 @@ export function parseRunnerSync(result, runnerId) {
|
|
|
33
34
|
...(Array.isArray(body.closed_delivery_ids) ? { closedDeliveryIds: body.closed_delivery_ids } : {}),
|
|
34
35
|
...("guidance_policy" in body ? { guidancePolicy: body.guidance_policy } : {}),
|
|
35
36
|
...(typeof body.guidance_policy_version === "string" ? { guidancePolicyVersion: body.guidance_policy_version } : {}),
|
|
37
|
+
...("claude_release" in body ? { claudeRelease: parseClaudeSyncRelease(body.claude_release) } : {}),
|
|
36
38
|
jobAvailable: body.job_available === true,
|
|
37
39
|
push: parsePush(body.push, runnerId),
|
|
38
40
|
syncSeconds: clampSeconds(body.sync_seconds, 15, 120, 30),
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Runtime adapters whose admitted jobs may use employee-local Fix publication (decision 9095c7a8 adds Claude).
|
|
2
|
+
* Membership grants nothing alone: each job still needs its adapter's host admission and the Gateway's Fix policy. */
|
|
3
|
+
export const LOCAL_FIX_ADAPTERS = Object.freeze(['codex_cli', 'claude_code_cli']);
|
|
4
|
+
export function isLocalFixAdapter(value) { return typeof value === 'string' && LOCAL_FIX_ADAPTERS.includes(value); }
|
|
@@ -4,6 +4,7 @@ import { lstat, realpath, open, rename, unlink } from 'node:fs/promises';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { CandidateVerifier, protocolHash } from './candidate.js';
|
|
6
6
|
import { createLocalFixExecutionHost } from './execution-host.js';
|
|
7
|
+
import { isLocalFixAdapter } from './local-fix-adapters.js';
|
|
7
8
|
import { GitSnapshotHost, SNAPSHOT_TREE_MAX_BYTES } from './git-snapshot.js';
|
|
8
9
|
import { createSealedIsolatedVerifier } from './isolated-verifier.js';
|
|
9
10
|
import { FilePublicationJournal } from './journal.js';
|
|
@@ -29,7 +30,7 @@ export function createProductionLocalFixHost(options) {
|
|
|
29
30
|
if (!lifecycle)
|
|
30
31
|
throw Error('LOCAL_FIX_OWNED_LIFECYCLE_REQUIRED');
|
|
31
32
|
const { job, bindingId } = input, pkg = job.taskPackage, repo = pkg.repository, scope = pkg.local_fix_scope;
|
|
32
|
-
if (!lifecycle.isCurrent() || repo.provider_url !== options.origin || repo.key !== registration.manifest.project || job.gatewayTaskPackageFingerprint !== protocolHash(pkg) || pkg.runtime_adapter
|
|
33
|
+
if (!lifecycle.isCurrent() || repo.provider_url !== options.origin || repo.key !== registration.manifest.project || job.gatewayTaskPackageFingerprint !== protocolHash(pkg) || !isLocalFixAdapter(pkg.runtime_adapter))
|
|
33
34
|
throw denied();
|
|
34
35
|
const root = await realpath(options.privateRoot), stat = await lstat(root);
|
|
35
36
|
if (root !== options.privateRoot || !stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o077) !== 0 || (process.getuid && stat.uid !== process.getuid()) || root === input.checkoutPath || root.startsWith(input.checkoutPath + path.sep))
|
|
@@ -12,6 +12,7 @@ import { protocolHash } from './candidate.js';
|
|
|
12
12
|
import { FilePublicationJournal } from './journal.js';
|
|
13
13
|
import { GitSnapshotHost, SNAPSHOT_TREE_MAX_BYTES } from './git-snapshot.js';
|
|
14
14
|
import { PublicationOutcomeUnknownError } from './publisher.js';
|
|
15
|
+
import { isLocalFixAdapter } from './local-fix-adapters.js';
|
|
15
16
|
const denied = () => Error('LOCAL_FIX_SERVER_CI_REJECTED');
|
|
16
17
|
const safePath = (p) => typeof p === 'string' && p.length > 0 && !p.includes('\\') && !p.includes('\0') && !path.isAbsolute(p) && p.split('/').every(s => s !== '' && s !== '.' && s !== '..' && !/^(?:\.git|\.mcp(?:\..*)?|\.npmrc|\.netrc|id_rsa|id_ed25519|\.local-runner|\.runner|\.env(?:\..*)?|\.codex|\.claude|\.ssh|\.aws|\.config|auth\.(?:json|toml|ya?ml)(?:\..*)?|credentials(?:\..*)?|AGENTS\.md|CLAUDE\.md|mcp\.(?:json|toml|ya?ml)(?:\..*)?)$/i.test(s));
|
|
17
18
|
const matches = (p, s) => s.endsWith('/**') ? safePath(s.slice(0, -3)) && (p === s.slice(0, -3) || p.startsWith(s.slice(0, -2))) : safePath(s) && p === s;
|
|
@@ -70,7 +71,7 @@ export function createServerCiLocalFixHost(options) {
|
|
|
70
71
|
throw denied();
|
|
71
72
|
const { job } = input, pkg = job.taskPackage, scope = pkg.local_fix_scope, repo = pkg.repository, rawLimits = scope?.candidate_limits, allowed = scope?.allowed_paths;
|
|
72
73
|
const root = await realpath(options.privateRoot), st = await lstat(root);
|
|
73
|
-
if (!lifecycle.isCurrent() || pkg.runtime_adapter
|
|
74
|
+
if (!lifecycle.isCurrent() || !isLocalFixAdapter(pkg.runtime_adapter) || !scope || scope.verification_mode !== mode || scope.ci_profile_fingerprint !== registration.ci_profile_fingerprint || protocolHash(scope.ci_profile) !== protocolHash(registration.ci_profile) || !Array.isArray(scope.required_commands) || scope.required_commands.length || !scope.registered_tests || typeof scope.registered_tests !== 'object' || Object.keys(scope.registered_tests).length || !Array.isArray(allowed) || !allowed.length || allowed.some(p => typeof p !== 'string' || !(safePath(p) || p.endsWith('/**') && safePath(p.slice(0, -3)))) || protocolHash(allowed) !== protocolHash(pkg.allowed_paths) || repo.key !== registration.project || job.gatewayTaskPackageFingerprint !== protocolHash(pkg) || Object.entries(registration.receipt).some(([k, v]) => scope[k] !== v) || root !== options.privateRoot || !st.isDirectory() || st.isSymbolicLink() || (st.mode & 0o077) !== 0 || (process.getuid && st.uid !== process.getuid()) || root === input.checkoutPath || root.startsWith(input.checkoutPath + path.sep))
|
|
74
75
|
throw denied();
|
|
75
76
|
const limits = candidateLimits(rawLimits, mode === 'gateway_publish_v1');
|
|
76
77
|
const fence = { jobId: job.id, generation: job.generation, leaseToken: job.leaseToken, launchGrant: job.launchGrant, bindingId: input.bindingId };
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { lstat, readdir } from 'node:fs/promises';
|
|
4
|
+
import { createPolicyInventoryClient, verifyPolicyObserver } from './policy-inventory-client.js';
|
|
5
|
+
import { CLAUDE_MANAGED_PREFERENCE_DOMAIN, CLAUDE_MANAGED_SETTINGS_DIRECTORY, CLAUDE_MANAGED_SETTINGS_FILES, CLAUDE_POLICY_OBSERVER, claudeCanonicalSha256 } from './claude-release.js';
|
|
6
|
+
const unverified = () => Error('CLAUDE_MANAGED_SCOPE_UNVERIFIED');
|
|
7
|
+
/** Claude managed sources the Runner checks on macOS (gate managed_scope_exclusion, scope unmanaged_personal_only). */
|
|
8
|
+
export function claudeManagedPaths(user, root = '/') {
|
|
9
|
+
if (!/^[A-Za-z0-9._-]{1,255}$/.test(user) || user.startsWith('.') || !path.isAbsolute(root))
|
|
10
|
+
throw unverified();
|
|
11
|
+
const at = (file) => path.join(root, file);
|
|
12
|
+
return { directory: at(CLAUDE_MANAGED_SETTINGS_DIRECTORY), files: [...CLAUDE_MANAGED_SETTINGS_FILES.map(at),
|
|
13
|
+
at(`/Library/Managed Preferences/${CLAUDE_MANAGED_PREFERENCE_DOMAIN}.plist`), at(`/Library/Managed Preferences/${user}/${CLAUDE_MANAGED_PREFERENCE_DOMAIN}.plist`)] };
|
|
14
|
+
}
|
|
15
|
+
async function absent(file) {
|
|
16
|
+
try {
|
|
17
|
+
await lstat(file);
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
if (error.code === 'ENOENT')
|
|
21
|
+
return;
|
|
22
|
+
throw unverified();
|
|
23
|
+
}
|
|
24
|
+
throw unverified();
|
|
25
|
+
}
|
|
26
|
+
/** Each managed file must be absent. The settings directory must be absent or an empty real directory,
|
|
27
|
+
* so drop-in or unknown sources also block. Unreadable means unknown, and unknown blocks. */
|
|
28
|
+
export async function assertClaudeManagedPathsAbsent(user, root = '/') {
|
|
29
|
+
const { directory, files } = claudeManagedPaths(user, root);
|
|
30
|
+
for (const file of files)
|
|
31
|
+
await absent(file);
|
|
32
|
+
let s;
|
|
33
|
+
try {
|
|
34
|
+
s = await lstat(directory);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
if (error.code === 'ENOENT')
|
|
38
|
+
return;
|
|
39
|
+
throw unverified();
|
|
40
|
+
}
|
|
41
|
+
if (!s.isDirectory() || s.isSymbolicLink())
|
|
42
|
+
throw unverified();
|
|
43
|
+
let entries;
|
|
44
|
+
try {
|
|
45
|
+
entries = await readdir(directory);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw unverified();
|
|
49
|
+
}
|
|
50
|
+
if (entries.length !== 0)
|
|
51
|
+
throw unverified();
|
|
52
|
+
}
|
|
53
|
+
/** Compiled defaults: the pinned installed observer (single-use clients) and the real managed locations. */
|
|
54
|
+
export function claudeManagedScopeDeps(root = '/') {
|
|
55
|
+
return {
|
|
56
|
+
checkReady: async () => { verifyPolicyObserver(CLAUDE_POLICY_OBSERVER); await createPolicyInventoryClient(CLAUDE_POLICY_OBSERVER).checkReady(); },
|
|
57
|
+
collect: () => createPolicyInventoryClient(CLAUDE_POLICY_OBSERVER).collectOnce(),
|
|
58
|
+
managedPathsAbsent: () => assertClaudeManagedPathsAbsent(os.userInfo().username, root),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
export async function claudeManagedScopeReady(deps) {
|
|
62
|
+
try {
|
|
63
|
+
await deps.checkReady();
|
|
64
|
+
await deps.managedPathsAbsent();
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
throw unverified();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Per-job managed scope: no configuration profile at all for machine and user (complete, empty, at most 60 s old),
|
|
71
|
+
* plus absent Claude managed files. Managed, partial, unknown or stale input blocks. */
|
|
72
|
+
export async function collectClaudeManagedScope(deps) {
|
|
73
|
+
try {
|
|
74
|
+
const value = await deps.collect(), now = (deps.now ?? Date.now)();
|
|
75
|
+
const r = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
76
|
+
const observedAt = r.observedAt;
|
|
77
|
+
if (r.complete !== true || r.empty !== true || r.scope !== 'machine_and_current_user' || typeof observedAt !== 'number' || !Number.isSafeInteger(observedAt)
|
|
78
|
+
|| observedAt > now || now - observedAt > 60_000 || typeof r.inventoryFingerprint !== 'string' || !/^[a-f0-9]{64}$/.test(r.inventoryFingerprint))
|
|
79
|
+
throw unverified();
|
|
80
|
+
await deps.managedPathsAbsent();
|
|
81
|
+
const bound = { observer_sha256: CLAUDE_POLICY_OBSERVER.sha256, inventory_fingerprint: r.inventoryFingerprint, managed_paths: 'absent' };
|
|
82
|
+
return Object.freeze({ observerSha256: CLAUDE_POLICY_OBSERVER.sha256, inventoryFingerprint: r.inventoryFingerprint, fingerprint: claudeCanonicalSha256(bound) });
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
throw unverified();
|
|
86
|
+
}
|
|
87
|
+
}
|