mtech-ai-local-runner 0.1.60 → 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/asks/inbox.js +10 -3
- package/dist/src/asks/notifier.js +7 -0
- package/dist/src/asks/routing.js +47 -6
- package/dist/src/asks/runtime.js +2 -1
- package/dist/src/asks/store.js +6 -1
- package/dist/src/asks/types.js +16 -4
- 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) {
|
package/dist/src/asks/inbox.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { emptyAskState, pruneAskState, sessionKey } from "./store.js";
|
|
2
|
-
import { addDeliveries, claimOpen, closeDeliveries, confirmNotified, confirmOpened, confirmWake, isLive,
|
|
2
|
+
import { addDeliveries, claimOpen, closeDeliveries, confirmNotified, confirmOpened, confirmWake, isLive, openApp, planRoutes, promptSubmitted, releaseOpen, sessionEnded, sessionStarted, turnEnded, } from "./routing.js";
|
|
3
3
|
import { droppableDeliveryId, parseAskDelivery } from "./types.js";
|
|
4
4
|
import { renderOutput } from "./templates.js";
|
|
5
5
|
import { PRESENCE_RECHECK_MS } from "./presence.js";
|
|
@@ -292,11 +292,16 @@ export class AskInbox {
|
|
|
292
292
|
// Claimed before anything runs, so a delivery is never opened or notified twice.
|
|
293
293
|
const opening = new Set(plan.open.map(item => item.deliveryId));
|
|
294
294
|
claimOpen(this.state, [...opening], now);
|
|
295
|
-
for (const item of plan.notify)
|
|
295
|
+
for (const item of [...plan.notify, ...plan.held])
|
|
296
296
|
confirmNotified(this.state, item.deliveryId, now, false);
|
|
297
297
|
const notify = plan.notify.map(item => item.deliveryId);
|
|
298
|
+
const held = plan.held.flatMap(item => {
|
|
299
|
+
const session = item.sessionKey === null ? undefined : this.state.sessions[item.sessionKey];
|
|
300
|
+
return session === undefined ? [] : [{ deliveryId: item.deliveryId, app: session.app }];
|
|
301
|
+
});
|
|
298
302
|
return {
|
|
299
303
|
notify: notify.filter(id => !opening.has(id)),
|
|
304
|
+
held,
|
|
300
305
|
// The notification of a delivery in the open waits for its outcome, which decides the text.
|
|
301
306
|
open: opening.size === 0 ? null : { deliveryIds: [...opening], notifyAfter: notify.filter(id => opening.has(id)) },
|
|
302
307
|
};
|
|
@@ -306,6 +311,8 @@ export class AskInbox {
|
|
|
306
311
|
await this.persist();
|
|
307
312
|
if (work.notify.length > 0)
|
|
308
313
|
this.startNotifying(work.notify);
|
|
314
|
+
for (const { deliveryId, app } of work.held)
|
|
315
|
+
this.startNotifying([deliveryId], { heldIn: app });
|
|
309
316
|
if (work.open !== null)
|
|
310
317
|
this.startOpening(work.open);
|
|
311
318
|
try {
|
|
@@ -411,7 +418,7 @@ export class AskInbox {
|
|
|
411
418
|
return { status: "skipped" };
|
|
412
419
|
const kinds = { asks: items.some(item => item.direction === "ask"), answers: items.some(item => item.direction === "answer") };
|
|
413
420
|
try {
|
|
414
|
-
return await this.deps.openSession(
|
|
421
|
+
return await this.deps.openSession(openApp(this.state, items), kinds);
|
|
415
422
|
}
|
|
416
423
|
catch {
|
|
417
424
|
return { status: "failed", code: "open_failed" };
|
|
@@ -25,6 +25,11 @@ const OPENED_TEXT = {
|
|
|
25
25
|
codex: "AI Gateway: your ask has an answer. A Codex session is open to continue.",
|
|
26
26
|
},
|
|
27
27
|
};
|
|
28
|
+
/** An answer held in its live asking session (Work Decision 8562d191): no session is opened, the person goes back to it. */
|
|
29
|
+
const HELD_TEXT = {
|
|
30
|
+
claude: "AI Gateway: your ask has an answer. It is waiting in the Claude session that asked. Type there to see it.",
|
|
31
|
+
codex: "AI Gateway: your ask has an answer. It is waiting in the Codex session that asked. Type there to see it.",
|
|
32
|
+
};
|
|
28
33
|
export const OSASCRIPT = "/usr/bin/osascript";
|
|
29
34
|
const SCRIPT = ["on run argv", "display notification (item 1 of argv) with title \"AI Gateway\"", "end run"];
|
|
30
35
|
/**
|
|
@@ -34,6 +39,8 @@ const SCRIPT = ["on run argv", "display notification (item 1 of argv) with title
|
|
|
34
39
|
*/
|
|
35
40
|
export const NOTIFY_TIMEOUT_MS = 3_000;
|
|
36
41
|
export function notificationText(item, options = {}) {
|
|
42
|
+
if (options.heldIn !== undefined)
|
|
43
|
+
return HELD_TEXT[options.heldIn];
|
|
37
44
|
if (options.openedApp !== undefined)
|
|
38
45
|
return OPENED_TEXT[item.direction][options.openedApp];
|
|
39
46
|
return (item.direction === "ask" ? ASK_TEXT : ANSWER_TEXT)[item.subjectType];
|
package/dist/src/asks/routing.js
CHANGED
|
@@ -59,13 +59,46 @@ export function preferredApp(state) {
|
|
|
59
59
|
}
|
|
60
60
|
return best?.app ?? null;
|
|
61
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* The live session on this Mac that made an answer's ask (Work Decision 8562d191): null for an ask, without a hash, or
|
|
64
|
+
* when that session is unknown here or no longer live.
|
|
65
|
+
*/
|
|
66
|
+
export function askerSessionKey(item, state, now) {
|
|
67
|
+
if (item.direction !== "answer" || item.askerSessionHash === undefined || item.askerSessionHash === null)
|
|
68
|
+
return null;
|
|
69
|
+
for (const [key, session] of Object.entries(state.sessions)) {
|
|
70
|
+
if (isLive(session, now) && hashSessionId(session.sessionId) === item.askerSessionHash)
|
|
71
|
+
return key;
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* The app an open uses: for answers only, the app of the first one whose asking session this Mac still knows, live or
|
|
77
|
+
* ended, so the new session opens where the person asked; otherwise preferredApp.
|
|
78
|
+
*/
|
|
79
|
+
export function openApp(state, items) {
|
|
80
|
+
if (items.length > 0 && items.every(item => item.direction === "answer")) {
|
|
81
|
+
for (const item of items) {
|
|
82
|
+
if (item.askerSessionHash === undefined || item.askerSessionHash === null)
|
|
83
|
+
continue;
|
|
84
|
+
const asking = Object.values(state.sessions).find(session => hashSessionId(session.sessionId) === item.askerSessionHash);
|
|
85
|
+
if (asking !== undefined)
|
|
86
|
+
return asking.app;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return preferredApp(state);
|
|
90
|
+
}
|
|
62
91
|
/**
|
|
63
92
|
* Spec section 5: a live session the delivery names, else the live session with the most recent human prompt. A named
|
|
64
93
|
* session counts only with a human prompt in the last 30 minutes: named sessions stay live until closed, and an older
|
|
65
94
|
* one would otherwise keep the delivery from the session the person is using (2026-10-26 Test B). Among named sessions
|
|
66
|
-
* the one listed first wins: the Gateway lists the session that made the ask first (2026-10-26 test C).
|
|
95
|
+
* the one listed first wins: the Gateway lists the session that made the ask first (2026-10-26 test C). An answer whose
|
|
96
|
+
* asking session is live goes there, however long ago it was typed in (Work Decision 8562d191).
|
|
67
97
|
*/
|
|
68
98
|
export function chooseSession(item, state, now) {
|
|
99
|
+
const asking = askerSessionKey(item, state, now);
|
|
100
|
+
if (asking !== null)
|
|
101
|
+
return asking;
|
|
69
102
|
const live = Object.entries(state.sessions).filter(([, session]) => isLive(session, now));
|
|
70
103
|
const order = new Map(item.targetSessionHashes.map((hash, index) => [hash, index]));
|
|
71
104
|
const rank = (session) => order.get(hashSessionId(session.sessionId)) ?? order.size;
|
|
@@ -84,17 +117,22 @@ export function chooseSession(item, state, now) {
|
|
|
84
117
|
* becomes the chosen one by that prompt (see promptSubmitted);
|
|
85
118
|
* - an idle Claude session whose Stop watcher waits, under its hourly limit: woken now;
|
|
86
119
|
* - otherwise (an idle Codex session, no watcher, or the limit reached): a notification.
|
|
120
|
+
* An answer whose asking session is live is pinned or woken there by the same rules, and otherwise held there: it waits
|
|
121
|
+
* for that session, with a notification that says so, and no session is opened for it (Work Decision 8562d191).
|
|
87
122
|
*/
|
|
88
123
|
export function routeItem(item, state, now, watching) {
|
|
89
|
-
const
|
|
124
|
+
const asking = askerSessionKey(item, state, now);
|
|
125
|
+
const key = asking ?? chooseSession(item, state, now);
|
|
90
126
|
const session = key === null ? undefined : state.sessions[key];
|
|
91
|
-
if (key === null || session === undefined
|
|
127
|
+
if (key === null || session === undefined)
|
|
128
|
+
return { action: "notify" };
|
|
129
|
+
if (asking === null && !humanPromptWithin(session, now))
|
|
92
130
|
return { action: "notify" };
|
|
93
131
|
if (turnInProgress(session))
|
|
94
132
|
return { action: "pin", sessionKey: key };
|
|
95
|
-
if (session.app === "claude" && watching.has(key) && wakesLeft(session, now) > 0)
|
|
133
|
+
if (session.app === "claude" && humanPromptWithin(session, now) && watching.has(key) && wakesLeft(session, now) > 0)
|
|
96
134
|
return { action: "wake", sessionKey: key };
|
|
97
|
-
return { action: "notify" };
|
|
135
|
+
return asking === null ? { action: "notify" } : { action: "hold", sessionKey: key };
|
|
98
136
|
}
|
|
99
137
|
/** At most 5 spelled out, oldest first; the rest are summarized by one "and N more" line. */
|
|
100
138
|
export function selectForOutput(items) {
|
|
@@ -263,6 +301,7 @@ export function sessionEnded(state, context) {
|
|
|
263
301
|
export function planRoutes(state, now, watching, options = {}) {
|
|
264
302
|
const wakeGroups = new Map();
|
|
265
303
|
const notify = [];
|
|
304
|
+
const held = [];
|
|
266
305
|
const unseen = [];
|
|
267
306
|
for (const item of itemsWhere(state, item => item.phase === "waiting" || item.phase === "pinned")) {
|
|
268
307
|
const decision = routeItem(item, state, now, watching);
|
|
@@ -276,12 +315,14 @@ export function planRoutes(state, now, watching, options = {}) {
|
|
|
276
315
|
}
|
|
277
316
|
item.phase = "pinned";
|
|
278
317
|
item.sessionKey = decision.sessionKey;
|
|
318
|
+
if (decision.action === "hold" && item.notifiedAt === null)
|
|
319
|
+
held.push(item);
|
|
279
320
|
if (decision.action === "wake")
|
|
280
321
|
wakeGroups.set(decision.sessionKey, [...wakeGroups.get(decision.sessionKey) ?? [], item]);
|
|
281
322
|
}
|
|
282
323
|
const openable = unseen.filter(item => item.openedAt === null);
|
|
283
324
|
const open = options.canOpen === true && openable.length > 0 && !openPending(state, now) && opensLeft(state, now) > 0 ? openable : [];
|
|
284
|
-
return { wakes: [...wakeGroups].map(([key, items]) => ({ sessionKey: key, output: selectForOutput(items) })), notify, open };
|
|
325
|
+
return { wakes: [...wakeGroups].map(([key, items]) => ({ sessionKey: key, output: selectForOutput(items) })), notify, held, open };
|
|
285
326
|
}
|
|
286
327
|
/** The hub delivered a wake to the session's watcher: everything in it counts as shown there. */
|
|
287
328
|
export function confirmWake(state, key, output, now) {
|
package/dist/src/asks/runtime.js
CHANGED
|
@@ -6,6 +6,7 @@ import { installedApps } from "./integrations.js";
|
|
|
6
6
|
import { createNotifier } from "./notifier.js";
|
|
7
7
|
import { createPresenceReader } from "./presence.js";
|
|
8
8
|
import { createSessionOpener } from "./opener.js";
|
|
9
|
+
import { ASK_INBOX_VERSION } from "./types.js";
|
|
9
10
|
/*
|
|
10
11
|
* AI-first ask delivery inside the daemon (Work Item e1c08b33). It rides on the existing session-open loop:
|
|
11
12
|
* no new long-poll. The hub runs for as long as that loop runs, deliveries from each wait are stored and
|
|
@@ -152,7 +153,7 @@ export class AskRuntime {
|
|
|
152
153
|
catch {
|
|
153
154
|
apps = [];
|
|
154
155
|
}
|
|
155
|
-
return apps.length > 0 ? { version:
|
|
156
|
+
return apps.length > 0 ? { version: ASK_INBOX_VERSION, apps: [...apps] } : undefined;
|
|
156
157
|
}
|
|
157
158
|
/** Resolves when the start settles or the bound passes, whichever comes first. Never rejects: start() never throws. */
|
|
158
159
|
async waitForStart(starting) {
|
package/dist/src/asks/store.js
CHANGED
|
@@ -79,6 +79,10 @@ function normalizeItem(key, value) {
|
|
|
79
79
|
// Missing in a file written before opens existed: never opened.
|
|
80
80
|
if (v.openedAt !== undefined && !isTimeOrNull(v.openedAt))
|
|
81
81
|
return null;
|
|
82
|
+
// Missing in a file written before 0.1.61 or from a seven-key delivery: no asking session known.
|
|
83
|
+
if (v.askerSessionHash !== undefined && v.askerSessionHash !== null
|
|
84
|
+
&& (typeof v.askerSessionHash !== "string" || !SESSION_HASH.test(v.askerSessionHash)))
|
|
85
|
+
return null;
|
|
82
86
|
const outbox = v.outbox.map(normalizeReport);
|
|
83
87
|
if (outbox.some(report => report === null))
|
|
84
88
|
return null;
|
|
@@ -86,7 +90,8 @@ function normalizeItem(key, value) {
|
|
|
86
90
|
targetSessionHashes: [...v.targetSessionHashes], createdAt: v.createdAt, receivedAt: v.receivedAt,
|
|
87
91
|
phase: settlePhase(v.phase, v.sessionKey),
|
|
88
92
|
sessionKey: v.sessionKey, shownVia: v.shownVia, shownAt: v.shownAt, firstShownVia: v.firstShownVia,
|
|
89
|
-
notifiedAt: v.notifiedAt, openedAt: (v.openedAt ?? null), seenAt: v.seenAt, outbox: outbox
|
|
93
|
+
notifiedAt: v.notifiedAt, openedAt: (v.openedAt ?? null), seenAt: v.seenAt, outbox: outbox,
|
|
94
|
+
...(v.askerSessionHash === undefined ? {} : { askerSessionHash: v.askerSessionHash }) };
|
|
90
95
|
}
|
|
91
96
|
function normalizeSession(key, value) {
|
|
92
97
|
if (value === null || typeof value !== "object")
|
package/dist/src/asks/types.js
CHANGED
|
@@ -9,6 +9,11 @@ export const SUBJECT_TYPES = ["decision", "review", "request"];
|
|
|
9
9
|
export const DIRECTIONS = ["ask", "answer"];
|
|
10
10
|
export const VIAS = ["prompt", "session_start", "stop", "rewake", "notification"];
|
|
11
11
|
export const REPORT_EVENTS = ["received", "shown", "seen", "dropped"];
|
|
12
|
+
/**
|
|
13
|
+
* heartbeat runner.session_open.ask_inbox: the integrations actually installed on this Mac. Version 2 says the runner
|
|
14
|
+
* reads asker_session_hash on deliveries (Work Decision 8562d191); it needs a Gateway that accepts version 2.
|
|
15
|
+
*/
|
|
16
|
+
export const ASK_INBOX_VERSION = 2;
|
|
12
17
|
/** Gateway ids are lowercase UUIDs (contract: subject keys must be lowercase UUIDs). */
|
|
13
18
|
export const LOWER_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
14
19
|
/** sha256 of a client session id, as the Gateway stores external_session_id_hash. */
|
|
@@ -22,8 +27,10 @@ export const OPEN_ASKS_LEAD = "AI Gateway: colleagues' AIs are waiting for you."
|
|
|
22
27
|
export const OPEN_ANSWERS_LEAD = "AI Gateway: your asks have answers.";
|
|
23
28
|
/** The most target session hashes one delivery may carry: a longer list is refused on the wire and dropped on load. */
|
|
24
29
|
export const MAX_TARGET_HASHES = 50;
|
|
25
|
-
// Sorted, so they compare directly with the sorted keys of a wire item.
|
|
30
|
+
// Sorted, so they compare directly with the sorted keys of a wire item. A Gateway that knows this runner reads
|
|
31
|
+
// ask_inbox version 2 adds asker_session_hash; an older one sends the seven keys only.
|
|
26
32
|
const DELIVERY_FIELDS = ["created_at", "delivery_id", "direction", "subject_key", "subject_type", "target_session_hashes", "work_item_id"];
|
|
33
|
+
const DELIVERY_FIELDS_WITH_ASKER = ["asker_session_hash", ...DELIVERY_FIELDS];
|
|
27
34
|
function oneOf(values, value) {
|
|
28
35
|
return typeof value === "string" && values.includes(value);
|
|
29
36
|
}
|
|
@@ -39,9 +46,13 @@ export function parseAskDelivery(input) {
|
|
|
39
46
|
return null;
|
|
40
47
|
const record = input;
|
|
41
48
|
const keys = Object.keys(record).sort();
|
|
42
|
-
|
|
49
|
+
const withAsker = keys.includes("asker_session_hash");
|
|
50
|
+
const fields = withAsker ? DELIVERY_FIELDS_WITH_ASKER : DELIVERY_FIELDS;
|
|
51
|
+
if (keys.length !== fields.length || keys.some((key, index) => key !== fields[index]))
|
|
52
|
+
return null;
|
|
53
|
+
const { delivery_id: deliveryId, subject_type: subjectType, subject_key: subjectKey, direction, work_item_id: workItemId, target_session_hashes: hashes, created_at: createdAt, asker_session_hash: asker } = record;
|
|
54
|
+
if (withAsker && asker !== null && (typeof asker !== "string" || !SESSION_HASH.test(asker)))
|
|
43
55
|
return null;
|
|
44
|
-
const { delivery_id: deliveryId, subject_type: subjectType, subject_key: subjectKey, direction, work_item_id: workItemId, target_session_hashes: hashes, created_at: createdAt } = record;
|
|
45
56
|
if (typeof deliveryId !== "string" || !LOWER_UUID.test(deliveryId))
|
|
46
57
|
return null;
|
|
47
58
|
if (!isSubjectType(subjectType) || !isDirection(direction))
|
|
@@ -56,7 +67,8 @@ export function parseAskDelivery(input) {
|
|
|
56
67
|
return null;
|
|
57
68
|
if (typeof createdAt !== "number" || !Number.isSafeInteger(createdAt) || createdAt < 0)
|
|
58
69
|
return null;
|
|
59
|
-
return { deliveryId, subjectType, subjectKey, direction, workItemId, targetSessionHashes: [...hashes], createdAt
|
|
70
|
+
return { deliveryId, subjectType, subjectKey, direction, workItemId, targetSessionHashes: [...hashes], createdAt,
|
|
71
|
+
...(withAsker ? { askerSessionHash: asker } : {}) };
|
|
60
72
|
}
|
|
61
73
|
/** The id of a wire item that failed validation, when the id itself is valid, so it can be reported as dropped. */
|
|
62
74
|
export function droppableDeliveryId(input) {
|
|
@@ -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 };
|