@khorsheed/dsh-ankh-guard 0.1.0 → 0.2.0
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/CHANGELOG.md +28 -0
- package/README.en.md +75 -29
- package/README.i18n.yaml +2 -2
- package/README.md +74 -29
- package/lib/cli.js +2383 -209
- package/lib/client.js +257 -0
- package/lib/exit-agent.js +5 -2
- package/lib/index.js +722 -38
- package/lib/invariant.js +1 -1
- package/lib/preflight-runner.js +125 -47
- package/lib/processes-BjZgJjQr.js +344 -0
- package/lib/restart-context-D6nISh28.js +1245 -0
- package/lib/restart-context-DUyExi9O.js +1245 -0
- package/lib/{state-Dhx9VG44.js → state-4f7yny39.js} +60 -13
- package/lib/state-CZMypGkB.js +323 -0
- package/lib/test-seam-DnvLWTeO.js +119 -0
- package/lib/test-seam-cli.js +24 -0
- package/lib/test-seam-dwvaKjRp.js +459 -0
- package/lib/test-seam.js +2 -0
- package/lib/types/browser-handoff.d.ts +55 -0
- package/lib/types/browser-handoff.js +489 -0
- package/lib/types/cli.d.ts +34 -4
- package/lib/types/cli.js +1487 -225
- package/lib/types/client/index.d.ts +15 -0
- package/lib/types/client/index.js +264 -0
- package/lib/types/deployment-proof.d.ts +24 -0
- package/lib/types/deployment-proof.js +314 -0
- package/lib/types/exit-agent.js +2 -0
- package/lib/types/git.d.ts +12 -3
- package/lib/types/git.js +69 -7
- package/lib/types/index.d.ts +66 -3
- package/lib/types/index.js +157 -39
- package/lib/types/launch-spec.d.ts +263 -0
- package/lib/types/launch-spec.js +823 -0
- package/lib/types/preflight-runner.d.ts +23 -12
- package/lib/types/preflight-runner.js +152 -57
- package/lib/types/processes.d.ts +38 -6
- package/lib/types/processes.js +236 -10
- package/lib/types/restart-context.d.ts +50 -0
- package/lib/types/restart-context.js +106 -0
- package/lib/types/restart-request.d.ts +32 -0
- package/lib/types/restart-request.js +128 -0
- package/lib/types/state-files.d.ts +30 -0
- package/lib/types/state-files.js +55 -0
- package/lib/types/state.d.ts +29 -2
- package/lib/types/state.js +52 -7
- package/lib/types/temp-artifact.d.ts +15 -0
- package/lib/types/temp-artifact.js +17 -0
- package/lib/types/test-seam-cli.d.ts +3 -0
- package/lib/types/test-seam-cli.js +27 -0
- package/lib/types/test-seam.d.ts +55 -0
- package/lib/types/test-seam.js +112 -0
- package/lib/types/transition.d.ts +118 -0
- package/lib/types/transition.js +717 -0
- package/package.json +29 -9
- package/scripts/dsh-watchdog.sh +1388 -80
- package/scripts/install-launchd.sh +43 -5
- package/scripts/install-systemd.sh +43 -5
- package/scripts/on-install.js +1 -1
- package/skills/dsh-self-restart-guard/SKILL.md +38 -12
- package/lib/processes-hCAmwma-.js +0 -127
- package/lib/restart-context-DmnQXNf-.js +0 -421
package/lib/index.js
CHANGED
|
@@ -1,15 +1,654 @@
|
|
|
1
1
|
import { i as resolveStateDir, r as resolveRepoDir, t as SRC_ARTIFACT_PATTERN } from "./defaults-CduvOcGq.js";
|
|
2
|
-
import { a as interruptedSnapshotFile, b as
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
2
|
+
import { M as currentHead, N as isWorkingTreeClean, P as resetToCheckpoint, S as cutoverBlocksWake, a as interruptedSnapshotFile, b as activeCutover, c as readInstanceLaunch, d as restartContextText, g as writeInterruptedSnapshot, i as continueInterruptedText, j as commitCheckpoint, l as readInterruptedSnapshot, m as writeInstanceLaunch, n as buildLaunchCommand, o as isParkedOnUserInput, r as continueAndReportText, s as pendingRestartRecord, t as acknowledgeRestartRecord, v as writeSkillRegistration, w as readCutoverReceipt } from "./restart-context-DUyExi9O.js";
|
|
3
|
+
import { _ as processIdentity, g as listeningPortsForPid, v as processIdentityMatches } from "./test-seam-dwvaKjRp.js";
|
|
4
|
+
import { f as stateFile, i as setCheckpoint, n as loadState, r as recordCredential, s as verifyCredential, t as clearCredential, u as liveWatchdogPid } from "./state-CZMypGkB.js";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
7
|
+
import { chmodSync, existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
8
|
import { connect } from "node:net";
|
|
7
|
-
import {
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { dirname, join, resolve, sep } from "node:path";
|
|
8
11
|
import { fileURLToPath } from "node:url";
|
|
9
12
|
import z from "@deepseek-ai/schemastery";
|
|
10
13
|
import { createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
11
|
-
import
|
|
14
|
+
import * as agentPresetsHost from "@deepseek-ai/dsh-agent-presets";
|
|
15
|
+
//#region lib/types/browser-handoff.js
|
|
16
|
+
/**
|
|
17
|
+
* Browser handoff bridge for launch cutovers.
|
|
18
|
+
*
|
|
19
|
+
* Old authenticated pages register random per-tab capabilities while the
|
|
20
|
+
* previous process is still the proven listener. Only SHA-256 digests are
|
|
21
|
+
* durable. After the successor proves one stable listener and passes canary,
|
|
22
|
+
* every registered tab may ask the final process for either a plain reload
|
|
23
|
+
* (its cookie is still valid) or that process's authenticated URL. The URL
|
|
24
|
+
* exists only in the response object and the browser's location.replace call:
|
|
25
|
+
* it never enters a state file, receipt, or log.
|
|
26
|
+
*
|
|
27
|
+
* Alongside the cutover channel, a boot-generation channel covers ordinary
|
|
28
|
+
* restarts (unsupervised `restart`/`schedule-exit`, watchdog crash respawns):
|
|
29
|
+
* the serving process's own pid + start token is its boot id, a polling tab
|
|
30
|
+
* carries the boot id it last saw, and a stale id with no active cutover
|
|
31
|
+
* means the process it knew is gone — answer ready/reload. A cutover receipt
|
|
32
|
+
* always wins over the generation check: during a cutover the old tab's known
|
|
33
|
+
* id is already stale, and the receipt protocol owns the pacing.
|
|
34
|
+
*/
|
|
35
|
+
const BROWSER_HANDOFF_ROUTE = "/_ankh-guard/browser-handoff";
|
|
36
|
+
const CAPABILITY_PATTERN = /^[A-Za-z0-9_-]{43}$/;
|
|
37
|
+
const MAX_BODY_BYTES = 4096;
|
|
38
|
+
const MAX_REGISTRATIONS = 64;
|
|
39
|
+
const DEFAULT_LONG_POLL_MS = 25e3;
|
|
40
|
+
const DEFAULT_LONG_POLL_INTERVAL_MS = 200;
|
|
41
|
+
function isObject(value) {
|
|
42
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
function isCapability(value) {
|
|
45
|
+
return typeof value === "string" && CAPABILITY_PATTERN.test(value);
|
|
46
|
+
}
|
|
47
|
+
function capabilitySha256(capability) {
|
|
48
|
+
return createHash("sha256").update(capability).digest("hex");
|
|
49
|
+
}
|
|
50
|
+
function parseMessage(value) {
|
|
51
|
+
if (!isObject(value) || value.version !== 1) return null;
|
|
52
|
+
if (value.operation === "poll" && isCapability(value.capability)) {
|
|
53
|
+
const knownBootId = typeof value.knownBootId === "string" && value.knownBootId.length <= 256 ? value.knownBootId : void 0;
|
|
54
|
+
return {
|
|
55
|
+
version: 1,
|
|
56
|
+
operation: "poll",
|
|
57
|
+
capability: value.capability,
|
|
58
|
+
...knownBootId === void 0 ? {} : { knownBootId }
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
if (value.operation !== "ack" || typeof value.cutoverId !== "string" || value.cutoverId === "") return null;
|
|
62
|
+
if (value.channel !== "original-tab" && value.channel !== "fallback-tab") return null;
|
|
63
|
+
if (value.authentication !== "existing-cookie" && value.authentication !== "launch-url") return null;
|
|
64
|
+
if (value.channel === "original-tab" && !isCapability(value.capability)) return null;
|
|
65
|
+
return {
|
|
66
|
+
version: 1,
|
|
67
|
+
operation: "ack",
|
|
68
|
+
cutoverId: value.cutoverId,
|
|
69
|
+
channel: value.channel,
|
|
70
|
+
authentication: value.authentication,
|
|
71
|
+
...typeof value.capability === "string" ? { capability: value.capability } : {}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
function readJson(file) {
|
|
75
|
+
try {
|
|
76
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function createJsonOnce(file, value) {
|
|
82
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
83
|
+
const tmp = `${file}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
84
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, {
|
|
85
|
+
mode: 384,
|
|
86
|
+
flag: "wx"
|
|
87
|
+
});
|
|
88
|
+
try {
|
|
89
|
+
linkSync(tmp, file);
|
|
90
|
+
try {
|
|
91
|
+
chmodSync(file, 384);
|
|
92
|
+
} catch {}
|
|
93
|
+
return true;
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (error.code === "EEXIST") return false;
|
|
96
|
+
throw error;
|
|
97
|
+
} finally {
|
|
98
|
+
unlinkSync(tmp);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function writeJsonAtomic(file, value) {
|
|
102
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
103
|
+
const tmp = `${file}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
104
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, {
|
|
105
|
+
mode: 384,
|
|
106
|
+
flag: "wx"
|
|
107
|
+
});
|
|
108
|
+
try {
|
|
109
|
+
renameSync(tmp, file);
|
|
110
|
+
try {
|
|
111
|
+
chmodSync(file, 384);
|
|
112
|
+
} catch {}
|
|
113
|
+
} finally {
|
|
114
|
+
try {
|
|
115
|
+
unlinkSync(tmp);
|
|
116
|
+
} catch {}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function validRegistration(value) {
|
|
120
|
+
return isObject(value) && typeof value.authority === "string" && value.authority !== "" && typeof value.capabilitySha256 === "string" && /^[a-f0-9]{64}$/.test(value.capabilitySha256) && typeof value.armedAt === "number" && Number.isFinite(value.armedAt) && (value.acknowledgedAt === void 0 || typeof value.acknowledgedAt === "number" && Number.isFinite(value.acknowledgedAt));
|
|
121
|
+
}
|
|
122
|
+
function readBrowserHandoffRequest(stateDir) {
|
|
123
|
+
const request = readJson(stateFile(stateDir, "browserHandoffRequest"));
|
|
124
|
+
if (request?.version !== 2 || typeof request.cutoverId !== "string" || !Array.isArray(request.registrations) || request.registrations.length === 0 || request.registrations.length > MAX_REGISTRATIONS || !request.registrations.every(validRegistration)) return null;
|
|
125
|
+
return request;
|
|
126
|
+
}
|
|
127
|
+
function registerBrowserTab(stateDir, cutoverId, registration) {
|
|
128
|
+
const existing = readBrowserHandoffRequest(stateDir);
|
|
129
|
+
const registrations = existing?.cutoverId === cutoverId ? [...existing.registrations] : [];
|
|
130
|
+
if (!registrations.some((item) => item.authority === registration.authority && item.capabilitySha256 === registration.capabilitySha256)) {
|
|
131
|
+
if (registrations.length >= MAX_REGISTRATIONS) return null;
|
|
132
|
+
registrations.push(registration);
|
|
133
|
+
}
|
|
134
|
+
const request = {
|
|
135
|
+
version: 2,
|
|
136
|
+
cutoverId,
|
|
137
|
+
registrations
|
|
138
|
+
};
|
|
139
|
+
writeJsonAtomic(stateFile(stateDir, "browserHandoffRequest"), request);
|
|
140
|
+
return request;
|
|
141
|
+
}
|
|
142
|
+
function registrationMatches(request, cutoverId, authority, digest) {
|
|
143
|
+
return request?.cutoverId === cutoverId && request.registrations.some((item) => item.authority === authority && item.capabilitySha256 === digest);
|
|
144
|
+
}
|
|
145
|
+
function matchingRegistration(request, cutoverId, authority, digest) {
|
|
146
|
+
if (request?.cutoverId !== cutoverId) return void 0;
|
|
147
|
+
return request.registrations.find((item) => item.authority === authority && item.capabilitySha256 === digest);
|
|
148
|
+
}
|
|
149
|
+
function acknowledgeBrowserTab(stateDir, cutoverId, authority, digest, acknowledgedAt) {
|
|
150
|
+
const request = readBrowserHandoffRequest(stateDir);
|
|
151
|
+
const registration = matchingRegistration(request, cutoverId, authority, digest);
|
|
152
|
+
if (request === null || registration === void 0) return false;
|
|
153
|
+
registration.acknowledgedAt = acknowledgedAt;
|
|
154
|
+
writeJsonAtomic(stateFile(stateDir, "browserHandoffRequest"), request);
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
157
|
+
function readBrowserHandoffAcknowledgement(stateDir) {
|
|
158
|
+
const ack = readJson(stateFile(stateDir, "browserHandoffAck"));
|
|
159
|
+
if (ack?.version !== 1 || typeof ack.cutoverId !== "string" || ack.role !== "target" && ack.role !== "previous" || typeof ack.authority !== "string" || ack.authority === "" || !Number.isInteger(ack.listenerPid) || (ack.listenerPid ?? 0) <= 0 || typeof ack.listenerStartToken !== "string" || ack.listenerStartToken === "" || ack.channel !== "original-tab" && ack.channel !== "fallback-tab" || ack.authentication !== "existing-cookie" && ack.authentication !== "launch-url" || typeof ack.acknowledgedAt !== "number") return null;
|
|
160
|
+
return ack;
|
|
161
|
+
}
|
|
162
|
+
function authorityOf(req) {
|
|
163
|
+
const host = req.headers.host;
|
|
164
|
+
const origin = req.headers.origin;
|
|
165
|
+
if (typeof host !== "string" || typeof origin !== "string") return null;
|
|
166
|
+
try {
|
|
167
|
+
const parsed = new URL(origin);
|
|
168
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:" || parsed.host !== host || parsed.origin !== origin) return null;
|
|
169
|
+
return {
|
|
170
|
+
authority: parsed.host,
|
|
171
|
+
origin: parsed.origin
|
|
172
|
+
};
|
|
173
|
+
} catch {
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
function ownershipMatches(ownership, pid, identityMatches) {
|
|
178
|
+
return ownership !== void 0 && ownership.listenerPid === pid && identityMatches({
|
|
179
|
+
pid,
|
|
180
|
+
startToken: ownership.listenerStartToken
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
function json(res, status, body) {
|
|
184
|
+
res.writeHead(status, {
|
|
185
|
+
"cache-control": "no-store",
|
|
186
|
+
"content-type": "application/json; charset=utf-8",
|
|
187
|
+
"referrer-policy": "no-referrer",
|
|
188
|
+
"x-content-type-options": "nosniff"
|
|
189
|
+
});
|
|
190
|
+
res.end(`${JSON.stringify(body)}\n`);
|
|
191
|
+
}
|
|
192
|
+
async function bodyOf(req) {
|
|
193
|
+
let size = 0;
|
|
194
|
+
const chunks = [];
|
|
195
|
+
for await (const chunk of req) {
|
|
196
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
197
|
+
size += bytes.byteLength;
|
|
198
|
+
if (size > MAX_BODY_BYTES) throw new Error("body too large");
|
|
199
|
+
chunks.push(bytes);
|
|
200
|
+
}
|
|
201
|
+
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
202
|
+
}
|
|
203
|
+
function currentReadyOwnership(stateDir, pid, identityMatches) {
|
|
204
|
+
const receipt = readCutoverReceipt(stateDir);
|
|
205
|
+
if (receipt === null || receipt.phase === "awaiting-user" || receipt.readiness?.retryCount !== 0) return null;
|
|
206
|
+
const role = receipt.readiness.role;
|
|
207
|
+
const ownership = role === "target" ? receipt.ownership.target : receipt.ownership.restored;
|
|
208
|
+
const canarySettled = role === "target" ? receipt.targetValidation?.canary?.outcome === "pass" : receipt.recovery.validation?.canary !== void 0;
|
|
209
|
+
if (!ownershipMatches(ownership, pid, identityMatches) || !canarySettled || receipt.readiness.listenerPid !== ownership.listenerPid || receipt.readiness.childPid !== ownership.childPid) return null;
|
|
210
|
+
return {
|
|
211
|
+
cutoverId: receipt.id,
|
|
212
|
+
role,
|
|
213
|
+
ownership,
|
|
214
|
+
protected: receipt.authentication.launchUrlObserved === true
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
function delay(ms) {
|
|
218
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
219
|
+
}
|
|
220
|
+
async function waitForPollState(ready, waitMs, intervalMs) {
|
|
221
|
+
if (ready() || waitMs <= 0) return;
|
|
222
|
+
const deadline = Date.now() + waitMs;
|
|
223
|
+
while (Date.now() < deadline) {
|
|
224
|
+
await delay(Math.min(intervalMs, Math.max(1, deadline - Date.now())));
|
|
225
|
+
if (ready()) return;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
function authenticationState(connection, req) {
|
|
229
|
+
if (connection?.requestRejection === void 0) return "legacy-public";
|
|
230
|
+
const rejection = connection.requestRejection(req);
|
|
231
|
+
if (rejection === void 0) return "authenticated";
|
|
232
|
+
return rejection === 403 ? "forbidden" : "unauthenticated";
|
|
233
|
+
}
|
|
234
|
+
/** Create the exact HTTP handler; dependencies are injectable for deterministic protocol tests. */
|
|
235
|
+
function createBrowserHandoffHandler(dependencies) {
|
|
236
|
+
const pid = dependencies.pid ?? process.pid;
|
|
237
|
+
const now = dependencies.now ?? Date.now;
|
|
238
|
+
const identityMatches = dependencies.identityMatches ?? processIdentityMatches;
|
|
239
|
+
const identityProvider = dependencies.identityProvider ?? processIdentity;
|
|
240
|
+
const longPollMs = dependencies.longPollMs ?? DEFAULT_LONG_POLL_MS;
|
|
241
|
+
const longPollIntervalMs = dependencies.longPollIntervalMs ?? DEFAULT_LONG_POLL_INTERVAL_MS;
|
|
242
|
+
let bootIdCache;
|
|
243
|
+
const thisBootId = () => {
|
|
244
|
+
if (bootIdCache === void 0) {
|
|
245
|
+
const identity = identityProvider(pid);
|
|
246
|
+
bootIdCache = identity === null ? null : `${String(identity.pid)}:${identity.startToken}`;
|
|
247
|
+
}
|
|
248
|
+
return bootIdCache ?? void 0;
|
|
249
|
+
};
|
|
250
|
+
return async (req, res) => {
|
|
251
|
+
const connection = dependencies.connectionProvider?.() ?? dependencies.connection;
|
|
252
|
+
if (req.method !== "POST" || req.headers["content-type"]?.split(";", 1)[0]?.trim().toLowerCase() !== "application/json") {
|
|
253
|
+
json(res, 404, { state: "unavailable" });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const requestAuthority = authorityOf(req);
|
|
257
|
+
if (requestAuthority === null) {
|
|
258
|
+
json(res, 403, { state: "forbidden" });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
let message;
|
|
262
|
+
try {
|
|
263
|
+
message = parseMessage(await bodyOf(req));
|
|
264
|
+
} catch {
|
|
265
|
+
message = null;
|
|
266
|
+
}
|
|
267
|
+
if (message === null) {
|
|
268
|
+
json(res, 400, { state: "invalid" });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (message.operation === "poll") {
|
|
272
|
+
const digest = capabilitySha256(message.capability);
|
|
273
|
+
const bootId = thisBootId();
|
|
274
|
+
const respond = (status, body) => {
|
|
275
|
+
json(res, status, bootId === void 0 ? body : {
|
|
276
|
+
...body,
|
|
277
|
+
bootId
|
|
278
|
+
});
|
|
279
|
+
};
|
|
280
|
+
if (bootId !== void 0 && message.knownBootId !== void 0 && message.knownBootId !== bootId && activeCutover(dependencies.stateDir) === null) {
|
|
281
|
+
respond(200, {
|
|
282
|
+
state: "ready",
|
|
283
|
+
action: "reload",
|
|
284
|
+
authentication: "existing-cookie"
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const pollHasState = () => {
|
|
289
|
+
const active = activeCutover(dependencies.stateDir);
|
|
290
|
+
if (active !== null) {
|
|
291
|
+
if (active.receipt.phase === "awaiting-user" || active.receipt.browserHandoff.status === "off") return true;
|
|
292
|
+
if (ownershipMatches(active.receipt.ownership.previous, pid, identityMatches)) return !registrationMatches(readBrowserHandoffRequest(dependencies.stateDir), active.receipt.id, requestAuthority.authority, digest);
|
|
293
|
+
return currentReadyOwnership(dependencies.stateDir, pid, identityMatches) !== null;
|
|
294
|
+
}
|
|
295
|
+
const ready = currentReadyOwnership(dependencies.stateDir, pid, identityMatches);
|
|
296
|
+
const registration = readBrowserHandoffRequest(dependencies.stateDir);
|
|
297
|
+
return ready !== null && matchingRegistration(registration, ready.cutoverId, requestAuthority.authority, digest)?.acknowledgedAt === void 0;
|
|
298
|
+
};
|
|
299
|
+
await waitForPollState(pollHasState, longPollMs, longPollIntervalMs);
|
|
300
|
+
const active = activeCutover(dependencies.stateDir);
|
|
301
|
+
const ready = currentReadyOwnership(dependencies.stateDir, pid, identityMatches);
|
|
302
|
+
const registration = readBrowserHandoffRequest(dependencies.stateDir);
|
|
303
|
+
const registered = ready === null ? void 0 : matchingRegistration(registration, ready.cutoverId, requestAuthority.authority, digest);
|
|
304
|
+
if (registered?.acknowledgedAt !== void 0 || active === null && (ready === null || registered === void 0) || active?.receipt.phase === "awaiting-user" || active?.receipt.browserHandoff.status === "off") {
|
|
305
|
+
respond(200, { state: "idle" });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
const previous = active?.receipt.ownership.previous;
|
|
309
|
+
if (active !== null && ownershipMatches(previous, pid, identityMatches)) {
|
|
310
|
+
const oldAuthentication = authenticationState(connection, req);
|
|
311
|
+
if (oldAuthentication === "unauthenticated" || oldAuthentication === "forbidden") {
|
|
312
|
+
json(res, 401, { state: "unauthorized" });
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
const registration = {
|
|
316
|
+
authority: requestAuthority.authority,
|
|
317
|
+
capabilitySha256: digest,
|
|
318
|
+
armedAt: now()
|
|
319
|
+
};
|
|
320
|
+
respond(200, registrationMatches(registerBrowserTab(dependencies.stateDir, active.receipt.id, registration), active.receipt.id, requestAuthority.authority, digest) ? {
|
|
321
|
+
state: "waiting",
|
|
322
|
+
cutoverId: active.receipt.id
|
|
323
|
+
} : {
|
|
324
|
+
state: "standby",
|
|
325
|
+
cutoverId: active.receipt.id
|
|
326
|
+
});
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (ready === null || active !== null && ready.cutoverId !== active.receipt.id || !registrationMatches(registration, ready?.cutoverId ?? "", requestAuthority.authority, digest)) {
|
|
330
|
+
respond(200, active === null ? { state: "idle" } : {
|
|
331
|
+
state: "waiting",
|
|
332
|
+
cutoverId: active.receipt.id
|
|
333
|
+
});
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
if (ready.protected && connection?.requestRejection === void 0) {
|
|
337
|
+
respond(200, {
|
|
338
|
+
state: "waiting",
|
|
339
|
+
cutoverId: ready.cutoverId
|
|
340
|
+
});
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const cookieState = authenticationState(connection, req);
|
|
344
|
+
if (cookieState === "forbidden") {
|
|
345
|
+
json(res, 403, { state: "forbidden" });
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (cookieState !== "unauthenticated") {
|
|
349
|
+
respond(200, {
|
|
350
|
+
state: "ready",
|
|
351
|
+
action: "reload",
|
|
352
|
+
cutoverId: ready.cutoverId,
|
|
353
|
+
role: ready.role,
|
|
354
|
+
authentication: "existing-cookie"
|
|
355
|
+
});
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
if (connection?.authenticatedUrl === void 0) {
|
|
359
|
+
respond(200, {
|
|
360
|
+
state: "waiting",
|
|
361
|
+
cutoverId: ready.cutoverId
|
|
362
|
+
});
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
let launchUrl;
|
|
366
|
+
try {
|
|
367
|
+
launchUrl = connection.authenticatedUrl(requestAuthority.origin);
|
|
368
|
+
} catch {
|
|
369
|
+
json(res, 500, { state: "unavailable" });
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
let safe = false;
|
|
373
|
+
try {
|
|
374
|
+
const parsed = new URL(launchUrl);
|
|
375
|
+
safe = parsed.origin === requestAuthority.origin && parsed.pathname === "/" && parsed.username === "" && parsed.password === "" && parsed.search !== "" && parsed.hash === "";
|
|
376
|
+
} catch {}
|
|
377
|
+
if (!safe) {
|
|
378
|
+
json(res, 500, { state: "unavailable" });
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
respond(200, {
|
|
382
|
+
state: "ready",
|
|
383
|
+
action: "replace",
|
|
384
|
+
cutoverId: ready.cutoverId,
|
|
385
|
+
role: ready.role,
|
|
386
|
+
authentication: "launch-url",
|
|
387
|
+
launchUrl
|
|
388
|
+
});
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
const receipt = readCutoverReceipt(dependencies.stateDir);
|
|
392
|
+
if (receipt === null || receipt.id !== message.cutoverId) {
|
|
393
|
+
json(res, 409, { state: "stale" });
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const ready = currentReadyOwnership(dependencies.stateDir, pid, identityMatches);
|
|
397
|
+
if (ready === null || ready.cutoverId !== message.cutoverId) {
|
|
398
|
+
json(res, 409, { state: "waiting" });
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
if (ready.protected && connection?.requestRejection === void 0) {
|
|
402
|
+
json(res, 409, { state: "waiting" });
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const ackAuthentication = authenticationState(connection, req);
|
|
406
|
+
if (ackAuthentication === "unauthenticated" || ackAuthentication === "forbidden") {
|
|
407
|
+
json(res, ackAuthentication === "forbidden" ? 403 : 401, { state: ackAuthentication === "forbidden" ? "forbidden" : "unauthorized" });
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
const acknowledgedAt = now();
|
|
411
|
+
if (message.channel === "original-tab") {
|
|
412
|
+
const registration = readBrowserHandoffRequest(dependencies.stateDir);
|
|
413
|
+
if (message.capability === void 0 || !registrationMatches(registration, message.cutoverId, requestAuthority.authority, capabilitySha256(message.capability))) {
|
|
414
|
+
json(res, 403, { state: "forbidden" });
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
if (!acknowledgeBrowserTab(dependencies.stateDir, message.cutoverId, requestAuthority.authority, capabilitySha256(message.capability), acknowledgedAt)) {
|
|
418
|
+
json(res, 409, { state: "conflict" });
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const acknowledgement = {
|
|
423
|
+
version: 1,
|
|
424
|
+
cutoverId: message.cutoverId,
|
|
425
|
+
role: ready.role,
|
|
426
|
+
authority: requestAuthority.authority,
|
|
427
|
+
listenerPid: ready.ownership.listenerPid,
|
|
428
|
+
listenerStartToken: ready.ownership.listenerStartToken,
|
|
429
|
+
channel: message.channel,
|
|
430
|
+
authentication: message.authentication,
|
|
431
|
+
acknowledgedAt
|
|
432
|
+
};
|
|
433
|
+
if (!createJsonOnce(stateFile(dependencies.stateDir, "browserHandoffAck"), acknowledgement)) {
|
|
434
|
+
const existing = readBrowserHandoffAcknowledgement(dependencies.stateDir);
|
|
435
|
+
if (existing?.cutoverId !== acknowledgement.cutoverId || existing.listenerPid !== acknowledgement.listenerPid || existing.listenerStartToken !== acknowledgement.listenerStartToken) {
|
|
436
|
+
json(res, 409, { state: "conflict" });
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
res.writeHead(204, { "cache-control": "no-store" });
|
|
441
|
+
res.end();
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
/** Register the optional Web-profile bridge; headless/TUI compositions simply never activate it. */
|
|
445
|
+
function registerBrowserHandoff(ctx, stateDir) {
|
|
446
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
447
|
+
const webServer = webCtx.get("webServer");
|
|
448
|
+
if (webServer === void 0) return;
|
|
449
|
+
const handler = createBrowserHandoffHandler({
|
|
450
|
+
stateDir,
|
|
451
|
+
connectionProvider: () => webCtx.get("connection")
|
|
452
|
+
});
|
|
453
|
+
webCtx.effect(() => webServer.register({
|
|
454
|
+
kind: "exact",
|
|
455
|
+
path: BROWSER_HANDOFF_ROUTE,
|
|
456
|
+
handler
|
|
457
|
+
}), "ankh-guard: browser handoff route");
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
//#endregion
|
|
461
|
+
//#region lib/types/restart-request.js
|
|
462
|
+
/**
|
|
463
|
+
* In-process guarded-restart trigger for UI-grade callers (the mode
|
|
464
|
+
* switcher's host half). Dispatches on supervision — no live watchdog: the
|
|
465
|
+
* `restart` verb's self-contained stop→start→canary; supervised:
|
|
466
|
+
* `reconfigure`'s transactional cutover, the only safe way to change the
|
|
467
|
+
* launch command under a live watchdog (its respawn would otherwise fight
|
|
468
|
+
* the new command). The trigger shells out to this package's own CLI, so
|
|
469
|
+
* the gate chain (credential → preflight → marker/lock) keeps exactly one
|
|
470
|
+
* behavior source and the detached driver keeps its proven lifetime rules;
|
|
471
|
+
* the structured verdict returns through DSH_ANKH_VERDICT_FILE, never
|
|
472
|
+
* scraped from the human stderr text.
|
|
473
|
+
*
|
|
474
|
+
* Never import the CLI module from the plugin graph: a runtime edge pulls
|
|
475
|
+
* cli.ts into a shared tsdown chunk, evicting its code from lib/cli.js so
|
|
476
|
+
* the direct-invocation guard never fires and the built CLI silently exits
|
|
477
|
+
* 0 on every call (observed 2026-09-09; type-only imports are erased and
|
|
478
|
+
* stay safe).
|
|
479
|
+
*/
|
|
480
|
+
/** Bounded human detail carried alongside the structured verdict, for display. */
|
|
481
|
+
const DETAIL_CAP = 4096;
|
|
482
|
+
/**
|
|
483
|
+
* argv (after process.execPath) that runs this package's CLI with the given
|
|
484
|
+
* args — the same source/built split as the CLI's own cliInvocation, resolved
|
|
485
|
+
* from this module's directory (src/ and lib/ keep their entries side by
|
|
486
|
+
* side). The two copies must not be unified through an import: see the module
|
|
487
|
+
* doc for what a runtime edge into the CLI module does to the built bundle.
|
|
488
|
+
*/
|
|
489
|
+
function cliEntryArgs(args) {
|
|
490
|
+
const modulePath = fileURLToPath(import.meta.url);
|
|
491
|
+
const here = dirname(modulePath);
|
|
492
|
+
if (modulePath.includes(`${sep}src${sep}`)) {
|
|
493
|
+
const tsx = join(resolve(here, "../../../node_modules"), "tsx", "dist", "esm", "index.mjs");
|
|
494
|
+
if (existsSync(tsx)) return [
|
|
495
|
+
"--import",
|
|
496
|
+
tsx,
|
|
497
|
+
join(here, "cli.ts"),
|
|
498
|
+
...args
|
|
499
|
+
];
|
|
500
|
+
return [join(here, "cli.ts"), ...args];
|
|
501
|
+
}
|
|
502
|
+
return [join(here, "cli.js"), ...args];
|
|
503
|
+
}
|
|
504
|
+
/** Read the caller-side verdict file, or null when the CLI never wrote one. */
|
|
505
|
+
function readVerdict(file) {
|
|
506
|
+
try {
|
|
507
|
+
const value = JSON.parse(readFileSync(file, "utf8"));
|
|
508
|
+
return typeof value.stage === "string" && typeof value.reason === "string" ? {
|
|
509
|
+
stage: value.stage,
|
|
510
|
+
reason: value.reason
|
|
511
|
+
} : null;
|
|
512
|
+
} catch {
|
|
513
|
+
return null;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Validate and dispatch one restart request through the guard's own gate
|
|
518
|
+
* chain. A refusal never stops the running instance.
|
|
519
|
+
* @param request - the successor command, target profile, and owning session.
|
|
520
|
+
* @param context - the guard's resolved state dir and credential repo.
|
|
521
|
+
* @returns the structured verdict with bounded human detail for display.
|
|
522
|
+
*/
|
|
523
|
+
async function requestRestart(request, context) {
|
|
524
|
+
if (request.start.trim() === "") return {
|
|
525
|
+
accepted: false,
|
|
526
|
+
stage: "usage",
|
|
527
|
+
reason: "start (the successor launch command) is required",
|
|
528
|
+
detail: ""
|
|
529
|
+
};
|
|
530
|
+
if (request.profile.trim() === "") return {
|
|
531
|
+
accepted: false,
|
|
532
|
+
stage: "usage",
|
|
533
|
+
reason: "profile (the dsh profile to preflight) is required",
|
|
534
|
+
detail: ""
|
|
535
|
+
};
|
|
536
|
+
if (request.initiator.trim() === "") return {
|
|
537
|
+
accepted: false,
|
|
538
|
+
stage: "usage",
|
|
539
|
+
reason: "initiator (the session id the restart report returns to) is required — never invent one",
|
|
540
|
+
detail: ""
|
|
541
|
+
};
|
|
542
|
+
const port = readInstanceLaunch(context.stateDir)?.port;
|
|
543
|
+
if (port === void 0) return {
|
|
544
|
+
accepted: false,
|
|
545
|
+
stage: "usage",
|
|
546
|
+
reason: "no instance launch record in the state dir — the guard does not know this instance's port",
|
|
547
|
+
detail: ""
|
|
548
|
+
};
|
|
549
|
+
const via = liveWatchdogPid(context.stateDir) === null ? "restart" : "reconfigure";
|
|
550
|
+
const argv = via === "restart" ? [
|
|
551
|
+
"restart",
|
|
552
|
+
"--port",
|
|
553
|
+
String(port),
|
|
554
|
+
"--start",
|
|
555
|
+
request.start,
|
|
556
|
+
"--profile",
|
|
557
|
+
request.profile,
|
|
558
|
+
"--initiator",
|
|
559
|
+
request.initiator,
|
|
560
|
+
"--state-dir",
|
|
561
|
+
context.stateDir,
|
|
562
|
+
"--repo",
|
|
563
|
+
context.repoDir
|
|
564
|
+
] : [
|
|
565
|
+
"reconfigure",
|
|
566
|
+
"--start",
|
|
567
|
+
request.start,
|
|
568
|
+
"--profile",
|
|
569
|
+
request.profile,
|
|
570
|
+
"--on-failure",
|
|
571
|
+
"restore-previous",
|
|
572
|
+
"--initiator",
|
|
573
|
+
request.initiator,
|
|
574
|
+
"--state-dir",
|
|
575
|
+
context.stateDir
|
|
576
|
+
];
|
|
577
|
+
const verdictDir = mkdtempSync(join(tmpdir(), "ankh-restart-request-"));
|
|
578
|
+
const verdictFile = join(verdictDir, "verdict.json");
|
|
579
|
+
const env = {
|
|
580
|
+
...process.env,
|
|
581
|
+
DSH_ANKH_VERDICT_FILE: verdictFile
|
|
582
|
+
};
|
|
583
|
+
delete env.DSH_ANKH_RESTART_DRIVER;
|
|
584
|
+
for (const key of Object.keys(env)) if (key.startsWith("WD_")) delete env[key];
|
|
585
|
+
try {
|
|
586
|
+
const { code, output } = await new Promise((resolvePromise, rejectPromise) => {
|
|
587
|
+
let captured = "";
|
|
588
|
+
const child = spawn(process.execPath, cliEntryArgs(argv), {
|
|
589
|
+
stdio: [
|
|
590
|
+
"ignore",
|
|
591
|
+
"pipe",
|
|
592
|
+
"pipe"
|
|
593
|
+
],
|
|
594
|
+
env
|
|
595
|
+
});
|
|
596
|
+
const append = (chunk) => {
|
|
597
|
+
if (captured.length < DETAIL_CAP) captured += chunk.toString("utf8");
|
|
598
|
+
};
|
|
599
|
+
child.stdout.on("data", append);
|
|
600
|
+
child.stderr.on("data", append);
|
|
601
|
+
child.once("error", rejectPromise);
|
|
602
|
+
child.once("exit", (exitCode) => {
|
|
603
|
+
resolvePromise({
|
|
604
|
+
code: exitCode,
|
|
605
|
+
output: captured
|
|
606
|
+
});
|
|
607
|
+
});
|
|
608
|
+
});
|
|
609
|
+
const detail = output.trim();
|
|
610
|
+
if (code === 0) return {
|
|
611
|
+
accepted: true,
|
|
612
|
+
via,
|
|
613
|
+
detail
|
|
614
|
+
};
|
|
615
|
+
const verdict = readVerdict(verdictFile);
|
|
616
|
+
return {
|
|
617
|
+
accepted: false,
|
|
618
|
+
stage: verdict?.stage ?? "unknown",
|
|
619
|
+
reason: verdict?.reason ?? detail.split("\n", 1)[0] ?? `guard CLI exited ${code ?? "signal"}`,
|
|
620
|
+
detail
|
|
621
|
+
};
|
|
622
|
+
} finally {
|
|
623
|
+
rmSync(verdictDir, {
|
|
624
|
+
recursive: true,
|
|
625
|
+
force: true
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
//#endregion
|
|
12
630
|
//#region lib/types/index.js
|
|
631
|
+
/**
|
|
632
|
+
* Which preset a session actually runs, newest selection winning. The rc host
|
|
633
|
+
* exports `resolveSessionPreset` for exactly this fold; the 0.1.2 host
|
|
634
|
+
* replaced it with `agentPresetProjectionDefinition` (init from the header,
|
|
635
|
+
* fold `agent-preset/selected` events) — same semantics, so the derivation
|
|
636
|
+
* feature-detects either surface and never touches the version string. A host
|
|
637
|
+
* with neither yields undefined: the resume falls back to the deployment's
|
|
638
|
+
* default preset, the same outcome a preset-less session had before.
|
|
639
|
+
* @param host - the agent-presets module namespace, structurally probed.
|
|
640
|
+
* @param session - the session's header and event log.
|
|
641
|
+
* @returns the preset id, or `undefined` when the session names none.
|
|
642
|
+
*/
|
|
643
|
+
function deriveSessionPreset(host, session) {
|
|
644
|
+
if (host.agentPresetProjectionDefinition !== void 0) {
|
|
645
|
+
const projection = host.agentPresetProjectionDefinition;
|
|
646
|
+
let state = projection.init(session.header);
|
|
647
|
+
for (const event of session.events) state = projection.apply(state, event);
|
|
648
|
+
return state ?? void 0;
|
|
649
|
+
}
|
|
650
|
+
return host.resolveSessionPreset?.(session);
|
|
651
|
+
}
|
|
13
652
|
const Config = z.object({
|
|
14
653
|
maxAgeMinutes: z.natural().min(1).default(10),
|
|
15
654
|
stateDir: z.string().default(""),
|
|
@@ -68,7 +707,9 @@ function registerRestartSkill(ctx, stateDir) {
|
|
|
68
707
|
ctx.effect(() => skills.register({
|
|
69
708
|
name,
|
|
70
709
|
description,
|
|
71
|
-
content
|
|
710
|
+
content,
|
|
711
|
+
source: "runtime",
|
|
712
|
+
provider: "ankh-guard"
|
|
72
713
|
}));
|
|
73
714
|
writeSkillRegistration(stateDir, {
|
|
74
715
|
registered: true,
|
|
@@ -120,6 +761,7 @@ function apply(ctx, config) {
|
|
|
120
761
|
const resumeMaxSnapshotAgeMs = config.resumeMaxSnapshotAgeMs ?? 6e5;
|
|
121
762
|
const followupReport = reportMode === "followup";
|
|
122
763
|
registerRestartSkill(ctx, stateDir);
|
|
764
|
+
registerBrowserHandoff(ctx, stateDir);
|
|
123
765
|
if (followupReport || resumeInterrupted) {
|
|
124
766
|
const pluginMessage = (text) => createUserMessage({
|
|
125
767
|
content: [{
|
|
@@ -141,6 +783,24 @@ function apply(ctx, config) {
|
|
|
141
783
|
ctx.effect(() => () => {
|
|
142
784
|
disposed = true;
|
|
143
785
|
});
|
|
786
|
+
const parkedMemo = /* @__PURE__ */ new Map();
|
|
787
|
+
const checkParked = (id) => {
|
|
788
|
+
let probe = parkedMemo.get(id);
|
|
789
|
+
if (probe === void 0) {
|
|
790
|
+
probe = (async () => {
|
|
791
|
+
try {
|
|
792
|
+
const persistence = ctx.get("sessionPersistence");
|
|
793
|
+
if (persistence === void 0) return false;
|
|
794
|
+
const { events } = await persistence.inspect(id);
|
|
795
|
+
return isParkedOnUserInput(events);
|
|
796
|
+
} catch {
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
})();
|
|
800
|
+
parkedMemo.set(id, probe);
|
|
801
|
+
}
|
|
802
|
+
return probe;
|
|
803
|
+
};
|
|
144
804
|
const claim = (agent, record) => {
|
|
145
805
|
const canaryPending = existsSync(stateFile(stateDir, "restartRequested"));
|
|
146
806
|
const text = restartContextText(record, canaryPending);
|
|
@@ -149,22 +809,39 @@ function apply(ctx, config) {
|
|
|
149
809
|
acknowledgeRestartRecord(stateDir, record, Date.now());
|
|
150
810
|
};
|
|
151
811
|
const deliver = (agent) => {
|
|
812
|
+
if (cutoverBlocksWake(stateDir)) return;
|
|
152
813
|
const id = agent.id;
|
|
153
814
|
const exitAt = pendingContinue.get(id);
|
|
154
815
|
const record = followupReport ? pendingRestartRecord(stateDir) : null;
|
|
155
|
-
|
|
156
|
-
const canaryPending = existsSync(stateFile(stateDir, "restartRequested"));
|
|
157
|
-
const text = continueAndReportText(record, canaryPending);
|
|
158
|
-
if (text !== "") {
|
|
159
|
-
agent.followup(pluginMessage(text));
|
|
160
|
-
pendingContinue.delete(id);
|
|
161
|
-
acknowledgeRestartRecord(stateDir, record, Date.now());
|
|
162
|
-
return;
|
|
163
|
-
}
|
|
164
|
-
}
|
|
816
|
+
const owesReport = record !== null && (record.initiator === void 0 || id === record.initiator);
|
|
165
817
|
if (exitAt !== void 0) {
|
|
166
|
-
|
|
167
|
-
|
|
818
|
+
(async () => {
|
|
819
|
+
const parked = await checkParked(id);
|
|
820
|
+
if (disposed || pendingContinue.get(id) !== exitAt) return;
|
|
821
|
+
pendingContinue.delete(id);
|
|
822
|
+
if (parked) {
|
|
823
|
+
if (owesReport && record !== null) claim(agent, record);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (owesReport && record !== null) {
|
|
827
|
+
const canaryPending = existsSync(stateFile(stateDir, "restartRequested"));
|
|
828
|
+
const text = continueAndReportText(record, canaryPending);
|
|
829
|
+
if (text !== "") try {
|
|
830
|
+
agent.followup(pluginMessage(text));
|
|
831
|
+
acknowledgeRestartRecord(stateDir, record, Date.now());
|
|
832
|
+
return;
|
|
833
|
+
} catch {
|
|
834
|
+
pendingContinue.set(id, exitAt);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
try {
|
|
839
|
+
agent.followup(pluginMessage(continueInterruptedText(exitAt)));
|
|
840
|
+
} catch {
|
|
841
|
+
pendingContinue.set(id, exitAt);
|
|
842
|
+
}
|
|
843
|
+
})();
|
|
844
|
+
return;
|
|
168
845
|
}
|
|
169
846
|
if (record === null) return;
|
|
170
847
|
if (record.initiator !== void 0 && id !== record.initiator) return;
|
|
@@ -202,7 +879,7 @@ function apply(ctx, config) {
|
|
|
202
879
|
const persistence = ctx.get("sessionPersistence");
|
|
203
880
|
if (presets !== void 0 && persistence !== void 0) {
|
|
204
881
|
const inspected = await persistence.inspect(id);
|
|
205
|
-
const presetId =
|
|
882
|
+
const presetId = deriveSessionPreset(agentPresetsHost, {
|
|
206
883
|
header: inspected.meta,
|
|
207
884
|
events: inspected.events
|
|
208
885
|
});
|
|
@@ -221,10 +898,16 @@ function apply(ctx, config) {
|
|
|
221
898
|
if (snapshot !== null && Date.now() - snapshot.exitAt <= resumeMaxSnapshotAgeMs) for (const id of snapshot.interrupted) pendingContinue.set(id, snapshot.exitAt);
|
|
222
899
|
}
|
|
223
900
|
const resumePass = async () => {
|
|
901
|
+
while (!disposed && cutoverBlocksWake(stateDir)) await new Promise((resolve) => setTimeout(resolve, 250));
|
|
902
|
+
if (disposed) return;
|
|
224
903
|
const snapshot = readInterruptedSnapshot(stateDir);
|
|
225
904
|
if (snapshot === null) return;
|
|
226
905
|
if (Date.now() - snapshot.exitAt <= resumeMaxSnapshotAgeMs) for (const id of [.../* @__PURE__ */ new Set([...snapshot.resume, ...snapshot.interrupted])]) {
|
|
227
906
|
if (disposed) return;
|
|
907
|
+
if (await checkParked(id)) {
|
|
908
|
+
pendingContinue.delete(id);
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
228
911
|
const live = ctx.agents.list().find((agent) => agent.id === id);
|
|
229
912
|
if (live !== void 0) {
|
|
230
913
|
deliver(live);
|
|
@@ -249,6 +932,16 @@ function apply(ctx, config) {
|
|
|
249
932
|
clearTimeout(timer);
|
|
250
933
|
});
|
|
251
934
|
}
|
|
935
|
+
if (cutoverBlocksWake(stateDir)) {
|
|
936
|
+
const releaseTimer = setInterval(() => {
|
|
937
|
+
if (disposed || cutoverBlocksWake(stateDir)) return;
|
|
938
|
+
clearInterval(releaseTimer);
|
|
939
|
+
for (const agent of ctx.agents.roots()) deliver(agent);
|
|
940
|
+
}, 250);
|
|
941
|
+
ctx.effect(() => () => {
|
|
942
|
+
clearInterval(releaseTimer);
|
|
943
|
+
});
|
|
944
|
+
}
|
|
252
945
|
ctx.on("agent/created", ({ agent }) => {
|
|
253
946
|
if (!ctx.agents.roots().includes(agent)) return;
|
|
254
947
|
deliver(agent);
|
|
@@ -259,6 +952,7 @@ function apply(ctx, config) {
|
|
|
259
952
|
if (decision.kind === "reject" || signal.aborted) return decision;
|
|
260
953
|
const record = pendingRestartRecord(stateDir);
|
|
261
954
|
if (record === null) return decision;
|
|
955
|
+
if (cutoverBlocksWake(stateDir)) return decision;
|
|
262
956
|
const canaryPending = existsSync(stateFile(stateDir, "restartRequested"));
|
|
263
957
|
const text = restartContextText(record, canaryPending);
|
|
264
958
|
if (text === "") return decision;
|
|
@@ -283,10 +977,11 @@ function apply(ctx, config) {
|
|
|
283
977
|
};
|
|
284
978
|
}, { prepend: true });
|
|
285
979
|
const service = {
|
|
286
|
-
verify: () => verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), maxAgeMinutes),
|
|
980
|
+
verify: () => verifyCredential(loadState(stateDir), currentHead(repoDir), Date.now(), maxAgeMinutes, isWorkingTreeClean(repoDir)),
|
|
287
981
|
record: (scope, options) => {
|
|
288
982
|
const head = currentHead(repoDir);
|
|
289
983
|
if (head === null) throw new Error("ankh-guard: cannot record a credential outside a git repository");
|
|
984
|
+
if (!isWorkingTreeClean(repoDir)) throw new Error("ankh-guard: cannot record a credential while the working tree is dirty");
|
|
290
985
|
return recordCredential(stateDir, {
|
|
291
986
|
scope,
|
|
292
987
|
revision: head,
|
|
@@ -305,6 +1000,10 @@ function apply(ctx, config) {
|
|
|
305
1000
|
return result;
|
|
306
1001
|
},
|
|
307
1002
|
reset: (sha) => resetToCheckpoint(repoDir, sha),
|
|
1003
|
+
requestRestart: (request) => requestRestart(request, {
|
|
1004
|
+
stateDir,
|
|
1005
|
+
repoDir
|
|
1006
|
+
}),
|
|
308
1007
|
canary: async (options) => {
|
|
309
1008
|
const checks = [];
|
|
310
1009
|
const verdict = service.verify();
|
|
@@ -332,22 +1031,7 @@ function apply(ctx, config) {
|
|
|
332
1031
|
try {
|
|
333
1032
|
const env = {};
|
|
334
1033
|
for (const [key, value] of Object.entries(process.env)) if (key.startsWith("DSH_") && value !== void 0) env[key] = value;
|
|
335
|
-
|
|
336
|
-
try {
|
|
337
|
-
const out = execFileSync("lsof", [
|
|
338
|
-
"-a",
|
|
339
|
-
"-p",
|
|
340
|
-
String(process.pid),
|
|
341
|
-
"-iTCP",
|
|
342
|
-
"-sTCP:LISTEN",
|
|
343
|
-
"-P"
|
|
344
|
-
], {
|
|
345
|
-
encoding: "utf8",
|
|
346
|
-
stdio: "pipe"
|
|
347
|
-
});
|
|
348
|
-
const match = /:(\d+) \(LISTEN\)/.exec(out);
|
|
349
|
-
if (match !== null) port = Number(match[1]);
|
|
350
|
-
} catch {}
|
|
1034
|
+
const port = listeningPortsForPid(process.pid)[0];
|
|
351
1035
|
writeInstanceLaunch(stateDir, {
|
|
352
1036
|
command: buildLaunchCommand(process.execPath, process.execArgv, process.argv.slice(1), process.cwd(), env),
|
|
353
1037
|
source: "instance",
|
|
@@ -359,4 +1043,4 @@ function apply(ctx, config) {
|
|
|
359
1043
|
})();
|
|
360
1044
|
}
|
|
361
1045
|
//#endregion
|
|
362
|
-
export { Config, apply, inject, name };
|
|
1046
|
+
export { Config, apply, deriveSessionPreset, inject, name };
|