talon-agent 5.2.0 → 5.2.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.
@@ -8,6 +8,17 @@
8
8
  * source tree on disk, so {@link getRepoRoot} returns `null` and the
9
9
  * `/update` command is never registered.
10
10
  *
11
+ * The install is verified before anyone hands off to it. `npm install`
12
+ * rewrites node_modules underneath the still-running process, so a bad
13
+ * dependency resolution does not surface until the *successor* imports
14
+ * the tree — detached, with its output going nowhere, at the one moment
15
+ * the daemon has no one left to report to. On 2026-09-18 that cost a
16
+ * 45-minute outage. The last step of an update therefore runs the new
17
+ * tree in a child with {@link BOOT_SMOKE_FLAG}: it resolves the daemon's
18
+ * entire import graph and exits without booting. If it fails, the update
19
+ * fails — the caller reports it to the chat and the current process, the
20
+ * one that still works, keeps running.
21
+ *
11
22
  * The update force-syncs the checkout to the remote branch with
12
23
  * `git reset --hard` (plus `git clean -fd`), discarding any local edits
13
24
  * or diverged commits. A bot host is meant to mirror the remote exactly,
@@ -22,6 +33,11 @@ import { execFile } from "node:child_process";
22
33
  import { existsSync } from "node:fs";
23
34
  import { dirname, join } from "node:path";
24
35
  import { fileURLToPath } from "node:url";
36
+ import {
37
+ BOOT_SMOKE_FLAG,
38
+ BOOT_SMOKE_OK,
39
+ successorCommand,
40
+ } from "../daemon/respawn.js";
25
41
 
26
42
  /** Tuning knobs for {@link runSelfUpdate}. */
27
43
  export interface UpdateOptions {
@@ -36,6 +52,11 @@ export interface UpdateOptions {
36
52
  setup?: readonly string[];
37
53
  /** Override the repo root (tests). Defaults to {@link getRepoRoot}. */
38
54
  repoRoot?: string;
55
+ /**
56
+ * The command that re-runs this process, used for the post-install
57
+ * import check. Defaults to our own argv (tests override it).
58
+ */
59
+ entry?: { cmd: string; args: readonly string[] };
39
60
  /** Injectable command runner (tests). */
40
61
  runner?: CommandRunner;
41
62
  }
@@ -70,6 +91,8 @@ export type CommandRunner = (
70
91
  ) => Promise<{ ok: boolean; output: string }>;
71
92
 
72
93
  const GIT_TIMEOUT_MS = 60_000;
94
+ /** A cold import of the whole daemon graph, on a busy host. */
95
+ const VERIFY_TIMEOUT_MS = 180_000;
73
96
  const INSTALL_TIMEOUT_MS = 300_000;
74
97
  const SETUP_TIMEOUT_MS = 300_000;
75
98
  const MAX_OUTPUT_BUFFER = 16 * 1024 * 1024;
@@ -265,5 +288,26 @@ export async function runSelfUpdate(
265
288
  }
266
289
  }
267
290
 
291
+ const entry = opts.entry ?? successorCommand();
292
+ const verify = await record(
293
+ "verify import",
294
+ entry.cmd,
295
+ [...entry.args, BOOT_SMOKE_FLAG],
296
+ VERIFY_TIMEOUT_MS,
297
+ );
298
+ if (!verify.ok || !verify.output.includes(BOOT_SMOKE_OK)) {
299
+ return {
300
+ ok: false,
301
+ repoRoot,
302
+ steps,
303
+ before,
304
+ after,
305
+ changed,
306
+ error:
307
+ `the updated tree does not import — not restarting into it. ` +
308
+ `Still running ${before}; the checkout is at ${after}.`,
309
+ };
310
+ }
311
+
268
312
  return { ok: true, repoRoot, steps, before, after, changed: true };
269
313
  }
package/src/index.ts CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
2
2
  * Talon entry shim.
3
3
  *
