taskchef 7.22.0 → 7.22.2
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/.codex-plugin/plugin.json +1 -1
- package/README.md +30 -3
- package/docs/spec.md +46 -8
- package/mcp/server.js +2 -16
- package/package.json +1 -1
- package/src/dashboard/actions.js +4 -4
- package/src/dashboard/app.js +19 -2
- package/src/dashboard/state.js +2 -0
- package/src/dashboard-manager.js +323 -7
- package/src/dashboard-ownership.js +140 -0
- package/src/dashboard.js +53 -1
- package/src/mcp-process.js +79 -0
- package/src/mcp.js +16 -2
- package/src/process-lifecycle.js +29 -0
package/README.md
CHANGED
|
@@ -241,9 +241,27 @@ identity proves the exact TaskChef/dashboard-server version and the same
|
|
|
241
241
|
canonical workspace. The response says `started` or `reused` and includes the
|
|
242
242
|
stable URL, canonical workspace, and versions.
|
|
243
243
|
|
|
244
|
-
The in-process dashboard closes
|
|
245
|
-
|
|
246
|
-
|
|
244
|
+
The in-process dashboard closes on MCP transport close, stdin EOF, SIGINT,
|
|
245
|
+
SIGTERM, or detected loss of the MCP process's original parent. Activating a
|
|
246
|
+
newer TaskChef MCP can also retire an older compatible MCP-owned dashboard for
|
|
247
|
+
the same canonical workspace through an authenticated loopback handoff. These
|
|
248
|
+
guards reduce unnecessary orphans but do not claim that Codex supplies a
|
|
249
|
+
restart or child-process cleanup guarantee. TaskChef adds no daemon or other
|
|
250
|
+
OS-persistent component, and dashboard availability remains best-effort while
|
|
251
|
+
Codex is closed.
|
|
252
|
+
|
|
253
|
+
Authenticated handoff requires the older listener to use the same dashboard
|
|
254
|
+
protocol and to have written TaskChef's private mode-0600 ownership record in
|
|
255
|
+
that canonical workspace. A nonce-bound HMAC challenge proves the listener has
|
|
256
|
+
the matching secret before TaskChef sends a separately authenticated graceful
|
|
257
|
+
shutdown request. The secret is never returned by `/api/health`, sent in a
|
|
258
|
+
request, or logged. Standalone, different-workspace, newer, malformed,
|
|
259
|
+
unverified, and spoofed listeners are left untouched. Versions predating this
|
|
260
|
+
protocol cannot be taken over safely and may require one final manual cleanup.
|
|
261
|
+
The ownership record is durable metadata: shutdown retains it, and the next
|
|
262
|
+
MCP owner atomically replaces it after binding. This avoids both a live
|
|
263
|
+
credentialless retirement window and an old process deleting its replacement's
|
|
264
|
+
proof.
|
|
247
265
|
|
|
248
266
|
Autostart is enabled when `dashboard` is absent and in new workspaces. To opt
|
|
249
267
|
out, add this optional exact object to `taskchef.json` while retaining its other
|
|
@@ -403,6 +421,15 @@ protocol `serverVersion`, `mcp` launcher, canonical workspace, and URL.
|
|
|
403
421
|
Exact-compatible MCP servers may be reused; standalone and unknown listeners
|
|
404
422
|
remain untouched.
|
|
405
423
|
|
|
424
|
+
If autostart reports a **verified older TaskChef** listener, the port occupant
|
|
425
|
+
passed the bounded TaskChef identity check but could not complete authenticated
|
|
426
|
+
handoff. For a release older than the ownership protocol, stop that one known
|
|
427
|
+
legacy MCP process once through the application/session that launched it, then
|
|
428
|
+
activate the installed plugin again. Do not kill an arbitrary port owner. A
|
|
429
|
+
listener from a handoff-capable prior release should retire automatically; if
|
|
430
|
+
it does not, retain it for diagnosis because TaskChef deliberately refuses to
|
|
431
|
+
weaken the ownership proof.
|
|
432
|
+
|
|
406
433
|
## Development
|
|
407
434
|
|
|
408
435
|
```sh
|
package/docs/spec.md
CHANGED
|
@@ -272,7 +272,10 @@ failures are surfaced as tool errors and MUST NOT partially mutate the log.
|
|
|
272
272
|
### `ensure_dashboard`
|
|
273
273
|
|
|
274
274
|
**Caller:** dispatcher. **Mutation:** starts at most one in-process loopback
|
|
275
|
-
HTTP server
|
|
275
|
+
HTTP server. The first owned start writes a private dashboard ownership record
|
|
276
|
+
in the canonical workspace. The record remains after shutdown and the next
|
|
277
|
+
owner atomically replaces it before reporting startup success.
|
|
278
|
+
Exact-compatible reuse does not write it.
|
|
276
279
|
|
|
277
280
|
**Input:** empty object.
|
|
278
281
|
|
|
@@ -298,13 +301,42 @@ host, or port.
|
|
|
298
301
|
|
|
299
302
|
Before reuse, TaskChef MUST query a bounded loopback identity endpoint and
|
|
300
303
|
require the exact fixed service/schema, TaskChef version, dashboard-server
|
|
301
|
-
version, canonical workspace, and `mcp` launcher identity.
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
304
|
+
version, canonical workspace, and `mcp` launcher identity. Exact-compatible
|
|
305
|
+
reuse MUST remain non-destructive and MUST NOT require ownership metadata. A
|
|
306
|
+
manager that reuses another same-version MCP listener MUST monitor that listener
|
|
307
|
+
for the remainder of its own lifetime and perform ordinary singleton startup if
|
|
308
|
+
the reused owner exits, so overlapping plugin reloads converge on the surviving
|
|
309
|
+
MCP process without signaling or terminating either process. A temporary
|
|
310
|
+
different or unknown occupant remains untouched and does not disable that
|
|
311
|
+
non-destructive availability monitor.
|
|
312
|
+
|
|
313
|
+
An older TaskChef version MAY be retired only when all of these facts hold: its
|
|
314
|
+
health identity is exact and reports the same canonical workspace, `mcp`
|
|
315
|
+
launcher, and dashboard-server version; its semantic version is older than the
|
|
316
|
+
requesting TaskChef version; a private regular mode-0600 owner record in that
|
|
317
|
+
same workspace exactly matches the listener; and a fresh nonce-bound HMAC
|
|
318
|
+
challenge proves control of that record's secret. Only after that proof MAY the
|
|
319
|
+
new process send a separately nonce-bound authenticated loopback shutdown
|
|
320
|
+
request. Shutdown nonces MUST be single-use. The secret MUST NOT appear in
|
|
321
|
+
health responses, requests, diagnostics, or logs. Shutdown MUST retain the
|
|
322
|
+
durable owner record so retirement cannot expose a credentialless live-listener
|
|
323
|
+
window or delete a replacement's record. An owner accepting handoff MUST retire its in-memory dashboard manager
|
|
324
|
+
so later tool calls cannot restart the older listener. Concurrent handoffs MUST
|
|
325
|
+
converge on one listener and exact-compatible reuse.
|
|
326
|
+
|
|
327
|
+
A standalone, unknown, malformed, different-workspace, newer, incompatible,
|
|
328
|
+
unproven, spoofed, or legacy listener without valid ownership metadata MUST
|
|
329
|
+
produce a concise actionable conflict and MUST NOT receive a shutdown request,
|
|
330
|
+
signal, or process-level termination attempt. TaskChef MUST NOT discover or
|
|
331
|
+
kill port owners. A startup or owner-record-write failure MUST leave no owned
|
|
332
|
+
listener. The MCP server MUST close its owned dashboard on explicit MCP close,
|
|
333
|
+
transport close, stdin EOF, SIGINT, SIGTERM, or detected replacement of its
|
|
334
|
+
original parent process. Parent-loss detection is a local best-effort guard,
|
|
335
|
+
not a Codex restart guarantee. Signal and parent-loss shutdown MUST bound the
|
|
336
|
+
wait for an in-flight transport startup before closing the MCP server, so a
|
|
337
|
+
never-settling startup cannot retain the dashboard indefinitely. This keeps dashboard child operations in the MCP
|
|
338
|
+
host environment and prevents an agent-shell or foreground CLI dashboard from
|
|
339
|
+
being mistaken for the canonical MCP-owned runtime.
|
|
308
340
|
|
|
309
341
|
The packaged `$taskchef-dashboard` skill MUST call this tool, report `started`
|
|
310
342
|
or `reused`, and return the canonical clickable URL. It MAY use an available
|
|
@@ -603,6 +635,12 @@ permissions for dashboard availability. Availability is best-effort while the
|
|
|
603
635
|
owning Codex/plugin MCP process is alive and is not guaranteed while Codex is
|
|
604
636
|
closed.
|
|
605
637
|
|
|
638
|
+
Releases predating authenticated dashboard handoff are legacy listeners. A
|
|
639
|
+
verified legacy listener MUST be reported distinctly from an unknown port
|
|
640
|
+
conflict but MUST remain untouched; one final manual cleanup MAY be required
|
|
641
|
+
when upgrading from such a release. Once both sides implement the same control
|
|
642
|
+
protocol, compatible prior-version handoff is automatic.
|
|
643
|
+
|
|
606
644
|
Installing or replacing plugin files MUST NOT be described as activating the
|
|
607
645
|
new MCP code. Release verification MUST install the plugin, activate or reload
|
|
608
646
|
the new MCP process, ensure the dashboard, and verify the expected TaskChef
|
package/mcp/server.js
CHANGED
|
@@ -1,19 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import { createTaskChefMcpServer } from "../src/mcp.js";
|
|
3
|
+
import { runTaskChefMcpProcess } from "../src/mcp-process.js";
|
|
5
4
|
|
|
6
|
-
|
|
7
|
-
await server.connect(new StdioServerTransport());
|
|
8
|
-
|
|
9
|
-
let shutdownPromise = null;
|
|
10
|
-
const shutdown = () => {
|
|
11
|
-
shutdownPromise ??= server.close().catch((error) => {
|
|
12
|
-
process.stderr.write(`TaskChef MCP shutdown failed: ${error.message}\n`);
|
|
13
|
-
process.exitCode = 1;
|
|
14
|
-
});
|
|
15
|
-
return shutdownPromise;
|
|
16
|
-
};
|
|
17
|
-
|
|
18
|
-
process.once("SIGINT", shutdown);
|
|
19
|
-
process.once("SIGTERM", shutdown);
|
|
5
|
+
await runTaskChefMcpProcess();
|
package/package.json
CHANGED
package/src/dashboard/actions.js
CHANGED
|
@@ -20,7 +20,7 @@ export async function archiveTaskFromControl(event, task, {
|
|
|
20
20
|
confirmAction = globalThis.confirm,
|
|
21
21
|
fetchAction = globalThis.fetch,
|
|
22
22
|
onArchived = () => {},
|
|
23
|
-
|
|
23
|
+
showUpdate,
|
|
24
24
|
} = {}) {
|
|
25
25
|
event.stopPropagation();
|
|
26
26
|
const accepted = confirmAction(
|
|
@@ -39,15 +39,15 @@ export async function archiveTaskFromControl(event, task, {
|
|
|
39
39
|
);
|
|
40
40
|
const result = await response.json();
|
|
41
41
|
if (!response.ok) {
|
|
42
|
-
|
|
42
|
+
showUpdate(result.message ?? "Codex could not archive this chat.", { failed: true });
|
|
43
43
|
return false;
|
|
44
44
|
}
|
|
45
45
|
onArchived(task.threadId);
|
|
46
46
|
archived = true;
|
|
47
|
-
|
|
47
|
+
showUpdate(result.message ?? "Archived the Codex chat. TaskChef history remains available.");
|
|
48
48
|
return true;
|
|
49
49
|
} catch {
|
|
50
|
-
|
|
50
|
+
showUpdate("Codex chat archiving is temporarily unavailable. Try again.", { failed: true });
|
|
51
51
|
return false;
|
|
52
52
|
} finally {
|
|
53
53
|
if (!archived) control.disabled = false;
|
package/src/dashboard/app.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
MAX_NOTIFICATIONS,
|
|
2
3
|
canArchiveTask,
|
|
3
4
|
canManuallyTransitionTask,
|
|
4
5
|
clearNotifications,
|
|
@@ -55,6 +56,7 @@ let dateRefreshTimer = null;
|
|
|
55
56
|
let detailRequestGeneration = 0;
|
|
56
57
|
let copyTaskIdGeneration = 0;
|
|
57
58
|
let copyTaskIdTimer = null;
|
|
59
|
+
let archiveUpdateSerial = 0;
|
|
58
60
|
const relativeTimes = new RelativeTimeController();
|
|
59
61
|
|
|
60
62
|
const elements = {
|
|
@@ -362,6 +364,22 @@ function renderNotifications(additions = []) {
|
|
|
362
364
|
}
|
|
363
365
|
}
|
|
364
366
|
|
|
367
|
+
export function showArchiveUpdate(task, message, { failed = false } = {}) {
|
|
368
|
+
const notification = Object.freeze({
|
|
369
|
+
id: `archive:${task.id}:${Date.now()}:${archiveUpdateSerial += 1}`,
|
|
370
|
+
taskId: task.id,
|
|
371
|
+
title: task.title,
|
|
372
|
+
status: task.status,
|
|
373
|
+
event: failed ? "archive_failed" : "archive_succeeded",
|
|
374
|
+
turnRef: null,
|
|
375
|
+
turnId: null,
|
|
376
|
+
timestamp: new Date().toISOString(),
|
|
377
|
+
summary: message,
|
|
378
|
+
});
|
|
379
|
+
state.notifications = [notification, ...state.notifications].slice(0, MAX_NOTIFICATIONS);
|
|
380
|
+
renderNotifications([notification]);
|
|
381
|
+
}
|
|
382
|
+
|
|
365
383
|
function detailRow(term, value) {
|
|
366
384
|
const dt = document.createElement("dt");
|
|
367
385
|
dt.textContent = term;
|
|
@@ -597,7 +615,6 @@ async function submitManualTransition(targetStatus, event) {
|
|
|
597
615
|
if (current === result.task) replaceCurrentTask(result.task);
|
|
598
616
|
renderDialog(current);
|
|
599
617
|
render();
|
|
600
|
-
showMessage(result.task.summary);
|
|
601
618
|
elements.dialogTitle.focus?.();
|
|
602
619
|
return;
|
|
603
620
|
}
|
|
@@ -959,7 +976,7 @@ elements.archiveTask.addEventListener("click", async (event) => {
|
|
|
959
976
|
try {
|
|
960
977
|
await archiveTaskFromControl(event, task, {
|
|
961
978
|
onArchived: (threadId) => state.archivedThreadIds.add(threadId),
|
|
962
|
-
|
|
979
|
+
showUpdate: (message, options) => showArchiveUpdate(task, message, options),
|
|
963
980
|
});
|
|
964
981
|
} finally {
|
|
965
982
|
state.archivePendingThreadIds.delete(task.threadId);
|
package/src/dashboard/state.js
CHANGED
|
@@ -30,6 +30,8 @@ const NOTIFICATION_TITLES = new Map([
|
|
|
30
30
|
["failed", "Task failed"],
|
|
31
31
|
["manual_completed", "Task manually completed"],
|
|
32
32
|
["manual_failed", "Task manually failed"],
|
|
33
|
+
["archive_succeeded", "Chat archived"],
|
|
34
|
+
["archive_failed", "Chat archive failed"],
|
|
33
35
|
["unresolved", "Task updated"],
|
|
34
36
|
]);
|
|
35
37
|
|
package/src/dashboard-manager.js
CHANGED
|
@@ -8,11 +8,23 @@ import {
|
|
|
8
8
|
createDashboardServer,
|
|
9
9
|
dashboardAuthority,
|
|
10
10
|
} from "./dashboard.js";
|
|
11
|
+
import {
|
|
12
|
+
DASHBOARD_CONTROL_CHALLENGE_PATH,
|
|
13
|
+
DASHBOARD_CONTROL_SHUTDOWN_PATH,
|
|
14
|
+
createDashboardControlNonce,
|
|
15
|
+
createDashboardControlSecret,
|
|
16
|
+
dashboardControlProof,
|
|
17
|
+
dashboardOwnerMetadata,
|
|
18
|
+
readDashboardOwner,
|
|
19
|
+
verifyDashboardControlProof,
|
|
20
|
+
writeDashboardOwner,
|
|
21
|
+
} from "./dashboard-ownership.js";
|
|
11
22
|
import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
|
|
12
23
|
|
|
13
24
|
const DEFAULT_HOST = "127.0.0.1";
|
|
14
25
|
const DEFAULT_PORT = 3210;
|
|
15
26
|
const HEALTH_TIMEOUT_MS = 750;
|
|
27
|
+
const REUSE_MONITOR_INTERVAL_MS = 250;
|
|
16
28
|
|
|
17
29
|
function expectedIdentity(workspace, taskchefVersion, serverVersion, launcher) {
|
|
18
30
|
return {
|
|
@@ -34,11 +46,109 @@ function isExactIdentity(value, expected) {
|
|
|
34
46
|
&& expectedKeys.every((key) => value[key] === expected[key]);
|
|
35
47
|
}
|
|
36
48
|
|
|
37
|
-
function listenerConflict(url, detail
|
|
38
|
-
|
|
49
|
+
function listenerConflict(url, detail, {
|
|
50
|
+
staleTaskchefVersion,
|
|
51
|
+
handoffRaceEligible = false,
|
|
52
|
+
retryAuthentication = false,
|
|
53
|
+
} = {}) {
|
|
54
|
+
const error = new Error(
|
|
39
55
|
`TaskChef dashboard port conflict at ${url} ${detail} `
|
|
40
56
|
+ "Stop that listener or choose another port for the foreground dashboard CLI; TaskChef will not terminate it.",
|
|
41
57
|
);
|
|
58
|
+
error.code = "TASKCHEF_DASHBOARD_CONFLICT";
|
|
59
|
+
if (staleTaskchefVersion) error.staleTaskchefVersion = staleTaskchefVersion;
|
|
60
|
+
if (handoffRaceEligible) error.handoffRaceEligible = true;
|
|
61
|
+
if (retryAuthentication) error.retryAuthentication = true;
|
|
62
|
+
return error;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function priorCompatibleVersion(candidate, current) {
|
|
66
|
+
const parse = (value) => {
|
|
67
|
+
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/.exec(value);
|
|
68
|
+
if (!match) return null;
|
|
69
|
+
const prerelease = match[4]?.split(".") ?? null;
|
|
70
|
+
if (prerelease?.some((identifier) => /^\d+$/.test(identifier)
|
|
71
|
+
&& identifier.length > 1 && identifier.startsWith("0"))) return null;
|
|
72
|
+
return { core: match.slice(1, 4).map(BigInt), prerelease };
|
|
73
|
+
};
|
|
74
|
+
const left = parse(candidate);
|
|
75
|
+
const right = parse(current);
|
|
76
|
+
if (!left || !right) return false;
|
|
77
|
+
for (let index = 0; index < left.core.length; index += 1) {
|
|
78
|
+
if (left.core[index] !== right.core[index]) return left.core[index] < right.core[index];
|
|
79
|
+
}
|
|
80
|
+
if (left.prerelease === null || right.prerelease === null) {
|
|
81
|
+
return left.prerelease !== null && right.prerelease === null;
|
|
82
|
+
}
|
|
83
|
+
const length = Math.max(left.prerelease.length, right.prerelease.length);
|
|
84
|
+
for (let index = 0; index < length; index += 1) {
|
|
85
|
+
const leftPart = left.prerelease[index];
|
|
86
|
+
const rightPart = right.prerelease[index];
|
|
87
|
+
if (leftPart === undefined || rightPart === undefined) return leftPart === undefined;
|
|
88
|
+
if (leftPart === rightPart) continue;
|
|
89
|
+
const leftNumeric = /^\d+$/.test(leftPart);
|
|
90
|
+
const rightNumeric = /^\d+$/.test(rightPart);
|
|
91
|
+
if (leftNumeric && rightNumeric) return BigInt(leftPart) < BigInt(rightPart);
|
|
92
|
+
if (leftNumeric !== rightNumeric) return leftNumeric;
|
|
93
|
+
return leftPart < rightPart;
|
|
94
|
+
}
|
|
95
|
+
return false;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function publicOwnerIdentity(owner) {
|
|
99
|
+
return {
|
|
100
|
+
schemaVersion: 1,
|
|
101
|
+
service: "taskchef-dashboard",
|
|
102
|
+
taskchefVersion: owner.taskchefVersion,
|
|
103
|
+
serverVersion: owner.serverVersion,
|
|
104
|
+
workspace: owner.workspace,
|
|
105
|
+
launcher: owner.launcher,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function requestDashboardJson({ host, port, path: requestPath, method = "GET", body, timeoutMs }) {
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
const encoded = body === undefined ? null : Buffer.from(JSON.stringify(body));
|
|
112
|
+
const request = http.request({
|
|
113
|
+
host,
|
|
114
|
+
port,
|
|
115
|
+
path: requestPath,
|
|
116
|
+
method,
|
|
117
|
+
headers: {
|
|
118
|
+
Accept: "application/json",
|
|
119
|
+
Host: dashboardAuthority(host, port),
|
|
120
|
+
...(encoded ? {
|
|
121
|
+
"Content-Type": "application/json",
|
|
122
|
+
"Content-Length": encoded.length,
|
|
123
|
+
} : {}),
|
|
124
|
+
},
|
|
125
|
+
}, (response) => {
|
|
126
|
+
const chunks = [];
|
|
127
|
+
let total = 0;
|
|
128
|
+
response.on("data", (chunk) => {
|
|
129
|
+
total += chunk.length;
|
|
130
|
+
if (total > DASHBOARD_HEALTH_MAX_BYTES) request.destroy(
|
|
131
|
+
new Error("dashboard control response exceeds the identity limit"),
|
|
132
|
+
);
|
|
133
|
+
else chunks.push(chunk);
|
|
134
|
+
});
|
|
135
|
+
response.on("end", () => {
|
|
136
|
+
let value;
|
|
137
|
+
try { value = JSON.parse(Buffer.concat(chunks, total).toString("utf8")); } catch {
|
|
138
|
+
reject(new Error("dashboard control returned invalid JSON"));
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
resolve({ statusCode: response.statusCode, value });
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
const deadline = setTimeout(() => request.destroy(
|
|
145
|
+
new Error("dashboard control request timed out"),
|
|
146
|
+
), timeoutMs);
|
|
147
|
+
request.on("close", () => clearTimeout(deadline));
|
|
148
|
+
request.on("error", reject);
|
|
149
|
+
if (encoded) request.write(encoded);
|
|
150
|
+
request.end();
|
|
151
|
+
});
|
|
42
152
|
}
|
|
43
153
|
|
|
44
154
|
export function readDashboardIdentity({
|
|
@@ -111,11 +221,30 @@ export function createDashboardManager({
|
|
|
111
221
|
launcher = "mcp",
|
|
112
222
|
createServer = createDashboardServer,
|
|
113
223
|
readIdentity = readDashboardIdentity,
|
|
224
|
+
readOwner = readDashboardOwner,
|
|
225
|
+
writeOwner = writeDashboardOwner,
|
|
226
|
+
reuseMonitorIntervalMs = REUSE_MONITOR_INTERVAL_MS,
|
|
114
227
|
} = {}) {
|
|
115
228
|
let canonicalWorkspace;
|
|
116
229
|
let ownedServer = null;
|
|
230
|
+
let ownedSecret = null;
|
|
117
231
|
let ensurePromise = null;
|
|
118
232
|
let closePromise = null;
|
|
233
|
+
let reuseMonitor = null;
|
|
234
|
+
let reuseRecovery = null;
|
|
235
|
+
let retired = false;
|
|
236
|
+
|
|
237
|
+
const stopReuseMonitor = () => {
|
|
238
|
+
if (reuseMonitor) clearInterval(reuseMonitor);
|
|
239
|
+
reuseMonitor = null;
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const closeOwned = async () => {
|
|
243
|
+
const server = ownedServer;
|
|
244
|
+
ownedServer = null;
|
|
245
|
+
ownedSecret = null;
|
|
246
|
+
await server?.close();
|
|
247
|
+
};
|
|
119
248
|
|
|
120
249
|
const publicResult = (action) => ({
|
|
121
250
|
action,
|
|
@@ -126,6 +255,97 @@ export function createDashboardManager({
|
|
|
126
255
|
serverVersion,
|
|
127
256
|
});
|
|
128
257
|
|
|
258
|
+
const authenticatePriorOwner = async (identity) => {
|
|
259
|
+
let owner;
|
|
260
|
+
try {
|
|
261
|
+
owner = await readOwner(canonicalWorkspace);
|
|
262
|
+
} catch (error) {
|
|
263
|
+
throw listenerConflict(
|
|
264
|
+
`http://${dashboardAuthority(host, port)}/`,
|
|
265
|
+
`is a verified older TaskChef ${identity.taskchefVersion} listener without usable authenticated handoff metadata.`,
|
|
266
|
+
{
|
|
267
|
+
staleTaskchefVersion: identity.taskchefVersion,
|
|
268
|
+
handoffRaceEligible: error?.code === "ENOENT",
|
|
269
|
+
retryAuthentication: error?.code === "ENOENT",
|
|
270
|
+
},
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
if (owner.host !== host || owner.port !== port
|
|
274
|
+
|| !isExactIdentity(identity, publicOwnerIdentity(owner))) {
|
|
275
|
+
throw listenerConflict(
|
|
276
|
+
`http://${dashboardAuthority(host, port)}/`,
|
|
277
|
+
"has ownership metadata that does not exactly match the listener identity.",
|
|
278
|
+
{ staleTaskchefVersion: identity.taskchefVersion },
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
const challengeNonce = createDashboardControlNonce();
|
|
282
|
+
const challenge = await requestDashboardJson({
|
|
283
|
+
host,
|
|
284
|
+
port,
|
|
285
|
+
path: `${DASHBOARD_CONTROL_CHALLENGE_PATH}?nonce=${challengeNonce}`,
|
|
286
|
+
timeoutMs: HEALTH_TIMEOUT_MS,
|
|
287
|
+
}).catch(() => null);
|
|
288
|
+
if (challenge?.statusCode !== 200
|
|
289
|
+
|| challenge.value?.schemaVersion !== 1
|
|
290
|
+
|| challenge.value?.nonce !== challengeNonce
|
|
291
|
+
|| !verifyDashboardControlProof(
|
|
292
|
+
owner.secret, "challenge", challengeNonce, challenge.value?.proof,
|
|
293
|
+
)) {
|
|
294
|
+
throw listenerConflict(
|
|
295
|
+
`http://${dashboardAuthority(host, port)}/`,
|
|
296
|
+
"did not prove control of its private TaskChef ownership credential.",
|
|
297
|
+
{
|
|
298
|
+
staleTaskchefVersion: identity.taskchefVersion,
|
|
299
|
+
handoffRaceEligible: challenge === null,
|
|
300
|
+
},
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
return owner;
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const retirePriorOwner = async (identity) => {
|
|
307
|
+
const owner = await authenticatePriorOwner(identity);
|
|
308
|
+
const shutdownNonce = createDashboardControlNonce();
|
|
309
|
+
const shutdown = await requestDashboardJson({
|
|
310
|
+
host,
|
|
311
|
+
port,
|
|
312
|
+
path: DASHBOARD_CONTROL_SHUTDOWN_PATH,
|
|
313
|
+
method: "POST",
|
|
314
|
+
body: {
|
|
315
|
+
nonce: shutdownNonce,
|
|
316
|
+
proof: dashboardControlProof(owner.secret, "shutdown", shutdownNonce),
|
|
317
|
+
},
|
|
318
|
+
timeoutMs: HEALTH_TIMEOUT_MS,
|
|
319
|
+
}).catch(() => null);
|
|
320
|
+
if (shutdown?.statusCode !== 202 || shutdown.value?.accepted !== true) {
|
|
321
|
+
throw listenerConflict(
|
|
322
|
+
`http://${dashboardAuthority(host, port)}/`,
|
|
323
|
+
"refused authenticated TaskChef version handoff.",
|
|
324
|
+
{
|
|
325
|
+
staleTaskchefVersion: identity.taskchefVersion,
|
|
326
|
+
handoffRaceEligible: shutdown === null || shutdown?.statusCode === 409,
|
|
327
|
+
},
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
331
|
+
try {
|
|
332
|
+
await readIdentity({ host, port, timeoutMs: 100 });
|
|
333
|
+
} catch (error) {
|
|
334
|
+
if (listenerAbsent(error) || error?.code === "ECONNRESET") return;
|
|
335
|
+
}
|
|
336
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
337
|
+
}
|
|
338
|
+
throw listenerConflict(
|
|
339
|
+
`http://${dashboardAuthority(host, port)}/`,
|
|
340
|
+
"accepted authenticated handoff but did not release the listener.",
|
|
341
|
+
{
|
|
342
|
+
staleTaskchefVersion: identity.taskchefVersion,
|
|
343
|
+
handoffRaceEligible: true,
|
|
344
|
+
retryAuthentication: true,
|
|
345
|
+
},
|
|
346
|
+
);
|
|
347
|
+
};
|
|
348
|
+
|
|
129
349
|
const probe = async () => {
|
|
130
350
|
const url = `http://${dashboardAuthority(host, port)}/`;
|
|
131
351
|
let identity;
|
|
@@ -136,6 +356,43 @@ export function createDashboardManager({
|
|
|
136
356
|
throw listenerConflict(url, `is occupied but did not return a compatible identity (${error.message}).`);
|
|
137
357
|
}
|
|
138
358
|
const expected = expectedIdentity(canonicalWorkspace, taskchefVersion, serverVersion, launcher);
|
|
359
|
+
if (isExactIdentity(identity, expected)) return true;
|
|
360
|
+
const compatiblePrior = identity?.schemaVersion === 1
|
|
361
|
+
&& identity?.service === "taskchef-dashboard"
|
|
362
|
+
&& identity?.workspace === canonicalWorkspace
|
|
363
|
+
&& identity?.launcher === "mcp"
|
|
364
|
+
&& identity?.serverVersion === serverVersion
|
|
365
|
+
&& priorCompatibleVersion(identity?.taskchefVersion, taskchefVersion);
|
|
366
|
+
if (compatiblePrior) {
|
|
367
|
+
try {
|
|
368
|
+
await retirePriorOwner(identity);
|
|
369
|
+
return false;
|
|
370
|
+
} catch (handoffError) {
|
|
371
|
+
if (!handoffError?.handoffRaceEligible) throw handoffError;
|
|
372
|
+
let raceError = handoffError;
|
|
373
|
+
for (let attempt = 0; attempt < 40; attempt += 1) {
|
|
374
|
+
try {
|
|
375
|
+
const replacement = await readIdentity({ host, port, timeoutMs: 100 });
|
|
376
|
+
if (isExactIdentity(replacement, expected)) return true;
|
|
377
|
+
if (!isExactIdentity(replacement, identity)) throw raceError;
|
|
378
|
+
if (raceError.retryAuthentication) {
|
|
379
|
+
try {
|
|
380
|
+
await retirePriorOwner(identity);
|
|
381
|
+
return false;
|
|
382
|
+
} catch (error) {
|
|
383
|
+
if (!error?.handoffRaceEligible) throw error;
|
|
384
|
+
raceError = error;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
} catch (error) {
|
|
388
|
+
if (listenerAbsent(error) || error?.code === "ECONNRESET") return false;
|
|
389
|
+
throw error;
|
|
390
|
+
}
|
|
391
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
392
|
+
}
|
|
393
|
+
throw raceError;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
139
396
|
if (!isExactIdentity(identity, expected)) {
|
|
140
397
|
throw listenerConflict(
|
|
141
398
|
url,
|
|
@@ -150,6 +407,14 @@ export function createDashboardManager({
|
|
|
150
407
|
if (ownedServer) return publicResult("reused");
|
|
151
408
|
if (await probe()) return publicResult("reused");
|
|
152
409
|
try {
|
|
410
|
+
const secret = createDashboardControlSecret();
|
|
411
|
+
const control = {
|
|
412
|
+
secret,
|
|
413
|
+
onShutdown: () => {
|
|
414
|
+
retired = true;
|
|
415
|
+
return closeOwned();
|
|
416
|
+
},
|
|
417
|
+
};
|
|
153
418
|
ownedServer = await createServer({
|
|
154
419
|
workspace: canonicalWorkspace,
|
|
155
420
|
host,
|
|
@@ -157,7 +422,23 @@ export function createDashboardManager({
|
|
|
157
422
|
taskchefVersion,
|
|
158
423
|
serverVersion,
|
|
159
424
|
launcher,
|
|
425
|
+
control,
|
|
160
426
|
});
|
|
427
|
+
ownedSecret = secret;
|
|
428
|
+
try {
|
|
429
|
+
await writeOwner(canonicalWorkspace, dashboardOwnerMetadata({
|
|
430
|
+
workspace: canonicalWorkspace,
|
|
431
|
+
host,
|
|
432
|
+
port: ownedServer.port,
|
|
433
|
+
taskchefVersion,
|
|
434
|
+
serverVersion,
|
|
435
|
+
launcher,
|
|
436
|
+
secret,
|
|
437
|
+
}));
|
|
438
|
+
} catch (error) {
|
|
439
|
+
await closeOwned().catch(() => {});
|
|
440
|
+
throw error;
|
|
441
|
+
}
|
|
161
442
|
return publicResult("started");
|
|
162
443
|
} catch (error) {
|
|
163
444
|
if (error?.code !== "EADDRINUSE") throw error;
|
|
@@ -166,22 +447,57 @@ export function createDashboardManager({
|
|
|
166
447
|
}
|
|
167
448
|
};
|
|
168
449
|
|
|
450
|
+
const monitorExactReuse = () => {
|
|
451
|
+
if (reuseMonitor || ownedServer || closePromise || retired) return;
|
|
452
|
+
reuseMonitor = setInterval(() => {
|
|
453
|
+
if (ownedServer || closePromise || retired || reuseRecovery) return;
|
|
454
|
+
reuseRecovery = (async () => {
|
|
455
|
+
try {
|
|
456
|
+
const identity = await readIdentity({ host, port, timeoutMs: HEALTH_TIMEOUT_MS });
|
|
457
|
+
const expected = expectedIdentity(
|
|
458
|
+
canonicalWorkspace, taskchefVersion, serverVersion, launcher,
|
|
459
|
+
);
|
|
460
|
+
if (isExactIdentity(identity, expected)) return;
|
|
461
|
+
// A replacement listener with any other identity is never touched. Keep
|
|
462
|
+
// watching so this process can recover normally if that occupant exits.
|
|
463
|
+
} catch (error) {
|
|
464
|
+
if (!listenerAbsent(error) && error?.code !== "ECONNRESET") return;
|
|
465
|
+
const result = await ensureOnce();
|
|
466
|
+
if (result.action === "started" || ownedServer) stopReuseMonitor();
|
|
467
|
+
}
|
|
468
|
+
})().catch(() => {
|
|
469
|
+
// Startup diagnostics remain available through an explicit ensure call.
|
|
470
|
+
}).finally(() => {
|
|
471
|
+
reuseRecovery = null;
|
|
472
|
+
});
|
|
473
|
+
}, reuseMonitorIntervalMs);
|
|
474
|
+
reuseMonitor.unref?.();
|
|
475
|
+
};
|
|
476
|
+
|
|
169
477
|
return {
|
|
170
478
|
async ensure() {
|
|
171
|
-
if (closePromise) throw new Error("TaskChef dashboard manager is shutting down");
|
|
479
|
+
if (closePromise || retired) throw new Error("TaskChef dashboard manager is shutting down");
|
|
172
480
|
if (ensurePromise) {
|
|
173
481
|
await ensurePromise;
|
|
174
482
|
return publicResult("reused");
|
|
175
483
|
}
|
|
176
484
|
ensurePromise = ensureOnce().finally(() => { ensurePromise = null; });
|
|
177
|
-
|
|
485
|
+
const result = await ensurePromise;
|
|
486
|
+
if (result.action === "reused" && !ownedServer) monitorExactReuse();
|
|
487
|
+
return result;
|
|
178
488
|
},
|
|
179
489
|
async close() {
|
|
180
490
|
closePromise ??= (async () => {
|
|
491
|
+
stopReuseMonitor();
|
|
181
492
|
await ensurePromise?.catch(() => {});
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
await
|
|
493
|
+
await reuseRecovery?.catch(() => {});
|
|
494
|
+
const secret = ownedSecret;
|
|
495
|
+
if (secret) await closeOwned();
|
|
496
|
+
else {
|
|
497
|
+
const server = ownedServer;
|
|
498
|
+
ownedServer = null;
|
|
499
|
+
await server?.close();
|
|
500
|
+
}
|
|
185
501
|
})();
|
|
186
502
|
return closePromise;
|
|
187
503
|
},
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { open, rename, unlink } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
export const DASHBOARD_CONTROL_VERSION = 1;
|
|
7
|
+
export const DASHBOARD_OWNER_FILE = ".taskchef-dashboard-owner.json";
|
|
8
|
+
export const DASHBOARD_CONTROL_CHALLENGE_PATH = "/api/control/challenge";
|
|
9
|
+
export const DASHBOARD_CONTROL_SHUTDOWN_PATH = "/api/control/shutdown";
|
|
10
|
+
|
|
11
|
+
const OWNER_MAX_BYTES = 4_096;
|
|
12
|
+
const SECRET_PATTERN = /^[a-f0-9]{64}$/;
|
|
13
|
+
const NONCE_PATTERN = /^[a-f0-9]{32,128}$/;
|
|
14
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1"]);
|
|
15
|
+
|
|
16
|
+
function ownerPath(workspace) {
|
|
17
|
+
return path.join(workspace, DASHBOARD_OWNER_FILE);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function exactKeys(value, keys) {
|
|
21
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
22
|
+
&& Object.keys(value).sort().join("\0") === [...keys].sort().join("\0");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function createDashboardControlSecret() {
|
|
26
|
+
return randomBytes(32).toString("hex");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function createDashboardControlNonce() {
|
|
30
|
+
return randomBytes(24).toString("hex");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function validDashboardControlSecret(secret) {
|
|
34
|
+
return typeof secret === "string" && SECRET_PATTERN.test(secret);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function dashboardControlProof(secret, action, nonce) {
|
|
38
|
+
return createHmac("sha256", Buffer.from(secret, "hex"))
|
|
39
|
+
.update(`taskchef-dashboard-control-v${DASHBOARD_CONTROL_VERSION}:${action}:${nonce}`)
|
|
40
|
+
.digest("hex");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function validDashboardControlNonce(nonce) {
|
|
44
|
+
return typeof nonce === "string" && NONCE_PATTERN.test(nonce);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function verifyDashboardControlProof(secret, action, nonce, proof) {
|
|
48
|
+
if (!validDashboardControlSecret(secret) || !validDashboardControlNonce(nonce)
|
|
49
|
+
|| typeof proof !== "string" || !SECRET_PATTERN.test(proof)) return false;
|
|
50
|
+
const expected = Buffer.from(dashboardControlProof(secret, action, nonce), "hex");
|
|
51
|
+
const supplied = Buffer.from(proof, "hex");
|
|
52
|
+
return expected.length === supplied.length && timingSafeEqual(expected, supplied);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function dashboardOwnerMetadata({
|
|
56
|
+
workspace,
|
|
57
|
+
host,
|
|
58
|
+
port,
|
|
59
|
+
taskchefVersion,
|
|
60
|
+
serverVersion,
|
|
61
|
+
launcher,
|
|
62
|
+
secret,
|
|
63
|
+
}) {
|
|
64
|
+
return {
|
|
65
|
+
schemaVersion: 1,
|
|
66
|
+
service: "taskchef-dashboard-owner",
|
|
67
|
+
controlVersion: DASHBOARD_CONTROL_VERSION,
|
|
68
|
+
workspace,
|
|
69
|
+
host,
|
|
70
|
+
port,
|
|
71
|
+
taskchefVersion,
|
|
72
|
+
serverVersion,
|
|
73
|
+
launcher,
|
|
74
|
+
secret,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function validOwner(value) {
|
|
79
|
+
const keys = [
|
|
80
|
+
"schemaVersion", "service", "controlVersion", "workspace", "host", "port",
|
|
81
|
+
"taskchefVersion", "serverVersion", "launcher", "secret",
|
|
82
|
+
];
|
|
83
|
+
return exactKeys(value, keys)
|
|
84
|
+
&& value.schemaVersion === 1
|
|
85
|
+
&& value.service === "taskchef-dashboard-owner"
|
|
86
|
+
&& value.controlVersion === DASHBOARD_CONTROL_VERSION
|
|
87
|
+
&& typeof value.workspace === "string"
|
|
88
|
+
&& LOOPBACK_HOSTS.has(value.host)
|
|
89
|
+
&& Number.isInteger(value.port) && value.port >= 0 && value.port <= 65_535
|
|
90
|
+
&& typeof value.taskchefVersion === "string"
|
|
91
|
+
&& typeof value.serverVersion === "string"
|
|
92
|
+
&& value.launcher === "mcp"
|
|
93
|
+
&& typeof value.secret === "string" && SECRET_PATTERN.test(value.secret);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function readDashboardOwner(workspace) {
|
|
97
|
+
const filePath = ownerPath(workspace);
|
|
98
|
+
const handle = await open(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
99
|
+
try {
|
|
100
|
+
const info = await handle.stat();
|
|
101
|
+
if (!info.isFile() || (info.mode & 0o777) !== 0o600) {
|
|
102
|
+
throw new Error("dashboard owner record is not a private regular file");
|
|
103
|
+
}
|
|
104
|
+
if (typeof process.getuid === "function" && info.uid !== process.getuid()) {
|
|
105
|
+
throw new Error("dashboard owner record has a different owner");
|
|
106
|
+
}
|
|
107
|
+
if (info.size > OWNER_MAX_BYTES) throw new Error("dashboard owner record is too large");
|
|
108
|
+
const buffer = Buffer.alloc(OWNER_MAX_BYTES + 1);
|
|
109
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
|
|
110
|
+
if (bytesRead > OWNER_MAX_BYTES) throw new Error("dashboard owner record is too large");
|
|
111
|
+
const value = JSON.parse(buffer.subarray(0, bytesRead).toString("utf8"));
|
|
112
|
+
if (!validOwner(value) || value.workspace !== workspace) {
|
|
113
|
+
throw new Error("dashboard owner record is invalid");
|
|
114
|
+
}
|
|
115
|
+
return value;
|
|
116
|
+
} finally {
|
|
117
|
+
await handle.close();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function writeDashboardOwner(workspace, value) {
|
|
122
|
+
if (!validOwner(value) || value.workspace !== workspace) {
|
|
123
|
+
throw new Error("dashboard owner record does not match its canonical workspace");
|
|
124
|
+
}
|
|
125
|
+
const filePath = ownerPath(workspace);
|
|
126
|
+
const temporary = `${filePath}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`;
|
|
127
|
+
try {
|
|
128
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
129
|
+
try {
|
|
130
|
+
await handle.writeFile(`${JSON.stringify(value)}\n`, { encoding: "utf8" });
|
|
131
|
+
await handle.sync();
|
|
132
|
+
} finally {
|
|
133
|
+
await handle.close();
|
|
134
|
+
}
|
|
135
|
+
await rename(temporary, filePath);
|
|
136
|
+
} catch (error) {
|
|
137
|
+
await unlink(temporary).catch(() => {});
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
package/src/dashboard.js
CHANGED
|
@@ -25,6 +25,14 @@ import { DASHBOARD_SERVER_VERSION, TASKCHEF_VERSION } from "./version.js";
|
|
|
25
25
|
import { taskGitHubProjection } from "./dashboard/github-links.js";
|
|
26
26
|
import { CODEX_CHAT_ARCHIVE_ENABLED } from "./dashboard/state.js";
|
|
27
27
|
import { createUsageTracker } from "./usage-tracker.js";
|
|
28
|
+
import {
|
|
29
|
+
DASHBOARD_CONTROL_CHALLENGE_PATH,
|
|
30
|
+
DASHBOARD_CONTROL_SHUTDOWN_PATH,
|
|
31
|
+
dashboardControlProof,
|
|
32
|
+
validDashboardControlNonce,
|
|
33
|
+
validDashboardControlSecret,
|
|
34
|
+
verifyDashboardControlProof,
|
|
35
|
+
} from "./dashboard-ownership.js";
|
|
28
36
|
|
|
29
37
|
const TASKS_FILE_NAME = "tasks.jsonl";
|
|
30
38
|
const STATIC_ROOT = fileURLToPath(new URL("./dashboard/", import.meta.url));
|
|
@@ -544,6 +552,7 @@ export async function createDashboardServer({
|
|
|
544
552
|
taskchefVersion = TASKCHEF_VERSION,
|
|
545
553
|
serverVersion = DASHBOARD_SERVER_VERSION,
|
|
546
554
|
usageTracker = null,
|
|
555
|
+
control = null,
|
|
547
556
|
} = {}) {
|
|
548
557
|
if (!LOOPBACK_HOSTS.has(host)) {
|
|
549
558
|
throw new Error("dashboard host must be a loopback address");
|
|
@@ -557,6 +566,11 @@ export async function createDashboardServer({
|
|
|
557
566
|
if (!new Set(["mcp", "standalone"]).has(launcher)) {
|
|
558
567
|
throw new Error("dashboard launcher must be mcp or standalone");
|
|
559
568
|
}
|
|
569
|
+
if (control !== null && (launcher !== "mcp"
|
|
570
|
+
|| !validDashboardControlSecret(control?.secret)
|
|
571
|
+
|| typeof control?.onShutdown !== "function")) {
|
|
572
|
+
throw new Error("dashboard control requires a valid MCP ownership controller");
|
|
573
|
+
}
|
|
560
574
|
const monitor = new DashboardMonitor(workspace, monitorOptions);
|
|
561
575
|
await monitor.start();
|
|
562
576
|
const taskUsageTracker = usageTracker ?? createUsageTracker({ workspace: monitor.workspace });
|
|
@@ -574,6 +588,7 @@ export async function createDashboardServer({
|
|
|
574
588
|
}
|
|
575
589
|
const clients = new Set();
|
|
576
590
|
const archiveRequests = new Set();
|
|
591
|
+
const controlNonces = new Set();
|
|
577
592
|
let allowedAuthority;
|
|
578
593
|
let allowedOrigin;
|
|
579
594
|
|
|
@@ -615,6 +630,41 @@ export async function createDashboardServer({
|
|
|
615
630
|
return;
|
|
616
631
|
}
|
|
617
632
|
|
|
633
|
+
if (url.pathname === DASHBOARD_CONTROL_CHALLENGE_PATH && method === "GET") {
|
|
634
|
+
const nonce = url.searchParams.get("nonce");
|
|
635
|
+
if (!control || !validDashboardControlNonce(nonce)) {
|
|
636
|
+
sendJson(response, control ? 400 : 404, { message: "Not found." });
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
sendJson(response, 200, {
|
|
640
|
+
schemaVersion: 1,
|
|
641
|
+
nonce,
|
|
642
|
+
proof: dashboardControlProof(control.secret, "challenge", nonce),
|
|
643
|
+
});
|
|
644
|
+
return;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
if (url.pathname === DASHBOARD_CONTROL_SHUTDOWN_PATH && method === "POST") {
|
|
648
|
+
if (!control) {
|
|
649
|
+
sendJson(response, 404, { message: "Not found." });
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const body = await readBoundedJsonBody(request);
|
|
653
|
+
const nonce = body?.nonce;
|
|
654
|
+
if (!verifyDashboardControlProof(control.secret, "shutdown", nonce, body?.proof)) {
|
|
655
|
+
sendJson(response, 403, { message: "Dashboard control authentication failed." });
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (controlNonces.has(nonce)) {
|
|
659
|
+
sendJson(response, 409, { message: "Dashboard control credential was already used." });
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
controlNonces.add(nonce);
|
|
663
|
+
sendJson(response, 202, { schemaVersion: 1, accepted: true });
|
|
664
|
+
setImmediate(() => { void control.onShutdown().catch(() => {}); });
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
|
|
618
668
|
if (url.pathname === "/api/snapshot" && (method === "GET" || method === "HEAD")) {
|
|
619
669
|
if (method === "HEAD") {
|
|
620
670
|
response.writeHead(200, securityHeaders("application/json; charset=utf-8"));
|
|
@@ -882,8 +932,10 @@ export async function createDashboardServer({
|
|
|
882
932
|
monitor.off("monitorError", errorListener);
|
|
883
933
|
monitor.close();
|
|
884
934
|
for (const client of clients) client.close();
|
|
885
|
-
|
|
935
|
+
const closed = new Promise((resolve, reject) => server.close((error) =>
|
|
886
936
|
error ? reject(error) : resolve()));
|
|
937
|
+
server.closeAllConnections?.();
|
|
938
|
+
await closed;
|
|
887
939
|
},
|
|
888
940
|
};
|
|
889
941
|
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
2
|
+
|
|
3
|
+
import { createTaskChefMcpServer } from "./mcp.js";
|
|
4
|
+
import { installParentLossMonitor } from "./process-lifecycle.js";
|
|
5
|
+
|
|
6
|
+
const CONNECTION_SHUTDOWN_GRACE_MS = 1_000;
|
|
7
|
+
|
|
8
|
+
export async function runTaskChefMcpProcess({
|
|
9
|
+
server = createTaskChefMcpServer(),
|
|
10
|
+
transport = new StdioServerTransport(),
|
|
11
|
+
processObject = process,
|
|
12
|
+
installParentMonitor = installParentLossMonitor,
|
|
13
|
+
} = {}) {
|
|
14
|
+
let shutdownPromise = null;
|
|
15
|
+
let parentMonitor = null;
|
|
16
|
+
let resolveTerminal;
|
|
17
|
+
let resolveConnectionSettled;
|
|
18
|
+
const originalProtocolClose = server.server?.onclose;
|
|
19
|
+
const terminal = new Promise((resolve) => { resolveTerminal = resolve; });
|
|
20
|
+
const connectionSettled = new Promise((resolve) => { resolveConnectionSettled = resolve; });
|
|
21
|
+
const removeLifecycleListeners = () => {
|
|
22
|
+
processObject.off("SIGINT", shutdown);
|
|
23
|
+
processObject.off("SIGTERM", shutdown);
|
|
24
|
+
processObject.stdin.off("end", shutdown);
|
|
25
|
+
processObject.stdin.off("close", shutdown);
|
|
26
|
+
if (server.server?.onclose === protocolClose) server.server.onclose = originalProtocolClose;
|
|
27
|
+
};
|
|
28
|
+
const protocolClose = () => {
|
|
29
|
+
try { originalProtocolClose?.(); } finally { void shutdown(); }
|
|
30
|
+
};
|
|
31
|
+
if (server.server) server.server.onclose = protocolClose;
|
|
32
|
+
const shutdown = () => {
|
|
33
|
+
shutdownPromise ??= (async () => {
|
|
34
|
+
try {
|
|
35
|
+
await Promise.resolve().then(() => transport.close?.()).catch(() => {});
|
|
36
|
+
let connectionTimer;
|
|
37
|
+
await Promise.race([
|
|
38
|
+
connectionSettled,
|
|
39
|
+
new Promise((resolve) => {
|
|
40
|
+
connectionTimer = setTimeout(resolve, CONNECTION_SHUTDOWN_GRACE_MS);
|
|
41
|
+
}),
|
|
42
|
+
]);
|
|
43
|
+
clearTimeout(connectionTimer);
|
|
44
|
+
await server.close();
|
|
45
|
+
} catch (error) {
|
|
46
|
+
processObject.stderr.write(`TaskChef MCP shutdown failed: ${error.message}\n`);
|
|
47
|
+
processObject.exitCode = 1;
|
|
48
|
+
} finally {
|
|
49
|
+
parentMonitor?.close();
|
|
50
|
+
removeLifecycleListeners();
|
|
51
|
+
resolveTerminal();
|
|
52
|
+
}
|
|
53
|
+
})();
|
|
54
|
+
return shutdownPromise;
|
|
55
|
+
};
|
|
56
|
+
processObject.once("SIGINT", shutdown);
|
|
57
|
+
processObject.once("SIGTERM", shutdown);
|
|
58
|
+
processObject.stdin.once("end", shutdown);
|
|
59
|
+
processObject.stdin.once("close", shutdown);
|
|
60
|
+
parentMonitor = installParentMonitor({ onParentLoss: shutdown });
|
|
61
|
+
const connection = server.connect(transport).then(
|
|
62
|
+
() => ({ connected: true, error: null }),
|
|
63
|
+
(error) => ({ connected: false, error }),
|
|
64
|
+
).finally(resolveConnectionSettled);
|
|
65
|
+
const outcome = await Promise.race([
|
|
66
|
+
connection,
|
|
67
|
+
terminal.then(() => ({ terminated: true })),
|
|
68
|
+
]);
|
|
69
|
+
if (outcome.terminated) return;
|
|
70
|
+
if (outcome.error) {
|
|
71
|
+
if (shutdownPromise) await shutdownPromise;
|
|
72
|
+
else {
|
|
73
|
+
parentMonitor.close();
|
|
74
|
+
removeLifecycleListeners();
|
|
75
|
+
}
|
|
76
|
+
throw outcome.error;
|
|
77
|
+
}
|
|
78
|
+
await terminal;
|
|
79
|
+
}
|
package/src/mcp.js
CHANGED
|
@@ -123,6 +123,9 @@ function toolResult(key, value, message) {
|
|
|
123
123
|
|
|
124
124
|
function dashboardAutostartDiagnostic(error) {
|
|
125
125
|
const message = typeof error?.message === "string" ? error.message : "";
|
|
126
|
+
if (typeof error?.staleTaskchefVersion === "string") {
|
|
127
|
+
return `TaskChef dashboard autostart skipped: verified older TaskChef ${error.staleTaskchefVersion} listener could not complete authenticated handoff; it was left untouched.`;
|
|
128
|
+
}
|
|
126
129
|
if (error?.code === "EADDRINUSE" || /port conflict|already in use/i.test(message)) {
|
|
127
130
|
return "TaskChef dashboard autostart skipped: port 127.0.0.1:3210 is unavailable; the listener was left untouched.";
|
|
128
131
|
}
|
|
@@ -176,10 +179,16 @@ export function createTaskChefMcpServer({
|
|
|
176
179
|
|
|
177
180
|
const originalClose = server.close.bind(server);
|
|
178
181
|
let closePromise = null;
|
|
182
|
+
let closing = false;
|
|
179
183
|
server.close = async () => {
|
|
184
|
+
closing = true;
|
|
180
185
|
closePromise ??= (async () => {
|
|
181
|
-
await
|
|
182
|
-
|
|
186
|
+
const results = await Promise.allSettled([
|
|
187
|
+
dashboardManager.close(),
|
|
188
|
+
originalClose(),
|
|
189
|
+
]);
|
|
190
|
+
const failure = results.find((result) => result.status === "rejected");
|
|
191
|
+
if (failure) throw failure.reason;
|
|
183
192
|
})();
|
|
184
193
|
return closePromise;
|
|
185
194
|
};
|
|
@@ -349,8 +358,13 @@ export function createTaskChefMcpServer({
|
|
|
349
358
|
});
|
|
350
359
|
server.connect = async (...args) => {
|
|
351
360
|
await autostartDashboard();
|
|
361
|
+
if (closing) throw new Error("TaskChef MCP server is shutting down");
|
|
352
362
|
try {
|
|
353
363
|
await originalConnect(...args);
|
|
364
|
+
if (closing) {
|
|
365
|
+
await originalClose();
|
|
366
|
+
throw new Error("TaskChef MCP server shut down during transport startup");
|
|
367
|
+
}
|
|
354
368
|
} catch (error) {
|
|
355
369
|
await Promise.allSettled([
|
|
356
370
|
Promise.resolve().then(() => dashboardManager.close()),
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const DEFAULT_PARENT_CHECK_INTERVAL_MS = 1_000;
|
|
2
|
+
|
|
3
|
+
export function installParentLossMonitor({
|
|
4
|
+
onParentLoss,
|
|
5
|
+
parentPid = process.ppid,
|
|
6
|
+
readParentPid = () => process.ppid,
|
|
7
|
+
intervalMs = DEFAULT_PARENT_CHECK_INTERVAL_MS,
|
|
8
|
+
schedule = setInterval,
|
|
9
|
+
cancel = clearInterval,
|
|
10
|
+
} = {}) {
|
|
11
|
+
if (typeof onParentLoss !== "function") throw new Error("parent-loss callback is required");
|
|
12
|
+
let stopped = false;
|
|
13
|
+
let notified = false;
|
|
14
|
+
const check = () => {
|
|
15
|
+
if (stopped || notified || readParentPid() === parentPid) return;
|
|
16
|
+
notified = true;
|
|
17
|
+
void Promise.resolve().then(onParentLoss).catch(() => {});
|
|
18
|
+
};
|
|
19
|
+
const timer = schedule(check, intervalMs);
|
|
20
|
+
timer.unref?.();
|
|
21
|
+
return {
|
|
22
|
+
check,
|
|
23
|
+
close() {
|
|
24
|
+
if (stopped) return;
|
|
25
|
+
stopped = true;
|
|
26
|
+
cancel(timer);
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|