mtech-ai-local-runner 0.1.68 → 0.1.70
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 +13 -0
- package/dist/src/asks/alerter.js +127 -0
- package/dist/src/asks/inbox.js +10 -1
- package/dist/src/asks/notifier.js +13 -9
- package/dist/src/asks/routing.js +20 -0
- package/dist/src/asks/runtime.js +15 -1
- package/dist/src/daemon/execution-coordinator.js +30 -1
- package/dist/src/daemon/runtime.js +5 -3
- package/dist/src/gateway-client.js +9 -0
- package/dist/src/publication/installed-host.js +41 -7
- package/dist/src/transport/sync-dispatch.js +1 -0
- package/dist/src/verification/p1-execution-host.js +4 -2
- package/dist/src/verification/runner-observed-verifier.js +22 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -186,6 +186,19 @@ and at most 6 times an hour. With no such session the ask becomes one macOS noti
|
|
|
186
186
|
seconds and logs only an error code, and the ask waits for the next session start or prompt. Hook text is fixed and
|
|
187
187
|
carries validated ids only; the AI reads the ask itself with the Gateway show tools, which mark its text as untrusted.
|
|
188
188
|
|
|
189
|
+
An ask or answer the runner puts into a session on its own, by a wake or at a turn end, also gets one macOS
|
|
190
|
+
notification naming that app ("It is in your Claude session."), since the person may be looking elsewhere (Work Item
|
|
191
|
+
643dc395). It is sent within two minutes of the show and never for a delivery that already had a notification; one
|
|
192
|
+
shown because the person typed gets none. The Gateway's `shown_via` stays `rewake` or `stop`.
|
|
193
|
+
|
|
194
|
+
While the Gateway's sync says `ask_alert: true` for the person (their actor id is in
|
|
195
|
+
`AI_OPS_GATEWAY_WORK_ASK_AI_FIRST_ALERT_ACTOR_IDS`, empty by default), every ask notification is the banner and also an
|
|
196
|
+
alert: a `display alert` with Later and Open Claude or Open Codex that stays until it is closed and gives up after 10
|
|
197
|
+
minutes. The banner stays in Notification Center if the alert is closed unread. Open runs `open -b` with that app's
|
|
198
|
+
bundle id. One alert is on screen at a time; while one is, the next notification is the banner only. The runner never waits for an alert, kills one still
|
|
199
|
+
open after 610 seconds, and closes it at shutdown. A sync without `ask_alert` turns alerts off; the session-open wait
|
|
200
|
+
leaves the last value.
|
|
201
|
+
|
|
189
202
|
The Gateway sends the ask keys on the wait only while asks are on for this runner. When a wait result comes without
|
|
190
203
|
them, every stored delivery ends: it is dropped from `asks.json` with no report, and the Gateway's Slack fallback
|
|
191
204
|
covers it. A stored delivery is forgotten an hour after it arrived, by which time the fallback (at most 15 minutes) has
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { execFile, spawn as nodeSpawn } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
import { OSASCRIPT, notificationText } from "./notifier.js";
|
|
4
|
+
/*
|
|
5
|
+
* Ask notifications as an alert that stays until it is closed (Work Item 643dc395, spec 2026-10-29, C), shown beside
|
|
6
|
+
* the banner while the Gateway's sync says alerts are on for this person (ask_alert). One alert at a time, and the runner never
|
|
7
|
+
* waits for it to close, so it holds no delivery handling and no shutdown. The text and the Open label reach
|
|
8
|
+
* AppleScript as arguments, never as script source, and nothing runs through a shell.
|
|
9
|
+
*/
|
|
10
|
+
export const OPEN = "/usr/bin/open";
|
|
11
|
+
/** An alert that has not failed after this long counts as shown, the same promise a banner makes. */
|
|
12
|
+
export const ALERT_SETTLE_MS = 1_000;
|
|
13
|
+
/** AppleScript gives up after 600 s; an alert still open 10 s later is killed. */
|
|
14
|
+
export const ALERT_KILL_MS = 610_000;
|
|
15
|
+
export const BUNDLE_IDS = { claude: "com.anthropic.claudefordesktop", codex: "com.openai.codex" };
|
|
16
|
+
const OPEN_LABEL = { claude: "Open Claude", codex: "Open Codex" };
|
|
17
|
+
/** `activate` brings osascript itself to the front, so the alert is not hidden behind other windows; it sends no Apple event to another app. */
|
|
18
|
+
const SCRIPT = [
|
|
19
|
+
"on run argv",
|
|
20
|
+
"activate",
|
|
21
|
+
"display alert \"AI Gateway\" message (item 1 of argv) buttons {\"Later\", item 2 of argv} default button 2 giving up after 600",
|
|
22
|
+
"end run",
|
|
23
|
+
];
|
|
24
|
+
/** `open -b` gives up after this, like the session opener's `open`. */
|
|
25
|
+
const OPEN_TIMEOUT_MS = 10_000;
|
|
26
|
+
/** The arguments osascript gets: the fixed script, then the text and the Open label. */
|
|
27
|
+
export function alertArguments(text, app) {
|
|
28
|
+
return [...SCRIPT.flatMap(line => ["-e", line]), text, OPEN_LABEL[app]];
|
|
29
|
+
}
|
|
30
|
+
const execFileAsync = promisify(execFile);
|
|
31
|
+
function errorCode(error) {
|
|
32
|
+
const code = error?.code;
|
|
33
|
+
return typeof code === "string" && /^[A-Z0-9_]{1,64}$/.test(code) ? code : "UNKNOWN";
|
|
34
|
+
}
|
|
35
|
+
export function createAlerter(deps = {}) {
|
|
36
|
+
const platform = deps.platform ?? process.platform;
|
|
37
|
+
const settleMs = deps.settleMs ?? ALERT_SETTLE_MS;
|
|
38
|
+
const killMs = deps.killMs ?? ALERT_KILL_MS;
|
|
39
|
+
const spawn = deps.spawn ?? ((file, args) => nodeSpawn(file, args, { stdio: ["ignore", "pipe", "ignore"] }));
|
|
40
|
+
const run = deps.run ?? ((file, args) => execFileAsync(file, args, { timeout: OPEN_TIMEOUT_MS, killSignal: "SIGKILL" }));
|
|
41
|
+
const fail = (code) => {
|
|
42
|
+
try {
|
|
43
|
+
deps.log?.({ event: "ask_alert_failed", code });
|
|
44
|
+
}
|
|
45
|
+
catch { /* Logging never stops delivery. */ }
|
|
46
|
+
};
|
|
47
|
+
let open = null;
|
|
48
|
+
return {
|
|
49
|
+
get busy() { return open !== null; },
|
|
50
|
+
show(text, app) {
|
|
51
|
+
if (platform !== "darwin" || open !== null)
|
|
52
|
+
return Promise.resolve(false);
|
|
53
|
+
return new Promise(resolve => {
|
|
54
|
+
let child;
|
|
55
|
+
try {
|
|
56
|
+
child = spawn(OSASCRIPT, alertArguments(text, app));
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
fail("SPAWN_FAILED");
|
|
60
|
+
resolve(false);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
open = child;
|
|
64
|
+
let output = "";
|
|
65
|
+
let settled = false;
|
|
66
|
+
let ended = false;
|
|
67
|
+
const settle = (shown) => {
|
|
68
|
+
if (settled)
|
|
69
|
+
return;
|
|
70
|
+
settled = true;
|
|
71
|
+
resolve(shown);
|
|
72
|
+
};
|
|
73
|
+
const settleTimer = setTimeout(() => settle(true), settleMs);
|
|
74
|
+
const killTimer = setTimeout(() => child.kill("SIGKILL"), killMs);
|
|
75
|
+
const end = () => {
|
|
76
|
+
ended = true;
|
|
77
|
+
clearTimeout(settleTimer);
|
|
78
|
+
clearTimeout(killTimer);
|
|
79
|
+
if (open === child)
|
|
80
|
+
open = null;
|
|
81
|
+
};
|
|
82
|
+
child.stdout?.on("data", chunk => { output += chunk.toString("utf8"); });
|
|
83
|
+
child.on("error", () => {
|
|
84
|
+
if (ended)
|
|
85
|
+
return;
|
|
86
|
+
end();
|
|
87
|
+
if (!settled)
|
|
88
|
+
fail("SPAWN_FAILED");
|
|
89
|
+
settle(false);
|
|
90
|
+
});
|
|
91
|
+
child.on("close", code => {
|
|
92
|
+
if (ended)
|
|
93
|
+
return;
|
|
94
|
+
end();
|
|
95
|
+
// A failure before the alert counted as shown leaves the delivery to the banner.
|
|
96
|
+
if (code !== 0 && !settled)
|
|
97
|
+
fail(code === null ? "KILLED" : `EXIT_${code}`);
|
|
98
|
+
settle(code === 0);
|
|
99
|
+
if (code === 0 && output.includes(`button returned:${OPEN_LABEL[app]}`)) {
|
|
100
|
+
run(OPEN, ["-b", BUNDLE_IDS[app]]).catch(error => fail(errorCode(error)));
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
},
|
|
105
|
+
stop() {
|
|
106
|
+
const child = open;
|
|
107
|
+
open = null;
|
|
108
|
+
child?.kill("SIGKILL");
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The runner's notifier: always the banner, and also the alert while alerts are on and none is open. The owner chose
|
|
114
|
+
* both on 2026-10-29: the alert stays until it is closed, and the banner stays in Notification Center if it is closed
|
|
115
|
+
* unread. The alert's app is the one the delivery is in, else the app of the most recent session with a typed prompt.
|
|
116
|
+
* The delivery counts as shown when either was.
|
|
117
|
+
*/
|
|
118
|
+
export function withAlerts(banner, alerter, deps) {
|
|
119
|
+
return async (item, options = {}) => {
|
|
120
|
+
const bannerShown = await banner(item, options);
|
|
121
|
+
if (!deps.alertsOn() || alerter.busy)
|
|
122
|
+
return bannerShown;
|
|
123
|
+
const app = options.shownIn ?? options.heldIn ?? options.openedApp ?? deps.fallbackApp();
|
|
124
|
+
const alertShown = await alerter.show(notificationText(item, options), app);
|
|
125
|
+
return bannerShown || alertShown;
|
|
126
|
+
};
|
|
127
|
+
}
|
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, openApp, planRoutes, promptSubmitted, releaseOpen, sessionEnded, sessionStarted, turnEnded, } from "./routing.js";
|
|
2
|
+
import { addDeliveries, claimOpen, claimShownNotifications, closeDeliveries, confirmNotified, confirmOpened, confirmWake, isLive, openApp, planRoutes, preferredApp, 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";
|
|
@@ -71,6 +71,10 @@ export class AskInbox {
|
|
|
71
71
|
watchers: this.watchers.size,
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
|
+
/** The app of the session with the most recent typed prompt, for an alert's Open button (Work Item 643dc395). */
|
|
75
|
+
preferredApp() {
|
|
76
|
+
return preferredApp(this.state);
|
|
77
|
+
}
|
|
74
78
|
/**
|
|
75
79
|
* The answer resolves as soon as the change is made, before the save; the queue is held until the save is done. A
|
|
76
80
|
* client that is gone gets nothing and changes nothing: marking an ask shown for a hook that cannot print it would
|
|
@@ -289,6 +293,8 @@ export class AskInbox {
|
|
|
289
293
|
this.watchers.delete(key);
|
|
290
294
|
confirmWake(this.state, key, output, now);
|
|
291
295
|
}
|
|
296
|
+
// What a wake or a turn end just showed gets its one notification: the person may not be looking at that session.
|
|
297
|
+
const shown = claimShownNotifications(this.state, now);
|
|
292
298
|
// Claimed before anything runs, so a delivery is never opened or notified twice.
|
|
293
299
|
const opening = new Set(plan.open.map(item => item.deliveryId));
|
|
294
300
|
claimOpen(this.state, [...opening], now);
|
|
@@ -302,6 +308,7 @@ export class AskInbox {
|
|
|
302
308
|
return {
|
|
303
309
|
notify: notify.filter(id => !opening.has(id)),
|
|
304
310
|
held,
|
|
311
|
+
shown,
|
|
305
312
|
// The notification of a delivery in the open waits for its outcome, which decides the text.
|
|
306
313
|
open: opening.size === 0 ? null : { deliveryIds: [...opening], notifyAfter: notify.filter(id => opening.has(id)) },
|
|
307
314
|
};
|
|
@@ -313,6 +320,8 @@ export class AskInbox {
|
|
|
313
320
|
this.startNotifying(work.notify);
|
|
314
321
|
for (const { deliveryId, app } of work.held)
|
|
315
322
|
this.startNotifying([deliveryId], { heldIn: app });
|
|
323
|
+
for (const { deliveryId, app } of work.shown)
|
|
324
|
+
this.startNotifying([deliveryId], { shownIn: app });
|
|
316
325
|
if (work.open !== null)
|
|
317
326
|
this.startOpening(work.open);
|
|
318
327
|
try {
|
|
@@ -4,16 +4,17 @@ import { execFile as nodeExecFile } from "node:child_process";
|
|
|
4
4
|
* One per delivery, a fixed text per subject type and direction, no buttons. /usr/bin/osascript runs through
|
|
5
5
|
* execFile, never a shell, and the text reaches AppleScript as an argument, never as script source.
|
|
6
6
|
*/
|
|
7
|
-
const
|
|
8
|
-
decision: "AI Gateway: a colleague asked you for a decision.
|
|
9
|
-
review: "AI Gateway: a colleague asked you for a review.
|
|
10
|
-
request: "AI Gateway: a colleague asked you for an answer.
|
|
7
|
+
const ASK_LEAD = {
|
|
8
|
+
decision: "AI Gateway: a colleague asked you for a decision.",
|
|
9
|
+
review: "AI Gateway: a colleague asked you for a review.",
|
|
10
|
+
request: "AI Gateway: a colleague asked you for an answer.",
|
|
11
11
|
};
|
|
12
|
-
const
|
|
13
|
-
decision: "AI Gateway: your decision request has been answered.
|
|
14
|
-
review: "AI Gateway: your review request has been answered.
|
|
15
|
-
request: "AI Gateway: your work request has been answered.
|
|
12
|
+
const ANSWER_LEAD = {
|
|
13
|
+
decision: "AI Gateway: your decision request has been answered.",
|
|
14
|
+
review: "AI Gateway: your review request has been answered.",
|
|
15
|
+
request: "AI Gateway: your work request has been answered.",
|
|
16
16
|
};
|
|
17
|
+
const APP_NAME = { claude: "Claude", codex: "Codex" };
|
|
17
18
|
/** Sent together with an open (open-session design section 5): the session is already there, in this app. */
|
|
18
19
|
const OPENED_TEXT = {
|
|
19
20
|
ask: {
|
|
@@ -43,7 +44,10 @@ export function notificationText(item, options = {}) {
|
|
|
43
44
|
return HELD_TEXT[options.heldIn];
|
|
44
45
|
if (options.openedApp !== undefined)
|
|
45
46
|
return OPENED_TEXT[item.direction][options.openedApp];
|
|
46
|
-
|
|
47
|
+
const lead = (item.direction === "ask" ? ASK_LEAD : ANSWER_LEAD)[item.subjectType];
|
|
48
|
+
if (options.shownIn !== undefined)
|
|
49
|
+
return `${lead} It is in your ${APP_NAME[options.shownIn]} session.`;
|
|
50
|
+
return `${lead} ${item.direction === "ask" ? "Open Claude or ChatGPT to see it." : "Open Claude or ChatGPT to continue."}`;
|
|
47
51
|
}
|
|
48
52
|
/** The arguments osascript gets: the fixed script, then the text as its only argument. */
|
|
49
53
|
export function notificationArguments(text) {
|
package/dist/src/asks/routing.js
CHANGED
|
@@ -357,6 +357,26 @@ export function confirmNotified(state, deliveryId, now, shown) {
|
|
|
357
357
|
item.outbox.push({ event: "shown", via: "notification" });
|
|
358
358
|
}
|
|
359
359
|
}
|
|
360
|
+
/** A delivery shown this long ago or longer gets no notification for it, so a just-upgraded runner does not announce old ones. */
|
|
361
|
+
export const SHOWN_NOTIFY_WINDOW_MS = 2 * 60 * 1000;
|
|
362
|
+
/**
|
|
363
|
+
* Deliveries the runner put into a session on its own, by rewake or stop, that have had no notification yet (Work Item
|
|
364
|
+
* 643dc395, B). Each is claimed (notifiedAt), so it is never notified twice, and returned with its session's app. A
|
|
365
|
+
* delivery shown because the person typed, or whose session is gone, is left alone.
|
|
366
|
+
*/
|
|
367
|
+
export function claimShownNotifications(state, now) {
|
|
368
|
+
const claimed = [];
|
|
369
|
+
for (const item of itemsWhere(state, item => item.phase === "shown" && (item.shownVia === "rewake" || item.shownVia === "stop") && item.notifiedAt === null)) {
|
|
370
|
+
if (item.shownAt === null || now - item.shownAt > SHOWN_NOTIFY_WINDOW_MS)
|
|
371
|
+
continue;
|
|
372
|
+
const session = item.sessionKey === null ? undefined : state.sessions[item.sessionKey];
|
|
373
|
+
if (session === undefined)
|
|
374
|
+
continue;
|
|
375
|
+
item.notifiedAt = now;
|
|
376
|
+
claimed.push({ deliveryId: item.deliveryId, app: session.app });
|
|
377
|
+
}
|
|
378
|
+
return claimed;
|
|
379
|
+
}
|
|
360
380
|
/** Claims one open for these deliveries before it runs, so none is opened twice. */
|
|
361
381
|
export function claimOpen(state, deliveryIds, now) {
|
|
362
382
|
for (const id of deliveryIds) {
|
package/dist/src/asks/runtime.js
CHANGED
|
@@ -4,6 +4,7 @@ import { AskStore } from "./store.js";
|
|
|
4
4
|
import { refreshShim } from "./shim.js";
|
|
5
5
|
import { installedApps } from "./integrations.js";
|
|
6
6
|
import { createNotifier } from "./notifier.js";
|
|
7
|
+
import { createAlerter, withAlerts } from "./alerter.js";
|
|
7
8
|
import { createPresenceReader } from "./presence.js";
|
|
8
9
|
import { createSessionOpener } from "./opener.js";
|
|
9
10
|
import { ASK_INBOX_VERSION } from "./types.js";
|
|
@@ -31,6 +32,9 @@ export class AskRuntime {
|
|
|
31
32
|
deps;
|
|
32
33
|
controller = new AbortController();
|
|
33
34
|
inbox;
|
|
35
|
+
alerter;
|
|
36
|
+
/** The last sync's ask_alert (Work Item 643dc395); off until a sync says otherwise. */
|
|
37
|
+
alertsOn = false;
|
|
34
38
|
hub;
|
|
35
39
|
started = false;
|
|
36
40
|
/** The one start of this run, kept so advertisement() can wait for it; null until start() is called. */
|
|
@@ -39,8 +43,10 @@ export class AskRuntime {
|
|
|
39
43
|
flushAgain = false;
|
|
40
44
|
constructor(deps) {
|
|
41
45
|
this.deps = deps;
|
|
46
|
+
this.alerter = deps.alerter ?? createAlerter({ log: event => this.log(event) });
|
|
47
|
+
const banner = deps.notify ?? createNotifier({ log: event => this.log(event) });
|
|
42
48
|
this.inbox = new AskInbox({ store: new AskStore(deps.stateRoot), now: deps.now,
|
|
43
|
-
notify:
|
|
49
|
+
notify: withAlerts(banner, this.alerter, { alertsOn: () => this.alertsOn, fallbackApp: () => this.inbox.preferredApp() ?? "claude" }),
|
|
44
50
|
presence: deps.presence ?? createPresenceReader({ log: event => this.log(event) }),
|
|
45
51
|
openSession: deps.openSession ?? createSessionOpener({ home: deps.env.HOME ?? "" }),
|
|
46
52
|
presenceRecheckMs: deps.presenceRecheckMs,
|
|
@@ -86,6 +92,7 @@ export class AskRuntime {
|
|
|
86
92
|
this.controller.abort();
|
|
87
93
|
this.inbox.stopTimers();
|
|
88
94
|
this.inbox.releaseAll("shutdown");
|
|
95
|
+
this.alerter.stop();
|
|
89
96
|
try {
|
|
90
97
|
await this.hub.stop();
|
|
91
98
|
}
|
|
@@ -111,12 +118,19 @@ export class AskRuntime {
|
|
|
111
118
|
* therefore means asks are off, and every stored delivery ends here with no report: the Gateway's fallback owns them.
|
|
112
119
|
* A result with the key present but empty changes nothing.
|
|
113
120
|
*
|
|
121
|
+
* A sync result also carries askAlert (Work Item 643dc395): whether notifications are alerts until the next sync. The
|
|
122
|
+
* session-open wait result has no askAlert and leaves the last value. Asks off means alerts off.
|
|
123
|
+
*
|
|
114
124
|
* Resolves false when the result's closures could not be handled (the hub is down, or closing failed), so the push
|
|
115
125
|
* transport does not acknowledge them and the Gateway lists them again (Work Item 25d8ea74).
|
|
116
126
|
*/
|
|
117
127
|
async handleWait(result) {
|
|
118
128
|
if (!this.started)
|
|
119
129
|
return false;
|
|
130
|
+
if (result.askAlert !== undefined)
|
|
131
|
+
this.alertsOn = result.askAlert;
|
|
132
|
+
if (result.askDeliveries === undefined)
|
|
133
|
+
this.alertsOn = false;
|
|
120
134
|
let closuresHandled = true;
|
|
121
135
|
try {
|
|
122
136
|
if (result.askDeliveries === undefined) {
|
|
@@ -75,7 +75,9 @@ export class ExecutionCoordinator {
|
|
|
75
75
|
let localFixPublication;
|
|
76
76
|
const isLocalFix = job.taskPackage.local_fix_scope !== undefined;
|
|
77
77
|
const isP1 = hasP1(job.taskPackage);
|
|
78
|
+
const isP2 = isP1 && job.taskPackage.verification?.gateway_verification !== undefined;
|
|
78
79
|
let p1Result;
|
|
80
|
+
let p2Handoff;
|
|
79
81
|
const p1ReceiptIds = [];
|
|
80
82
|
const lifecycle = { frozen: false, current: true };
|
|
81
83
|
const deadline = Date.now() + (this.deps.preparationTimeoutMs ?? 900_000);
|
|
@@ -92,6 +94,8 @@ export class ExecutionCoordinator {
|
|
|
92
94
|
throw Error('P1_HOST_REQUIRED');
|
|
93
95
|
this.deps.p1Host.assertEligible(job);
|
|
94
96
|
}
|
|
97
|
+
if (isP2 && !this.deps.gateway.stageGatewayVerification)
|
|
98
|
+
throw Error('P2_HOST_TRANSPORT_REQUIRED');
|
|
95
99
|
if (isLocalFix && (!this.deps.localFixHost || pkg.repository?.access !== "write"))
|
|
96
100
|
throw new LocalFixHostRequired();
|
|
97
101
|
const repository = pkg.repository;
|
|
@@ -500,7 +504,9 @@ export class ExecutionCoordinator {
|
|
|
500
504
|
await freezeOwnedModel();
|
|
501
505
|
await readControls();
|
|
502
506
|
executionStage = 'p1_verify';
|
|
503
|
-
p1Result = await publicationStep(() => this.deps.p1Host.verify({ job, checkoutPath: workspace.path, bindingId: binding.bindingId, signal: verificationAbort.signal, current, freezeOwnedModel, assertModelFrozen
|
|
507
|
+
p1Result = await publicationStep(() => this.deps.p1Host.verify({ job, checkoutPath: workspace.path, bindingId: binding.bindingId, signal: verificationAbort.signal, current, freezeOwnedModel, assertModelFrozen,
|
|
508
|
+
exportCandidate: isP2 ? async (handoff) => { await readControls(); await assertModelFrozen(); if (p2Handoff)
|
|
509
|
+
throw Error('P2_DUPLICATE_HANDOFF'); p2Handoff = structuredClone(handoff); } : undefined }));
|
|
504
510
|
executionStage = 'p1_receipts';
|
|
505
511
|
await readControls();
|
|
506
512
|
await assertModelFrozen();
|
|
@@ -514,6 +520,29 @@ export class ExecutionCoordinator {
|
|
|
514
520
|
const id = await publicationStep(() => append({ eventType: 'test_result', summary: 'runner command observed', payload: { observer: 'runner', runner_receipt: receipt } }, true));
|
|
515
521
|
p1ReceiptIds.push(id);
|
|
516
522
|
}
|
|
523
|
+
if (isP2 && p1Result.receipts.length > 0 && p1Result.receipts.every(r => r.status === 'passed' && r.exit_code === 0)) {
|
|
524
|
+
if (!p2Handoff || p2Handoff.snapshot_fingerprint !== p1Result.snapshot_fingerprint || p2Handoff.candidate_commit_sha !== p1Result.candidate_commit_sha)
|
|
525
|
+
throw Error('P2_SEALED_HANDOFF_REQUIRED');
|
|
526
|
+
await readControls();
|
|
527
|
+
await assertModelFrozen();
|
|
528
|
+
try {
|
|
529
|
+
const result = await publicationStep(() => this.deps.gateway.stageGatewayVerification({ job, bindingId: binding.bindingId, handoff: p2Handoff, receiptEventIds: [...p1ReceiptIds], securityReceipt: adapter.securityReceipt?.() }));
|
|
530
|
+
if (typeof result.publication_id !== 'string' || !result.publication_id || typeof result.status !== 'string' || !/^p2_[a-z_]+$/.test(result.status))
|
|
531
|
+
throw Error('P2_STAGE_UNCONFIRMED');
|
|
532
|
+
await readControls();
|
|
533
|
+
await assertModelFrozen();
|
|
534
|
+
await append({ eventType: 'progress', summary: 'Gateway shadow verification recorded', payload: { gateway_verification: { publication_id: result.publication_id, status: result.status } } });
|
|
535
|
+
}
|
|
536
|
+
catch {
|
|
537
|
+
// An uncertain transport outcome is not a failing P1 command or permission to retry a POST.
|
|
538
|
+
await readControls();
|
|
539
|
+
await assertModelFrozen();
|
|
540
|
+
await append({ eventType: 'progress', summary: 'Gateway shadow stage outcome unconfirmed', payload: { reason: 'P2_STAGE_UNCONFIRMED' } });
|
|
541
|
+
}
|
|
542
|
+
finally {
|
|
543
|
+
p2Handoff = undefined;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
517
546
|
executionStage = 'event_stream';
|
|
518
547
|
}
|
|
519
548
|
if (event.eventType === "completed" && fixSession) {
|
|
@@ -103,6 +103,7 @@ export async function buildDaemon(input) {
|
|
|
103
103
|
reportPreparationFailure: (diagnostic) => { process.stderr.write(`${JSON.stringify({ event: "preparation_failed", ...diagnostic })}\n`); },
|
|
104
104
|
terminalEvidenceCollector: buildTerminalEvidenceCollector(),
|
|
105
105
|
gateway: {
|
|
106
|
+
stageGatewayVerification: async (input) => (await gateway.stageGatewayVerification({ jobId: input.job.id, generation: input.job.generation, leaseToken: input.job.leaseToken, launchGrant: input.job.launchGrant, bindingId: input.bindingId, handoff: input.handoff, receiptEventIds: input.receiptEventIds, securityReceipt: input.securityReceipt })).result,
|
|
106
107
|
renewJob: (job, signal) => gateway.heartbeatLocalJob({ jobId: job.id, generation: job.generation, leaseToken: job.leaseToken }, signal),
|
|
107
108
|
confirmJobLease,
|
|
108
109
|
shadow: (job, signal) => gateway.localJobShadow({ workLoopId: job.workLoopId,
|
|
@@ -232,7 +233,7 @@ export async function buildDaemon(input) {
|
|
|
232
233
|
return {
|
|
233
234
|
deviceFingerprint: createHash("sha256").update(`${process.getuid?.() ?? 0}|${runnerDisplayName()}|${input.gatewayMcpUrl}`).digest("hex"),
|
|
234
235
|
displayName: runnerDisplayName(), packageVersion: packageVersion(),
|
|
235
|
-
capabilities: acceptingJobs(localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationMode), poller.accepting),
|
|
236
|
+
capabilities: acceptingJobs(localRunnerCapabilities(probes, !!localFixHost, localFixHost?.verificationModes ?? localFixHost?.verificationMode), poller.accepting),
|
|
236
237
|
mode: "full",
|
|
237
238
|
...(sessionOpen ? { sessionOpen: { ...sessionOpen, ...(askInbox ? { ask_inbox: askInbox } : {}) } } : {}),
|
|
238
239
|
...(guidance ? { guidance } : {}),
|
|
@@ -354,15 +355,16 @@ export async function reconcilePreparationState(previous, deps) {
|
|
|
354
355
|
}
|
|
355
356
|
/** Vendor capability text cannot claim a host-owned publication facility. */
|
|
356
357
|
export function localRunnerCapabilities(probes, trustedLocalFixHost = false, verificationMode) {
|
|
358
|
+
const modes = verificationMode === undefined ? [] : typeof verificationMode === 'string' ? [verificationMode] : verificationMode;
|
|
357
359
|
return probes.map(probe => {
|
|
358
360
|
const status = adapterStatus(probe);
|
|
359
361
|
const authenticated = status.authenticated;
|
|
360
362
|
const capabilities = probe.capabilities.filter(value => value !== 'local_fix_publication_v1' && value !== 'local_fix_gateway_ci_v1' && value !== 'local_fix_gateway_publish_v1');
|
|
361
363
|
if (trustedLocalFixHost && authenticated && isLocalFixAdapter(probe.id)) {
|
|
362
364
|
capabilities.push('local_fix_publication_v1');
|
|
363
|
-
if (
|
|
365
|
+
if (modes.includes('gateway_ci_v1'))
|
|
364
366
|
capabilities.push('local_fix_gateway_ci_v1');
|
|
365
|
-
if (
|
|
367
|
+
if (modes.includes('gateway_publish_v1'))
|
|
366
368
|
capabilities.push('local_fix_gateway_publish_v1');
|
|
367
369
|
}
|
|
368
370
|
return { adapter: probe.id, version: probe.version, ...status, capabilities };
|
|
@@ -35,6 +35,7 @@ export function parseRunnerSync(result, runnerId) {
|
|
|
35
35
|
...("guidance_policy" in body ? { guidancePolicy: body.guidance_policy } : {}),
|
|
36
36
|
...(typeof body.guidance_policy_version === "string" ? { guidancePolicyVersion: body.guidance_policy_version } : {}),
|
|
37
37
|
...("claude_release" in body ? { claudeRelease: parseClaudeSyncRelease(body.claude_release) } : {}),
|
|
38
|
+
...(body.ask_alert === true ? { askAlert: true } : {}),
|
|
38
39
|
jobAvailable: body.job_available === true,
|
|
39
40
|
push: parsePush(body.push, runnerId),
|
|
40
41
|
syncSeconds: clampSeconds(body.sync_seconds, 15, 120, 30),
|
|
@@ -731,6 +732,14 @@ export class McpGatewayClient {
|
|
|
731
732
|
async localPublicationContext(input) {
|
|
732
733
|
return this.callTool('local_agent_publication_context', this.localPublicationFence(input));
|
|
733
734
|
}
|
|
735
|
+
async stageGatewayVerification(input) {
|
|
736
|
+
const security = { ...input.securityReceipt };
|
|
737
|
+
if (security.transport === 'official_exec_v1' && ['cli-exec-job-security-receipt-v2', 'cli-exec-job-security-receipt-v3'].includes(String(security.receipt_version))) {
|
|
738
|
+
delete security.preparation_timing;
|
|
739
|
+
delete security.launch_process;
|
|
740
|
+
}
|
|
741
|
+
return this.callTool('local_agent_server_publication', { ...this.localPublicationFence(input), operation: 'verification_stage', handoff: input.handoff, receipt_event_ids: input.receiptEventIds, security_receipt: security });
|
|
742
|
+
}
|
|
734
743
|
async executeServerCiPublication(input) {
|
|
735
744
|
const body = { ...this.localPublicationFence(input), operation: input.operation };
|
|
736
745
|
if (input.operation === 'stage') {
|
|
@@ -29,6 +29,18 @@ async function readPrivateJson(file) {
|
|
|
29
29
|
await f.close();
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
/** Serves each job with the registration of the mode frozen in its own Fix scope. */
|
|
33
|
+
function multiModeHost(hosts) {
|
|
34
|
+
const verificationModes = hosts.map(h => { if (h.verificationMode === undefined)
|
|
35
|
+
throw denied(); return h.verificationMode; });
|
|
36
|
+
return { requiresOwnedFreeze: true, verificationModes, prepare: async (input) => {
|
|
37
|
+
const mode = input.job.taskPackage?.local_fix_scope?.verification_mode;
|
|
38
|
+
const host = hosts.find(h => h.verificationMode === mode);
|
|
39
|
+
if (!host)
|
|
40
|
+
throw denied();
|
|
41
|
+
return host.prepare(input);
|
|
42
|
+
} };
|
|
43
|
+
}
|
|
32
44
|
/** No installation file is created here. A missing file preserves investigation
|
|
33
45
|
* behavior. Explicit approved installation is owner-only; credentials are read
|
|
34
46
|
* only for an exact post-freeze Git operation, never during daemon discovery. */
|
|
@@ -49,19 +61,40 @@ export async function loadInstalledLocalFixHost(input) {
|
|
|
49
61
|
const raw = await readPrivateJson(file);
|
|
50
62
|
if (raw.schema === 'local-fix-host-installation-v2') {
|
|
51
63
|
const config = raw;
|
|
52
|
-
if (config.enabled !== true ||
|
|
53
|
-
throw denied();
|
|
54
|
-
assertServerCiRegistration(config.registration);
|
|
55
|
-
if (config.verification_mode !== (config.registration.verification_mode ?? 'gateway_ci_v1'))
|
|
64
|
+
if (config.enabled !== true || config.runnerId !== input.runnerId)
|
|
56
65
|
throw denied();
|
|
66
|
+
let registrations;
|
|
67
|
+
if (config.registrations !== undefined) {
|
|
68
|
+
// One registration per mode, for one project and one runtime release; never mixed with the single form.
|
|
69
|
+
if (config.registration !== undefined || config.verification_mode !== undefined || !Array.isArray(config.registrations) || config.registrations.length < 1 || config.registrations.length > 2)
|
|
70
|
+
throw denied();
|
|
71
|
+
for (const r of config.registrations) {
|
|
72
|
+
assertServerCiRegistration(r);
|
|
73
|
+
if (!['gateway_ci_v1', 'gateway_publish_v1'].includes(r.verification_mode))
|
|
74
|
+
throw denied();
|
|
75
|
+
}
|
|
76
|
+
const [first] = config.registrations;
|
|
77
|
+
if (new Set(config.registrations.map(r => r.verification_mode)).size !== config.registrations.length
|
|
78
|
+
|| config.registrations.some(r => r.project !== first.project || protocolHash(r.receipt) !== protocolHash(first.receipt)))
|
|
79
|
+
throw denied();
|
|
80
|
+
registrations = config.registrations;
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
if (!config.registration || !['gateway_ci_v1', 'gateway_publish_v1'].includes(config.verification_mode))
|
|
84
|
+
throw denied();
|
|
85
|
+
assertServerCiRegistration(config.registration);
|
|
86
|
+
if (config.verification_mode !== (config.registration.verification_mode ?? 'gateway_ci_v1'))
|
|
87
|
+
throw denied();
|
|
88
|
+
registrations = [config.registration];
|
|
89
|
+
}
|
|
57
90
|
const packageRoot = await realpath(fileURLToPath(new URL('../../../', import.meta.url)));
|
|
58
|
-
const assertReleaseCurrent = () => verifyLocalFixRuntime(packageRoot, config.runtimeFiles,
|
|
91
|
+
const assertReleaseCurrent = () => verifyLocalFixRuntime(packageRoot, config.runtimeFiles, registrations[0].receipt);
|
|
59
92
|
await assertReleaseCurrent();
|
|
60
93
|
await assertPinnedGit(config.git);
|
|
61
94
|
const privateRoot = path.join(input.stateRoot, 'local-fix-publication'), s = await lstat(privateRoot);
|
|
62
95
|
if (!s.isDirectory() || s.isSymbolicLink() || (s.mode & 0o077) !== 0 || (process.getuid && s.uid !== process.getuid()) || await realpath(privateRoot) !== privateRoot)
|
|
63
96
|
throw denied();
|
|
64
|
-
|
|
97
|
+
const hosts = registrations.map(registration => createServerCiLocalFixHost({ runnerId: input.runnerId, privateRoot, git: config.git, registration, gateway: input.gateway, assertReleaseCurrent, canonicalApprovalId: async (publicationId) => {
|
|
65
98
|
let approvals;
|
|
66
99
|
try {
|
|
67
100
|
approvals = await readPrivateJson(path.join(input.stateRoot, 'local-fix-pr-approvals.json'));
|
|
@@ -75,7 +108,8 @@ export async function loadInstalledLocalFixHost(input) {
|
|
|
75
108
|
if (id !== undefined && (typeof id !== 'string' || !/^[a-zA-Z0-9-]{1,191}$/.test(id)))
|
|
76
109
|
throw denied();
|
|
77
110
|
return id;
|
|
78
|
-
} });
|
|
111
|
+
} }));
|
|
112
|
+
return config.registrations === undefined ? hosts[0] : multiModeHost(hosts);
|
|
79
113
|
}
|
|
80
114
|
const config = raw;
|
|
81
115
|
if (config.schema !== 'local-fix-host-installation-v1' || config.enabled !== true || config.runnerId !== input.runnerId || !config.registration || config.registration.review.manifestFingerprint !== protocolHash(config.registration.manifest))
|
|
@@ -92,6 +92,7 @@ export function createSyncDispatcher(deps) {
|
|
|
92
92
|
const handled = await deps.asks.handleWait({
|
|
93
93
|
...(result.askDeliveries !== undefined ? { askDeliveries: result.askDeliveries } : {}),
|
|
94
94
|
...(result.closedDeliveryIds !== undefined ? { closedDeliveryIds: result.closedDeliveryIds } : {}),
|
|
95
|
+
askAlert: result.askAlert === true,
|
|
95
96
|
});
|
|
96
97
|
// Acknowledged only once handled: a failed handleWait leaves them for the Gateway to list again.
|
|
97
98
|
if (handled !== false)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { RunnerObservedVerifier } from './runner-observed-verifier.js';
|
|
1
|
+
import { RunnerObservedVerifier, assertGatewayVerificationScope } from './runner-observed-verifier.js';
|
|
2
2
|
import { isP1WorkingBranch } from '../publication/git-snapshot.js';
|
|
3
3
|
import { protocolHash } from '../publication/candidate.js';
|
|
4
4
|
export class RunnerObservedExecutionHost {
|
|
@@ -13,7 +13,7 @@ export class RunnerObservedExecutionHost {
|
|
|
13
13
|
return { root: input.checkoutPath, privateRoot: this.privateRoot, registration: this.registration, verification: p.verification,
|
|
14
14
|
fence: { binding_id: input.bindingId, job_id: input.job.id, generation: input.job.generation, contract_fingerprint: p.contract_fingerprint, base_commit_sha: r.base_commit_sha, working_branch: r.working_branch },
|
|
15
15
|
allowedPaths: p.allowed_paths, blockedPaths: r.blocked_paths, candidateLimits: p.candidate_limits,
|
|
16
|
-
current: input.current, freezeOwnedModel: input.freezeOwnedModel, assertModelFrozen: input.assertModelFrozen, signal: input.signal };
|
|
16
|
+
current: input.current, freezeOwnedModel: input.freezeOwnedModel, assertModelFrozen: input.assertModelFrozen, signal: input.signal, exportCandidate: input.exportCandidate };
|
|
17
17
|
}
|
|
18
18
|
assertEligible(job) {
|
|
19
19
|
const r = this.registration;
|
|
@@ -33,6 +33,8 @@ export const hasP1 = (p) => {
|
|
|
33
33
|
/** Compare host launch claims to authoritative WEC/bundle fields before start. */
|
|
34
34
|
export function assertP1Bindings(p, canonical, bundle = false) {
|
|
35
35
|
const v = record(p.verification), r = record(p.repository), limits = record(p.candidate_limits);
|
|
36
|
+
if (v.gateway_verification !== undefined)
|
|
37
|
+
assertGatewayVerificationScope(v.gateway_verification, r.key);
|
|
36
38
|
if (p.local_fix_scope !== undefined || p.verification_mode === 'gateway_ci_v1' || p.runtime_adapter !== 'codex_cli' || p.runtime_adapter_version !== '1' || p.runtime_transport !== 'official_exec_v1' || v.schema_version !== 'local-runner-verification-v1' || v.policy_key !== 'local-runner-verified-v1' || v.mode !== 'runner_observed_v1' || v.runtime_adapter !== p.runtime_adapter || v.runtime_transport !== p.runtime_transport || r.access !== 'write' || !isP1WorkingBranch(r.working_branch) || !equal(p.allowed_paths, v.allowed_paths) || !equal(r.allowed_paths, v.allowed_paths) || !Array.isArray(r.blocked_paths))
|
|
37
39
|
throw Error('P1_CONTRACT_REJECTED');
|
|
38
40
|
if (Object.keys(limits).sort().join(',') !== 'max_changed_files,max_diff_bytes,max_file_bytes' || Object.values(limits).some(n => !Number.isSafeInteger(n) || Number(n) < 1))
|
|
@@ -23,6 +23,19 @@ const exactPath = (name) => typeof name === 'string' && /^[A-Za-z0-9_][A-Za-z0-9
|
|
|
23
23
|
const isBlocked = (name, scopes) => scopes.some(p => p === '.env.*' ? name.startsWith('.env.') : p === '**' || name === p || name.startsWith(p.replace(/\/\*\*$/, '') + '/'));
|
|
24
24
|
function keys(object, allowed) { if (!object || typeof object !== 'object' || Array.isArray(object) || Object.keys(object).some(k => !allowed.includes(k)))
|
|
25
25
|
throw denied(); }
|
|
26
|
+
export function assertGatewayVerificationScope(value, repositoryKey) {
|
|
27
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
28
|
+
throw denied();
|
|
29
|
+
const g = value;
|
|
30
|
+
keys(g, ['schema_version', 'intent', 'mode', 'profile_id', 'repository_key', 'profile_definition', 'profile_fingerprint']);
|
|
31
|
+
if (g.schema_version !== 'gateway-verification-scope-v1' || g.intent !== 'verification_only' || g.mode !== 'shadow'
|
|
32
|
+
|| typeof g.profile_id !== 'string' || !g.profile_id || typeof g.repository_key !== 'string' || !g.repository_key
|
|
33
|
+
|| repositoryKey !== undefined && g.repository_key !== repositoryKey || !g.profile_definition || typeof g.profile_definition !== 'object'
|
|
34
|
+
|| protocolHash(g.profile_definition) !== g.profile_fingerprint || g.profile_definition.reviewed !== true
|
|
35
|
+
|| g.profile_definition.ref_prefix !== 'ai-verify/' || g.profile_definition.max_concurrent_runs !== 1
|
|
36
|
+
|| !Array.isArray(g.profile_definition.repository_keys) || !g.profile_definition.repository_keys.includes(g.repository_key))
|
|
37
|
+
throw denied();
|
|
38
|
+
}
|
|
26
39
|
/** Canonical P1 domain. Never uses legacy terminal T/U bytes or diff hashing. */
|
|
27
40
|
export function buildP1ChangedPaths(input, bounds) {
|
|
28
41
|
const entries = [...input.entries].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
@@ -48,7 +61,9 @@ export class RunnerObservedVerifier {
|
|
|
48
61
|
} }
|
|
49
62
|
validate() {
|
|
50
63
|
const o = this.options, v = o.verification, p = v.profile_definition, r = o.registration, f = o.fence;
|
|
51
|
-
keys(v, ['schema_version', 'mode', 'policy_key', 'profile_id', 'profile_fingerprint', 'project_id', 'runtime_adapter', 'runtime_transport', 'profile_definition', 'frozen_criteria_fingerprint', 'frozen_loop_contract', 'commands', 'command_manifest', 'bounds', 'allowed_paths', 'required_commands', 'qa_scenario_ids', 'required_criteria', 'independent_review_required']);
|
|
64
|
+
keys(v, ['schema_version', 'mode', 'policy_key', 'profile_id', 'profile_fingerprint', 'project_id', 'runtime_adapter', 'runtime_transport', 'profile_definition', 'frozen_criteria_fingerprint', 'frozen_loop_contract', 'commands', 'command_manifest', 'bounds', 'allowed_paths', 'required_commands', 'qa_scenario_ids', 'required_criteria', 'independent_review_required', 'gateway_verification']);
|
|
65
|
+
if (v.gateway_verification !== undefined)
|
|
66
|
+
assertGatewayVerificationScope(v.gateway_verification);
|
|
52
67
|
keys(p, ['schema_version', 'reviewed', 'project_ids', 'runtime_adapters', 'runtime_transports', 'allowed_paths', 'bounds', 'commands', 'bindings']);
|
|
53
68
|
keys(v.bounds, Object.keys(ceilings));
|
|
54
69
|
if (!r || !r.review || ![f.binding_id, f.job_id].every(id => typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(id)) || !Number.isSafeInteger(f.generation) || f.generation < 1 || !hex(f.contract_fingerprint, 64) || !hex(f.base_commit_sha, 40) || !isP1WorkingBranch(f.working_branch) || v.schema_version !== 'local-runner-verification-v1' || v.mode !== 'runner_observed_v1' || v.policy_key !== 'local-runner-verified-v1' || !v.profile_id || p.schema_version !== 'local-runner-profile-v1' || p.reviewed !== true || r.profileId !== v.profile_id || r.profileFingerprint !== v.profile_fingerprint || protocolHash(p) !== v.profile_fingerprint || protocolHash(v.frozen_loop_contract) !== v.frozen_criteria_fingerprint)
|
|
@@ -133,7 +148,12 @@ export class RunnerObservedVerifier {
|
|
|
133
148
|
policy.revoke();
|
|
134
149
|
}
|
|
135
150
|
await check();
|
|
136
|
-
|
|
151
|
+
const result = { candidate_commit_sha: candidate.commitSha, snapshot_fingerprint, changed_paths, receipts };
|
|
152
|
+
if (o.exportCandidate && receipts.length > 0 && receipts.every(r => r.status === 'passed' && r.exit_code === 0)) {
|
|
153
|
+
await o.exportCandidate(structuredClone({ schema_version: 'gateway-candidate-handoff-v1', ...result, fence: f, profile_fingerprint: v.profile_fingerprint, frozen_criteria_fingerprint: v.frozen_criteria_fingerprint, tree_fingerprint: sealed.treeFingerprint, diff: observation.diff }));
|
|
154
|
+
await check();
|
|
155
|
+
}
|
|
156
|
+
return result;
|
|
137
157
|
}
|
|
138
158
|
catch {
|
|
139
159
|
throw denied();
|