4
- * Dispatches the hidden `_mcp-launch` (MCP supervisor) and `_lua-run`
5
- * (WASM Lua trigger runner) subcommands BEFORE the app graph loads —
6
- * both are Talon re-invoking itself (see core/mcp-hub/launcher.ts). The
7
- * dynamic import keeps these helper processes light: they evaluate this
8
- * shim and their own module, never the backends/frontends/plugins.
4
+ * Dispatches the hidden `_mcp-launch` (MCP supervisor), `_lua-run`
5
+ * (WASM Lua trigger runner) and `_handoff-watch` (restart/update
6
+ * witness) subcommands BEFORE the app graph loads — all three are Talon
7
+ * re-invoking itself (see core/mcp-hub/launcher.ts, core/daemon/
8
+ * handoff.ts). The dynamic import keeps these helper processes light:
9
+ * they evaluate this shim and their own module, never the
10
+ * backends/frontends/plugins.
9
11
  */
10
12
 
11
13
  import {
@@ -13,11 +15,17 @@ import {
13
15
  runSupervisor,
14
16
  } from "./core/mcp-hub/launcher.js";
15
17
  import { LUA_RUN_SUBCOMMAND, runLuaMain } from "./core/scripts/lua.js";
18
+ import {
19
+ HANDOFF_WATCH_SUBCOMMAND,
20
+ runHandoffWatch,
21
+ } from "./core/daemon/handoff.js";
16
22
 
17
23
  if (process.argv[2] === MCP_LAUNCH_SUBCOMMAND) {
18
24
  await runSupervisor(process.argv.slice(3));
19
25
  } else if (process.argv[2] === LUA_RUN_SUBCOMMAND) {
20
26
  await runLuaMain(process.argv.slice(3));
27
+ } else if (process.argv[2] === HANDOFF_WATCH_SUBCOMMAND) {
28
+ await runHandoffWatch(process.argv.slice(3));
21
29
  } else {
22
30
  await import("./app.js");
23
31
  }
@@ -25,9 +25,9 @@
25
25
  * with "428 Precondition Required". @playwright/mcp is therefore pinned
26
26
  * exactly in package.json (0.0.56 → playwright 1.58.x, matching python
27
27
  * playwright 1.58 which hosts Camoufox — camoufox itself caps playwright at
28
- * <1.61, so the node client cannot chase latest). Bump BOTH sides together,
29
- * deliberately — do not let a routine dependency bump move one without the
30
- * other.
28
+ * <1.61, so the node client cannot chase latest). The pin is enforced, not
29
+ * just documented: see version-coupling.ts (unit test that fails CI on a
30
+ * bump, validateConfig refusal, and a live handshake probe at init).
31
31
  */
32
32
 
33
33
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -35,6 +35,12 @@ import { dirname, resolve } from "node:path";
35
35
  import type { TalonPlugin } from "../../core/plugin/types.js";
36
36
  import { files } from "../../util/paths.js";
37
37
  import { log } from "../../util/log.js";
38
+ import {
39
+ ENDPOINT_PLAYWRIGHT_MINOR,
40
+ bundledPlaywrightVersion,
41
+ couplingError,
42
+ probeEndpoint,
43
+ } from "./version-coupling.js";
38
44
 
39
45
  export function createPlaywrightPlugin(config: {
40
46
  browser?: string;
@@ -141,6 +147,13 @@ export function createPlaywrightPlugin(config: {
141
147
  );
142
148
  }
143
149
 
150
+ // Endpoint mode: refuse to start on a client/server minor mismatch
151
+ // rather than fail every tool call later (see version-coupling.ts).
152
+ if (endpoint) {
153
+ const coupling = couplingError(bundledPlaywrightVersion());
154
+ if (coupling) errors.push(coupling);
155
+ }
156
+
144
157
  return errors.length > 0 ? errors : undefined;
145
158
  },
146
159
 
@@ -149,6 +162,29 @@ export function createPlaywrightPlugin(config: {
149
162
  "playwright",
150
163
  `Ready (${endpoint ? `Camoufox @ ${endpoint}` : `${browser}, headless=${headless}`})`,
151
164
  );
165
+ if (!endpoint) return;
166
+ // Live handshake with the bundled client version: catches a drifted
167
+ // server (the static check only knows the expected minor).
168
+ const client =
169
+ bundledPlaywrightVersion() ?? `${ENDPOINT_PLAYWRIGHT_MINOR}.0`;
170
+ const probe = await probeEndpoint(endpoint, client);
171
+ switch (probe.state) {
172
+ case "match":
173
+ log("playwright", `Endpoint handshake OK (Playwright ${client})`);
174
+ break;
175
+ case "mismatch":
176
+ log(
177
+ "playwright",
178
+ `ERROR: endpoint ${endpoint} is on Playwright ${probe.server} but the bundled client is ${probe.client} — every browser tool call will fail with 428. Align the python playwright hosting Camoufox with ENDPOINT_PLAYWRIGHT_MINOR (${ENDPOINT_PLAYWRIGHT_MINOR}) or re-pin @playwright/mcp.`,
179
+ );
180
+ break;
181
+ case "unreachable":
182
+ log(
183
+ "playwright",
184
+ `Warning: endpoint ${endpoint} did not answer the handshake probe (${probe.reason}); browser tools will fail until it is up.`,
185
+ );
186
+ break;
187
+ }
152
188
  },
153
189
  };
154
190
  }
@@ -28,6 +28,11 @@ import {
28
28
  type ProvisionOutcome,
29
29
  } from "../../core/plugin/provision.js";
30
30
  import { dirs } from "../../util/paths.js";
31
+ import {
32
+ ENDPOINT_PLAYWRIGHT_MINOR,
33
+ bundledPlaywrightVersion,
34
+ couplingError,
35
+ } from "./version-coupling.js";
31
36
 
32
37
  /** Engines whose builds Playwright manages (vs system channels). */
33
38
  const MANAGED_ENGINES = new Set(["chromium", "firefox", "webkit"]);
@@ -54,6 +59,8 @@ export interface PlaywrightProvisionDeps {
54
59
  listDir?: (p: string) => string[];
55
60
  /** playwright-core's browser registry (default: browsers.json beside the CLI). */
56
61
  registry?: BrowserDescriptor[];
62
+ /** Bundled playwright-core version (default: read from node_modules). */
63
+ bundledPlaywrightVersion?: string;
57
64
  }
58
65
 
59
66
  /** One entry of playwright-core's browsers.json. */
@@ -287,12 +294,26 @@ export function inspectPlaywright(
287
294
  deps: PlaywrightProvisionDeps = {},
288
295
  ): DoctorCheck[] {
289
296
  if (section.endpoint ?? section.endpointFile) {
297
+ const bundled = deps.bundledPlaywrightVersion ?? bundledPlaywrightVersion();
298
+ const coupling = couplingError(bundled);
290
299
  return [
291
300
  {
292
301
  label: "Playwright: remote endpoint mode",
293
302
  status: "info",
294
303
  detail: "browser lives on the remote end",
295
304
  },
305
+ coupling
306
+ ? {
307
+ label: "Playwright: client/endpoint version mismatch",
308
+ status: "fail",
309
+ detail: coupling,
310
+ issue: true,
311
+ }
312
+ : {
313
+ label: `Playwright: client on endpoint minor ${ENDPOINT_PLAYWRIGHT_MINOR}`,
314
+ status: "ok",
315
+ detail: bundled ? `playwright-core ${bundled}` : undefined,
316
+ },
296
317
  ];
297
318
  }
298
319
  const browser = section.browser ?? "chromium";
@@ -0,0 +1,195 @@
1
+ /**
2
+ * Endpoint-mode version coupling — the machinery behind the pin.
3
+ *
4
+ * In endpoint mode the MCP child connects to a remote Playwright server
5
+ * (the python-playwright process hosting Camoufox). Playwright's server
6
+ * refuses the WebSocket upgrade with "428 Precondition Required" when the
7
+ * client's playwright MINOR (sent in the User-Agent, `Playwright/x.y.z`)
8
+ * differs from its own — so a client bump that nobody noticed turns every
9
+ * browser tool call into an opaque error at the worst possible moment.
10
+ *
11
+ * Three guards, all keyed on ENDPOINT_PLAYWRIGHT_MINOR:
12
+ * - a unit test asserts the playwright-core that @playwright/mcp bundles is
13
+ * on that minor, so a dependency bump goes red in CI instead of green;
14
+ * - `validateConfig` refuses to start the plugin on a mismatch, with a
15
+ * message that names both versions and the fix;
16
+ * - `probeEndpoint` performs the real handshake at init and reports the
17
+ * server's verdict, so a drifted *server* is caught too.
18
+ *
19
+ * Bumping: change ENDPOINT_PLAYWRIGHT_MINOR and `@playwright/mcp` in
20
+ * package.json in the same commit, after upgrading the python side
21
+ * (camoufox caps python-playwright, so the node side cannot chase latest).
22
+ */
23
+
24
+ import { randomBytes } from "node:crypto";
25
+ import { readFileSync } from "node:fs";
26
+ import { request as httpRequest } from "node:http";
27
+ import { request as httpsRequest } from "node:https";
28
+ import { resolve } from "node:path";
29
+
30
+ /**
31
+ * Playwright minor of the remote endpoint (python playwright behind
32
+ * Camoufox). Must equal the minor of the playwright-core bundled by the
33
+ * pinned @playwright/mcp — see the header comment before changing it.
34
+ */
35
+ export const ENDPOINT_PLAYWRIGHT_MINOR = "1.58";
36
+
37
+ /** "1.58.0-alpha-2026-01-16" → "1.58". */
38
+ export function minorOf(version: string): string {
39
+ const m = version.match(/^(\d+)\.(\d+)/);
40
+ return m ? `${m[1]}.${m[2]}` : version;
41
+ }
42
+
43
+ function defaultModulesRoot(): string {
44
+ return resolve(import.meta.dirname ?? ".", "../../../node_modules");
45
+ }
46
+
47
+ /** Version of the playwright-core the MCP child will run with. */
48
+ export function bundledPlaywrightVersion(
49
+ modulesRoot: string = defaultModulesRoot(),
50
+ ): string | undefined {
51
+ try {
52
+ const pkg = JSON.parse(
53
+ readFileSync(
54
+ resolve(modulesRoot, "playwright-core/package.json"),
55
+ "utf-8",
56
+ ),
57
+ ) as { version?: string };
58
+ return pkg.version;
59
+ } catch {
60
+ return undefined;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Static check: does the bundled client sit on the endpoint's minor?
66
+ * Returns an error message, or undefined when coupled (or when the bundle
67
+ * cannot be read — that is reported separately as a missing install).
68
+ */
69
+ export function couplingError(
70
+ bundled: string | undefined,
71
+ expectedMinor: string = ENDPOINT_PLAYWRIGHT_MINOR,
72
+ ): string | undefined {
73
+ if (!bundled) return undefined;
74
+ const got = minorOf(bundled);
75
+ if (got === expectedMinor) return undefined;
76
+ return (
77
+ `@playwright/mcp bundles playwright-core ${bundled} (minor ${got}) but the ` +
78
+ `remote endpoint is on Playwright ${expectedMinor} — every browser tool call ` +
79
+ `would fail with "428 Precondition Required". Pin @playwright/mcp to the ` +
80
+ `release that bundles playwright-core ${expectedMinor}.x, or bump ` +
81
+ `ENDPOINT_PLAYWRIGHT_MINOR together with the python side ` +
82
+ `(src/plugins/playwright/version-coupling.ts).`
83
+ );
84
+ }
85
+
86
+ export type EndpointProbe =
87
+ | { state: "match"; client: string }
88
+ | { state: "mismatch"; client: string; server: string }
89
+ | { state: "unreachable"; client: string; reason: string };
90
+
91
+ /** Parse the body Playwright's server sends with its 428. */
92
+ export function parseMismatch(
93
+ body: string,
94
+ ): { server: string; client: string } | undefined {
95
+ const server = body.match(/server version:\s*v?([\d.]+)/);
96
+ const client = body.match(/client version:\s*v?([\d.]+)/);
97
+ return server && client
98
+ ? { server: server[1], client: client[1] }
99
+ : undefined;
100
+ }
101
+
102
+ /**
103
+ * Perform the WebSocket upgrade the MCP child performs, advertising
104
+ * `clientVersion`, and read the server's verdict. Never throws; never
105
+ * leaves a connection open (a completed upgrade is torn down at once, and
106
+ * Playwright's server treats that as an ordinary client disconnect).
107
+ */
108
+ export function probeEndpoint(
109
+ endpoint: string,
110
+ clientVersion: string,
111
+ timeoutMs = 3000,
112
+ ): Promise<EndpointProbe> {
113
+ return new Promise((settle) => {
114
+ let url: URL;
115
+ try {
116
+ url = new URL(endpoint);
117
+ } catch {
118
+ settle({
119
+ state: "unreachable",
120
+ client: clientVersion,
121
+ reason: `invalid endpoint URL: ${endpoint}`,
122
+ });
123
+ return;
124
+ }
125
+ const secure = url.protocol === "wss:" || url.protocol === "https:";
126
+ const request = secure ? httpsRequest : httpRequest;
127
+ const req = request({
128
+ host: url.hostname,
129
+ port: url.port || (secure ? 443 : 80),
130
+ path: `${url.pathname}${url.search}`,
131
+ method: "GET",
132
+ headers: {
133
+ Connection: "Upgrade",
134
+ Upgrade: "websocket",
135
+ "Sec-WebSocket-Version": "13",
136
+ "Sec-WebSocket-Key": randomBytes(16).toString("base64"),
137
+ "User-Agent": `Playwright/${clientVersion} (talon endpoint probe)`,
138
+ },
139
+ timeout: timeoutMs,
140
+ });
141
+ let settled = false;
142
+ const done = (result: EndpointProbe) => {
143
+ if (settled) return;
144
+ settled = true;
145
+ req.destroy();
146
+ settle(result);
147
+ };
148
+ req.on("upgrade", (_res, socket) => {
149
+ socket.destroy();
150
+ done({ state: "match", client: clientVersion });
151
+ });
152
+ req.on("response", (res) => {
153
+ let body = "";
154
+ res.setEncoding("utf-8");
155
+ res.on("data", (chunk: string) => {
156
+ body += chunk;
157
+ });
158
+ res.on("end", () => {
159
+ if (res.statusCode === 428) {
160
+ const parsed = parseMismatch(body);
161
+ done(
162
+ parsed
163
+ ? {
164
+ state: "mismatch",
165
+ client: clientVersion,
166
+ server: parsed.server,
167
+ }
168
+ : {
169
+ state: "unreachable",
170
+ client: clientVersion,
171
+ reason: "428 without a version box in the body",
172
+ },
173
+ );
174
+ return;
175
+ }
176
+ done({
177
+ state: "unreachable",
178
+ client: clientVersion,
179
+ reason: `HTTP ${res.statusCode ?? "?"} instead of an upgrade`,
180
+ });
181
+ });
182
+ });
183
+ req.on("timeout", () =>
184
+ done({ state: "unreachable", client: clientVersion, reason: "timeout" }),
185
+ );
186
+ req.on("error", (err: Error) =>
187
+ done({
188
+ state: "unreachable",
189
+ client: clientVersion,
190
+ reason: err.message,
191
+ }),
192
+ );
193
+ req.end();
194
+ });
195
+ }