appduct 0.14.0 → 0.15.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.
Files changed (55) hide show
  1. package/README.md +52 -22
  2. package/dist/artifact-inspect.d.ts +38 -39
  3. package/dist/bin.js +45 -3
  4. package/dist/cli/result-types.d.ts +27 -1
  5. package/dist/cli/routes/daemon/index.js +32 -1
  6. package/dist/cli/routes/daemon/run.js +962 -521
  7. package/dist/cli/routes/daemon/start.js +40 -1
  8. package/dist/cli/routes/daemon/status.js +41 -1
  9. package/dist/cli/routes/daemon/stop.js +40 -1
  10. package/dist/cli/routes/doctor.js +40 -2
  11. package/dist/cli/routes/events/index.js +32 -1
  12. package/dist/cli/routes/events/ls.js +40 -1
  13. package/dist/cli/routes/events/since.js +40 -1
  14. package/dist/cli/routes/events/tail.js +40 -1
  15. package/dist/cli/routes/init.js +207 -107
  16. package/dist/cli/routes/keygen.js +32 -1
  17. package/dist/cli/routes/mcp.js +135 -24
  18. package/dist/cli/routes/sessions/index.js +32 -1
  19. package/dist/cli/routes/sessions/link.js +141 -21
  20. package/dist/cli/routes/sessions/ls.js +40 -1
  21. package/dist/cli/routes/sessions/revoke.js +40 -1
  22. package/dist/cli/routes/tools/call.js +40 -1
  23. package/dist/cli/routes/tools/describe.js +40 -1
  24. package/dist/cli/routes/tools/index.js +32 -1
  25. package/dist/cli/routes/tools/ls.js +40 -1
  26. package/dist/client/attach-page.d.ts +39 -0
  27. package/dist/client/bootstrap.d.ts +17 -3
  28. package/dist/client/index.d.ts +2 -1
  29. package/dist/client/index.js +286 -112
  30. package/dist/commands/doctor.d.ts +3 -4
  31. package/dist/commands/init.d.ts +8 -2
  32. package/dist/commands/link.d.ts +10 -1
  33. package/dist/daemon/calls.d.ts +1 -1
  34. package/dist/daemon/config.d.ts +6 -0
  35. package/dist/daemon/daemon.d.ts +6 -1
  36. package/dist/daemon/links.d.ts +9 -5
  37. package/dist/daemon/listener.d.ts +28 -1
  38. package/dist/daemon/rpc-errors.d.ts +3 -0
  39. package/dist/daemon/sessions.d.ts +11 -7
  40. package/dist/daemon/tls.d.ts +0 -3
  41. package/dist/devtools-relay/attach-tab.d.ts +33 -0
  42. package/dist/devtools-relay/index.d.ts +8 -0
  43. package/dist/devtools-relay/memory-daemon-sockets.d.ts +20 -0
  44. package/dist/devtools-relay/memory-devtools-browser.d.ts +10 -0
  45. package/dist/devtools-relay/memory-page-channel.d.ts +16 -0
  46. package/dist/devtools-relay/node-daemon-socket.d.ts +3 -0
  47. package/dist/devtools-relay/node-devtools-browser.d.ts +3 -0
  48. package/dist/devtools-relay/ports.d.ts +48 -0
  49. package/dist/devtools-relay/relay.d.ts +16 -0
  50. package/dist/index.js +45 -3
  51. package/dist/link.d.ts +35 -1
  52. package/dist/mcp/connect-tool.d.ts +15 -2
  53. package/dist/native-scheme.d.ts +11 -7
  54. package/dist/scheme.d.ts +5 -1
  55. package/package.json +15 -5
@@ -2,131 +2,209 @@
2
2
  import { rm as rm5 } from "node:fs/promises";
3
3
  import {
4
4
  MAX_TOOLS_FILTER_LENGTH,
5
- RPC_METHODS,
5
+ RPC_METHODS as RPC_METHODS3,
6
6
  isValidToolGroup,
7
7
  summarizeToolGroups,
8
8
  toolGroupMatches,
9
9
  EVENT_KINDS
10
10
  } from "@appduct/shared";
11
11
 
12
- // src/package-version.ts
13
- import { readFileSync as readFileSync2 } from "node:fs";
14
- import { join as join2 } from "node:path";
15
-
16
- // src/package-root.ts
17
- import { existsSync, readFileSync } from "node:fs";
18
- import { dirname, join } from "node:path";
19
- import { fileURLToPath } from "node:url";
20
- var PACKAGE_NAME = "appduct";
21
- var cached;
22
- var getPackageRoot = () => {
23
- if (cached !== void 0) {
24
- return cached;
25
- }
26
- let dir = dirname(fileURLToPath(import.meta.url));
27
- for (; ; ) {
28
- const candidate = join(dir, "package.json");
29
- if (existsSync(candidate)) {
30
- let name;
31
- try {
32
- name = JSON.parse(readFileSync(candidate, "utf8")).name;
33
- } catch {
34
- }
35
- if (name === PACKAGE_NAME) {
36
- cached = dir;
37
- return dir;
38
- }
39
- }
40
- const parent = dirname(dir);
41
- if (parent === dir) {
42
- throw new Error(`Could not locate the "${PACKAGE_NAME}" package root above ${import.meta.url}.`);
12
+ // src/devtools-relay/relay.ts
13
+ import { decodeBootstrap } from "@appduct/shared";
14
+ var linkPayload = (link) => {
15
+ const payload = new URLSearchParams(new URL(link.url).hash.slice(1)).get("appduct");
16
+ if (payload === null) throw new Error('The link has no #appduct= payload: pass the result of link({ target: "web", url }).');
17
+ return payload;
18
+ };
19
+ var parseLink = (link) => {
20
+ const bootstrap = decodeBootstrap(linkPayload(link));
21
+ if (!bootstrap) throw new Error("The link's #appduct= payload is not a valid Appduct link.");
22
+ return bootstrap;
23
+ };
24
+ var GOING_AWAY = 1001;
25
+ var relayPage = (page, link, openDaemonSocket) => {
26
+ const bootstrap = parseLink(link);
27
+ const host = bootstrap.address.includes(":") ? `[${bootstrap.address}]` : bootstrap.address;
28
+ const url = `ws://${host}:${bootstrap.port}`;
29
+ let current;
30
+ const tellPage = (message) => {
31
+ page.evaluate(`window.__APPDUCT__.receive(${JSON.stringify(JSON.stringify(message))})`).catch(() => void 0);
32
+ };
33
+ const closeCurrent = (code, reason) => {
34
+ const socket = current;
35
+ current = void 0;
36
+ socket?.close(code, reason);
37
+ };
38
+ page.onBindingCall((json) => {
39
+ const message = JSON.parse(json);
40
+ if (message.kind === "open") {
41
+ closeCurrent(GOING_AWAY, "superseded");
42
+ const socket = openDaemonSocket(url, {
43
+ open: () => current === socket && tellPage({ kind: "open" }),
44
+ message: (text) => current === socket && tellPage({ kind: "message", text }),
45
+ close: (code, reason) => {
46
+ if (current !== socket) return;
47
+ current = void 0;
48
+ tellPage({ kind: "close", code, reason });
49
+ }
50
+ });
51
+ current = socket;
52
+ } else if (message.kind === "send") {
53
+ current?.send(message.text ?? "");
54
+ } else if (message.kind === "close") {
55
+ closeCurrent(message.code ?? 1e3, message.reason ?? "");
43
56
  }
44
- dir = parent;
45
- }
57
+ });
58
+ page.onContextReset(() => closeCurrent(GOING_AWAY, "page_gone"));
46
59
  };
47
60
 
48
- // src/package-version.ts
49
- var DAEMON_VERSION_OVERRIDE_ENV = "APPDUCT_DAEMON_VERSION_OVERRIDE";
50
- var isTestRuntime = (env) => {
51
- return Boolean(env.VITEST) || env.NODE_ENV === "test";
52
- };
53
- var cached2;
54
- var getPackageVersion = () => {
55
- cached2 ??= JSON.parse(readFileSync2(join2(getPackageRoot(), "package.json"), "utf8")).version;
56
- return cached2;
57
- };
58
- var getDaemonReportedVersion = (env = process.env) => {
59
- if (!isTestRuntime(env)) {
60
- return getPackageVersion();
61
+ // src/devtools-relay/attach-tab.ts
62
+ var listTabs = (tabs) => tabs.length === 0 ? " (none)" : tabs.map((tab) => ` ${tab.id} ${tab.url}`).join("\n");
63
+ var pickTab = (tabs, url, targetId) => {
64
+ if (targetId !== void 0) {
65
+ const byId = tabs.find((tab) => tab.id === targetId);
66
+ if (byId) return byId;
67
+ throw new Error(`No open tab has target id "${targetId}". Open tabs:
68
+ ${listTabs(tabs)}`);
69
+ }
70
+ const matching = tabs.filter((tab) => tab.url.startsWith(url));
71
+ if (matching.length === 1) return matching[0];
72
+ if (matching.length === 0) throw new Error(`No open tab starts with "${url}". Open tabs:
73
+ ${listTabs(tabs)}`);
74
+ throw new Error(`${matching.length} open tabs start with "${url}"; pass the target id of the one you want. Matching tabs:
75
+ ${listTabs(matching)}`);
76
+ };
77
+ var WAIT_FOR_WEB = `new Promise((resolve, reject) => {
78
+ const ready = () => typeof window.__APPDUCT__?.connect === 'function';
79
+ if (ready()) return resolve(true);
80
+ const timer = setInterval(() => { if (ready()) { clearInterval(timer); clearTimeout(giveUp); resolve(true); } }, 50);
81
+ const giveUp = setTimeout(() => { clearInterval(timer); reject(new Error('@appduct/web did not load')); }, 5000);
82
+ })`;
83
+ var attachBrowserTab = async (options) => {
84
+ const tab = pickTab(await options.browser.targets(), options.url, options.targetId);
85
+ const link = await options.mintLink();
86
+ const page = await options.browser.openPage(tab.id);
87
+ options.relayedTabs.get(tab.id)?.close();
88
+ options.relayedTabs.set(tab.id, page);
89
+ try {
90
+ relayPage(page, link, options.openDaemonSocket);
91
+ await page.evaluate(WAIT_FOR_WEB).catch(() => {
92
+ throw new Error(`The tab ${tab.id} (${tab.url}) does not load @appduct/web. Add it to the page, then try again.`);
93
+ });
94
+ await page.evaluate(`window.__APPDUCT__.connect(${JSON.stringify(linkPayload(link))}, { transport: "devtools" })`);
95
+ } catch (error) {
96
+ page.close();
97
+ if (options.relayedTabs.get(tab.id) === page) options.relayedTabs.delete(tab.id);
98
+ throw error;
61
99
  }
62
- const override = env[DAEMON_VERSION_OVERRIDE_ENV];
63
- return override !== void 0 && override.length > 0 ? override : getPackageVersion();
100
+ return { sessionId: link.sessionId, url: tab.url, targetId: tab.id, expiresAt: link.expiresAt };
64
101
  };
65
102
 
66
- // src/daemon/address.ts
67
- import { networkInterfaces } from "node:os";
68
- import {
69
- isLinkLocalIpv6Address,
70
- isPrivateIpv4Address,
71
- isUniqueLocalIpv6Address
72
- } from "@appduct/shared";
73
- var isDockerDefaultBridgeAddress = (address) => {
74
- return address.startsWith("172.17.");
75
- };
76
- var isCgnatIpv4Address = (address) => {
77
- const parts = address.split(".");
78
- if (parts.length !== 4) {
79
- return false;
80
- }
81
- const first = Number(parts[0]);
82
- const second = Number(parts[1]);
83
- return first === 100 && Number.isInteger(second) && second >= 64 && second <= 127;
103
+ // src/devtools-relay/node-devtools-browser.ts
104
+ import WebSocket from "ws";
105
+ var BINDING = "__appductBinding";
106
+ var listTargets = async (browserUrl) => {
107
+ let listed;
108
+ try {
109
+ listed = await (await fetch(new URL("/json/list", browserUrl))).json();
110
+ } catch (error) {
111
+ throw new Error(`Could not reach a browser at ${browserUrl}: ${error.message}. Launch Chrome with --remote-debugging-port and its own --user-data-dir.`);
112
+ }
113
+ return listed.filter((target) => target.type === "page");
114
+ };
115
+ var openTab = async (target, browserUrl) => {
116
+ const endpoint = new URL(browserUrl);
117
+ const socketUrl = new URL(target.webSocketDebuggerUrl);
118
+ socketUrl.protocol = endpoint.protocol === "https:" ? "wss:" : "ws:";
119
+ socketUrl.host = endpoint.host;
120
+ const socket = new WebSocket(socketUrl);
121
+ await new Promise((resolve2, reject) => {
122
+ socket.once("open", () => resolve2());
123
+ socket.once("error", reject);
124
+ });
125
+ const pending = /* @__PURE__ */ new Map();
126
+ const bindingHandlers = [];
127
+ const resetHandlers = [];
128
+ let nextId = 0;
129
+ const send = (method, params) => new Promise((resolve2, reject) => {
130
+ nextId += 1;
131
+ pending.set(nextId, { resolve: resolve2, reject });
132
+ socket.send(JSON.stringify({ id: nextId, method, params }));
133
+ });
134
+ socket.on("message", (data) => {
135
+ const message = JSON.parse(data.toString());
136
+ if (message.id !== void 0) {
137
+ const call = pending.get(message.id);
138
+ pending.delete(message.id);
139
+ if (message.error) call?.reject(new Error(message.error.message));
140
+ else call?.resolve(message.result);
141
+ } else if (message.method === "Runtime.bindingCalled" && message.params?.name === BINDING) {
142
+ bindingHandlers.forEach((handler) => handler(message.params.payload));
143
+ } else if (message.method === "Runtime.executionContextsCleared") {
144
+ resetHandlers.forEach((handler) => handler());
145
+ }
146
+ });
147
+ socket.on("error", () => void 0);
148
+ socket.on("close", () => {
149
+ pending.forEach((call) => call.reject(new Error("The tab's debugging connection closed.")));
150
+ pending.clear();
151
+ resetHandlers.forEach((handler) => handler());
152
+ });
153
+ await send("Runtime.enable", {});
154
+ await send("Runtime.addBinding", { name: BINDING });
155
+ return {
156
+ onBindingCall: (handler) => void bindingHandlers.push(handler),
157
+ onContextReset: (handler) => void resetHandlers.push(handler),
158
+ evaluate: async (expression) => {
159
+ const result = await send("Runtime.evaluate", { expression, awaitPromise: true, returnByValue: true });
160
+ const failure = result?.exceptionDetails;
161
+ if (failure) throw new Error(failure.exception?.description ?? failure.text);
162
+ },
163
+ close: () => socket.close()
164
+ };
84
165
  };
85
- var isExcludedIpv4Address = (address) => {
86
- return isDockerDefaultBridgeAddress(address) || isCgnatIpv4Address(address);
166
+ var connectNodeDevtoolsBrowser = (browserUrl) => {
167
+ const open4 = /* @__PURE__ */ new Map();
168
+ return {
169
+ targets: async () => (await listTargets(browserUrl)).map(({ id, url, title }) => ({ id, url, title })),
170
+ openPage: async (targetId) => {
171
+ const target = (await listTargets(browserUrl)).find((candidate) => candidate.id === targetId);
172
+ if (!target?.webSocketDebuggerUrl) throw new Error(`The browser at ${browserUrl} has no tab ${targetId}.`);
173
+ const page = await openTab(target, browserUrl);
174
+ open4.get(targetId)?.close();
175
+ open4.set(targetId, page);
176
+ return page;
177
+ },
178
+ close: () => {
179
+ open4.forEach((page) => page.close());
180
+ open4.clear();
181
+ }
182
+ };
87
183
  };
88
- var detectAdvertisedAddress = (options = {}) => {
89
- if (options.override) {
90
- return {
91
- family: options.override.includes(":") ? 6 : 4,
92
- address: options.override
93
- };
94
- }
95
- const ipv4Candidates = [];
96
- const ipv6Candidates = [];
97
- for (const entries of Object.values(networkInterfaces())) {
98
- for (const entry of entries ?? []) {
99
- if (entry.internal) {
100
- continue;
101
- }
102
- if (entry.family === "IPv4") {
103
- if (isExcludedIpv4Address(entry.address)) {
104
- continue;
105
- }
106
- ipv4Candidates.push(entry.address);
107
- } else if (entry.family === "IPv6") {
108
- if (isLinkLocalIpv6Address(entry.address)) {
109
- continue;
110
- }
111
- ipv6Candidates.push(entry.address);
112
- }
184
+
185
+ // src/devtools-relay/node-daemon-socket.ts
186
+ import WebSocket2 from "ws";
187
+ var openNodeDaemonSocket = (url, events) => {
188
+ const socket = new WebSocket2(url);
189
+ socket.on("open", () => events.open());
190
+ socket.on("message", (data, isBinary) => {
191
+ if (isBinary) {
192
+ socket.close(4008, "binary_frame_not_supported");
193
+ return;
113
194
  }
114
- }
115
- const preferredIpv4 = ipv4Candidates.find((address) => isPrivateIpv4Address(address)) ?? ipv4Candidates[0];
116
- if (preferredIpv4) {
117
- return { family: 4, address: preferredIpv4 };
118
- }
119
- const preferredIpv6 = ipv6Candidates.find((address) => isUniqueLocalIpv6Address(address)) ?? ipv6Candidates[0];
120
- if (preferredIpv6) {
121
- return { family: 6, address: preferredIpv6 };
122
- }
123
- return { family: 4, address: "127.0.0.1" };
195
+ events.message(data.toString());
196
+ });
197
+ socket.on("error", () => void 0);
198
+ socket.on("close", (code, reason) => events.close(code, reason.toString()));
199
+ return { send: (text) => socket.send(text), close: (code, reason) => socket.close(code, reason) };
124
200
  };
125
201
 
126
- // src/daemon/audit.ts
127
- import { createHash } from "node:crypto";
128
- import { chmod, mkdir, open, readdir, rm, stat } from "node:fs/promises";
129
- import { join as join3 } from "node:path";
202
+ // src/link.ts
203
+ import { RPC_METHODS as RPC_METHODS2 } from "@appduct/shared";
204
+
205
+ // src/rpc/client.ts
206
+ import { join as join4 } from "node:path";
207
+ import { RPC_METHODS } from "@appduct/shared";
130
208
 
131
209
  // src/daemon/config.ts
132
210
  import { readFile } from "node:fs/promises";
@@ -141,6 +219,7 @@ var KNOWN_TOP_LEVEL_KEYS = /* @__PURE__ */ new Set([
141
219
  "daemonLogMaxBytes",
142
220
  "eventsLogMaxBytes",
143
221
  "policy",
222
+ "webOrigins",
144
223
  "advertisedIp",
145
224
  "scheme",
146
225
  "restartDaemonOnVersionMismatch"
@@ -201,6 +280,7 @@ var defaultConfig = (paths) => {
201
280
  daemonLogMaxBytes: DEFAULT_DAEMON_LOG_MAX_BYTES,
202
281
  eventsLogMaxBytes: 10 * 1024 * 1024,
203
282
  restartDaemonOnVersionMismatch: false,
283
+ webOrigins: [],
204
284
  policy: {
205
285
  default: "allow",
206
286
  destructive: "allow"
@@ -244,64 +324,482 @@ var loadConfig = async (paths, options = {}) => {
244
324
  if (parsed.graceSeconds !== void 0) {
245
325
  config.graceSeconds = requirePositiveInteger(parsed.graceSeconds, "graceSeconds");
246
326
  }
247
- if (parsed.linkTtlSeconds !== void 0) {
248
- config.linkTtlSeconds = requirePositiveInteger(parsed.linkTtlSeconds, "linkTtlSeconds");
327
+ if (parsed.linkTtlSeconds !== void 0) {
328
+ config.linkTtlSeconds = requirePositiveInteger(parsed.linkTtlSeconds, "linkTtlSeconds");
329
+ }
330
+ if (parsed.keepaliveIntervalSeconds !== void 0) {
331
+ config.keepaliveIntervalSeconds = requirePositiveInteger(
332
+ parsed.keepaliveIntervalSeconds,
333
+ "keepaliveIntervalSeconds"
334
+ );
335
+ }
336
+ if (parsed.eventBufferSize !== void 0) {
337
+ config.eventBufferSize = requirePositiveInteger(parsed.eventBufferSize, "eventBufferSize");
338
+ }
339
+ if (parsed.auditRetentionDays !== void 0) {
340
+ config.auditRetentionDays = requirePositiveInteger(parsed.auditRetentionDays, "auditRetentionDays");
341
+ }
342
+ if (parsed.daemonLogMaxBytes !== void 0) {
343
+ config.daemonLogMaxBytes = requirePositiveInteger(parsed.daemonLogMaxBytes, "daemonLogMaxBytes");
344
+ }
345
+ if (parsed.eventsLogMaxBytes !== void 0) {
346
+ config.eventsLogMaxBytes = requirePositiveInteger(parsed.eventsLogMaxBytes, "eventsLogMaxBytes");
347
+ }
348
+ if (parsed.restartDaemonOnVersionMismatch !== void 0) {
349
+ config.restartDaemonOnVersionMismatch = requireBoolean(
350
+ parsed.restartDaemonOnVersionMismatch,
351
+ "restartDaemonOnVersionMismatch"
352
+ );
353
+ }
354
+ if (parsed.webOrigins !== void 0) {
355
+ if (!Array.isArray(parsed.webOrigins)) {
356
+ throw configError("webOrigins", "must be an array of origin strings.");
357
+ }
358
+ config.webOrigins = parsed.webOrigins.map((origin) => requireNonEmptyString(origin, "webOrigins"));
359
+ }
360
+ if (parsed.policy !== void 0) {
361
+ if (typeof parsed.policy !== "object" || parsed.policy === null || Array.isArray(parsed.policy)) {
362
+ throw configError("policy", "must be an object.");
363
+ }
364
+ const policy = parsed.policy;
365
+ for (const key of Object.keys(policy)) {
366
+ if (!KNOWN_POLICY_KEYS.has(key)) {
367
+ warn(`Unknown Appduct config key "policy.${key}" is ignored.`);
368
+ }
369
+ }
370
+ if (policy.default !== void 0) {
371
+ config.policy.default = requirePolicyDecision(policy.default, "policy.default");
372
+ }
373
+ if (policy.destructive !== void 0) {
374
+ config.policy.destructive = requirePolicyDecision(policy.destructive, "policy.destructive");
375
+ }
376
+ if (policy.tools !== void 0) {
377
+ if (typeof policy.tools !== "object" || policy.tools === null || Array.isArray(policy.tools)) {
378
+ throw configError("policy.tools", "must be an object.");
379
+ }
380
+ const tools = {};
381
+ for (const [toolKey, toolValue] of Object.entries(policy.tools)) {
382
+ tools[toolKey] = requirePolicyDecision(toolValue, `policy.tools["${toolKey}"]`);
383
+ }
384
+ config.policy.tools = tools;
385
+ }
386
+ }
387
+ return config;
388
+ };
389
+
390
+ // src/daemon/pidfile.ts
391
+ import { readFileSync } from "node:fs";
392
+ import { open, readFile as readFile2, rm } from "node:fs/promises";
393
+ var DaemonAlreadyRunningError = class extends Error {
394
+ constructor(pid) {
395
+ super(`Appduct daemon is already running (pid ${pid}).`);
396
+ this.pid = pid;
397
+ this.name = "DaemonAlreadyRunningError";
398
+ }
399
+ };
400
+ var readProcStatus = (pid) => {
401
+ try {
402
+ return readFileSync(`/proc/${pid}/status`, "utf8");
403
+ } catch {
404
+ return void 0;
405
+ }
406
+ };
407
+ var isZombie = (pid, readStatus) => {
408
+ const status = readStatus(pid);
409
+ if (status === void 0) {
410
+ return false;
411
+ }
412
+ return /^State:\s*Z\b/mu.test(status);
413
+ };
414
+ var isProcessAlive = (pid, readStatus = readProcStatus) => {
415
+ try {
416
+ process.kill(pid, 0);
417
+ } catch (error) {
418
+ return error.code === "EPERM";
419
+ }
420
+ return !isZombie(pid, readStatus);
421
+ };
422
+ var readPidFromFile = async (pidFilePath) => {
423
+ let contents;
424
+ try {
425
+ contents = await readFile2(pidFilePath, "utf8");
426
+ } catch (error) {
427
+ if (error.code === "ENOENT") {
428
+ return void 0;
429
+ }
430
+ throw error;
431
+ }
432
+ const pid = Number.parseInt(contents.trim(), 10);
433
+ return Number.isInteger(pid) && pid > 0 ? pid : void 0;
434
+ };
435
+ var acquirePidfile = async (pidFilePath, options = {}) => {
436
+ const pid = options.pid ?? process.pid;
437
+ for (let attempt = 0; attempt < 5; attempt += 1) {
438
+ try {
439
+ const handle = await open(pidFilePath, "wx", 384);
440
+ try {
441
+ await handle.writeFile(String(pid), "utf8");
442
+ } finally {
443
+ await handle.close();
444
+ }
445
+ let released = false;
446
+ return {
447
+ pid,
448
+ release: async () => {
449
+ if (released) {
450
+ return;
451
+ }
452
+ released = true;
453
+ await rm(pidFilePath, { force: true });
454
+ }
455
+ };
456
+ } catch (error) {
457
+ if (error.code !== "EEXIST") {
458
+ throw error;
459
+ }
460
+ const existingPid = await readPidFromFile(pidFilePath);
461
+ if (existingPid !== void 0 && isProcessAlive(existingPid)) {
462
+ throw new DaemonAlreadyRunningError(existingPid);
463
+ }
464
+ await rm(pidFilePath, { force: true });
465
+ await options.onStaleTakeover?.();
466
+ }
467
+ }
468
+ throw new Error(`Failed to acquire the Appduct daemon pidfile at "${pidFilePath}".`);
469
+ };
470
+
471
+ // src/daemon/state-dir.ts
472
+ import { join } from "node:path";
473
+ import { mkdir, chmod } from "node:fs/promises";
474
+ var getStateDirPaths = (stateDir) => {
475
+ return {
476
+ root: stateDir,
477
+ socketPath: join(stateDir, "daemon.sock"),
478
+ pidFilePath: join(stateDir, "daemon.pid"),
479
+ logFilePath: join(stateDir, "daemon.log"),
480
+ eventsLogPath: join(stateDir, "events.log"),
481
+ keyPath: join(stateDir, "key.pem"),
482
+ configPath: join(stateDir, "config.json"),
483
+ auditDir: join(stateDir, "audit"),
484
+ spawnLockPath: join(stateDir, "daemon.spawn.lock")
485
+ };
486
+ };
487
+ var getSocketPath = (paths) => {
488
+ return paths.socketPath;
489
+ };
490
+ var ensureStateDir = async (stateDir) => {
491
+ const paths = getStateDirPaths(stateDir);
492
+ await mkdir(paths.auditDir, { recursive: true });
493
+ await chmod(paths.root, 448);
494
+ await chmod(paths.auditDir, 448);
495
+ return paths;
496
+ };
497
+
498
+ // src/package-root.ts
499
+ import { existsSync, readFileSync as readFileSync2 } from "node:fs";
500
+ import { dirname, join as join2 } from "node:path";
501
+ import { fileURLToPath } from "node:url";
502
+ var PACKAGE_NAME = "appduct";
503
+ var cached;
504
+ var getPackageRoot = () => {
505
+ if (cached !== void 0) {
506
+ return cached;
507
+ }
508
+ let dir = dirname(fileURLToPath(import.meta.url));
509
+ for (; ; ) {
510
+ const candidate = join2(dir, "package.json");
511
+ if (existsSync(candidate)) {
512
+ let name;
513
+ try {
514
+ name = JSON.parse(readFileSync2(candidate, "utf8")).name;
515
+ } catch {
516
+ }
517
+ if (name === PACKAGE_NAME) {
518
+ cached = dir;
519
+ return dir;
520
+ }
521
+ }
522
+ const parent = dirname(dir);
523
+ if (parent === dir) {
524
+ throw new Error(`Could not locate the "${PACKAGE_NAME}" package root above ${import.meta.url}.`);
525
+ }
526
+ dir = parent;
527
+ }
528
+ };
529
+
530
+ // src/package-version.ts
531
+ import { readFileSync as readFileSync3 } from "node:fs";
532
+ import { join as join3 } from "node:path";
533
+ var DAEMON_VERSION_OVERRIDE_ENV = "APPDUCT_DAEMON_VERSION_OVERRIDE";
534
+ var isTestRuntime = (env) => {
535
+ return Boolean(env.VITEST) || env.NODE_ENV === "test";
536
+ };
537
+ var cached2;
538
+ var getPackageVersion = () => {
539
+ cached2 ??= JSON.parse(readFileSync3(join3(getPackageRoot(), "package.json"), "utf8")).version;
540
+ return cached2;
541
+ };
542
+ var getDaemonReportedVersion = (env = process.env) => {
543
+ if (!isTestRuntime(env)) {
544
+ return getPackageVersion();
545
+ }
546
+ const override = env[DAEMON_VERSION_OVERRIDE_ENV];
547
+ return override !== void 0 && override.length > 0 ? override : getPackageVersion();
548
+ };
549
+
550
+ // src/rpc/client.ts
551
+ var defaultBinPath = join4(getPackageRoot(), "bin.js");
552
+ var DaemonRpcError = class extends Error {
553
+ constructor(code, message, data) {
554
+ super(message);
555
+ this.code = code;
556
+ this.data = data;
557
+ this.name = "DaemonRpcError";
558
+ }
559
+ };
560
+ var DaemonUnavailableError = class extends Error {
561
+ constructor(message, reason = "unreachable") {
562
+ super(message);
563
+ this.reason = reason;
564
+ this.name = "DaemonUnavailableError";
565
+ }
566
+ };
567
+ var describeLiveState = (state) => {
568
+ const parts = [];
569
+ if (state.sessions > 0) {
570
+ parts.push(`${state.sessions} connected session(s)`);
571
+ }
572
+ if (state.pendingLinks > 0) {
573
+ parts.push(`${state.pendingLinks} unclaimed link(s)`);
574
+ }
575
+ return parts.join(" and ");
576
+ };
577
+ var DaemonVersionMismatchError = class extends Error {
578
+ constructor(daemonVersion, clientVersion, live, message) {
579
+ super(
580
+ message ?? `The running Appduct daemon is version ${daemonVersion}, but this client is version ${clientVersion}. It was not restarted automatically because ${describeLiveState(live)} would be lost. Run "appduct daemon stop" once that state is expendable, or force it with "--daemon-restart".`
581
+ );
582
+ this.daemonVersion = daemonVersion;
583
+ this.clientVersion = clientVersion;
584
+ this.name = "DaemonVersionMismatchError";
585
+ this.sessionCount = live.sessions;
586
+ this.pendingLinkCount = live.pendingLinks;
587
+ }
588
+ sessionCount;
589
+ pendingLinkCount;
590
+ };
591
+ var isConnectionMissingError = (error) => {
592
+ const code = error.code;
593
+ return code === "ENOENT" || code === "ECONNREFUSED";
594
+ };
595
+ var isDaemonUnreachableError = (error) => {
596
+ return error instanceof DaemonUnavailableError || isConnectionMissingError(error);
597
+ };
598
+
599
+ // src/errors.ts
600
+ var EXIT_CODE_BY_ERROR_TYPE = {
601
+ usage_error: 64,
602
+ validation_error: 65,
603
+ // EX_NOINPUT (66): "an input file did not exist or was not readable" — `doctor` throws this for a
604
+ // missing external tool or an unreadable/corrupt artifact, deliberately distinct from every other
605
+ // code here so a release pipeline can't mistake "we couldn't tell" for either a clean pass or an
606
+ // assertion failure.
607
+ inspection_error: 66,
608
+ connection_error: 70,
609
+ session_error: 71,
610
+ tool_error: 72,
611
+ permission_error: 77,
612
+ internal_error: 1,
613
+ // `doctor --assert-present`/`--assert-absent`: the artifact was inspected successfully and the
614
+ // assertion did not hold. Distinct from `inspection_error` (66) — this means the answer is known
615
+ // and it's "no", not "couldn't tell" — and distinct from `internal_error` (1) so a caller can't
616
+ // conflate a normal assertion failure with an unexpected crash.
617
+ assertion_error: 3
618
+ };
619
+ var RPC_ERROR_EXIT_CLASS = {
620
+ no_session: "session_error",
621
+ ambiguous_session: "session_error",
622
+ unknown_session: "session_error",
623
+ session_not_active: "session_error",
624
+ session_suspended: "session_error",
625
+ tool_not_found: "tool_error",
626
+ tool_input_validation_error: "tool_error",
627
+ tool_output_validation_error: "tool_error",
628
+ tool_execution_error: "tool_error",
629
+ tool_serialization_error: "tool_error",
630
+ tool_timeout: "tool_error",
631
+ tool_cancelled: "tool_error",
632
+ policy_denied: "permission_error",
633
+ invalid_request: "validation_error"
634
+ };
635
+ var AppductCliError = class extends Error {
636
+ type;
637
+ details;
638
+ constructor(type, message, details) {
639
+ super(message);
640
+ this.name = "AppductCliError";
641
+ this.type = type;
642
+ this.details = details;
643
+ }
644
+ };
645
+ var connectionError = (message, details) => {
646
+ return new AppductCliError("connection_error", message, details);
647
+ };
648
+ var isAppductCliError = (value) => {
649
+ return value instanceof AppductCliError;
650
+ };
651
+ var rpcErrorClass = (error) => {
652
+ const type = error.data?.type;
653
+ return type ? RPC_ERROR_EXIT_CLASS[type] : void 0;
654
+ };
655
+ var toCliError = (error) => {
656
+ if (isAppductCliError(error)) {
657
+ return {
658
+ type: error.type,
659
+ message: error.message,
660
+ details: error.details
661
+ };
662
+ }
663
+ if (error instanceof DaemonVersionMismatchError) {
664
+ return {
665
+ type: "connection_error",
666
+ message: error.message,
667
+ details: {
668
+ daemon_version: error.daemonVersion,
669
+ client_version: error.clientVersion,
670
+ session_count: error.sessionCount,
671
+ pending_link_count: error.pendingLinkCount
672
+ }
673
+ };
674
+ }
675
+ if (error instanceof DaemonRpcError && error.data?.type) {
676
+ return {
677
+ type: error.data.type,
678
+ message: error.message,
679
+ details: error.data.details
680
+ };
681
+ }
682
+ if (isDaemonUnreachableError(error)) {
683
+ return {
684
+ type: "connection_error",
685
+ message: error instanceof Error ? error.message : "The Appduct daemon is unreachable."
686
+ };
687
+ }
688
+ if (error instanceof Error) {
689
+ return {
690
+ type: "internal_error",
691
+ message: error.message
692
+ };
249
693
  }
250
- if (parsed.keepaliveIntervalSeconds !== void 0) {
251
- config.keepaliveIntervalSeconds = requirePositiveInteger(
252
- parsed.keepaliveIntervalSeconds,
253
- "keepaliveIntervalSeconds"
254
- );
694
+ return {
695
+ type: "internal_error",
696
+ message: "An unexpected error occurred.",
697
+ details: error
698
+ };
699
+ };
700
+ var getExitCodeForError = (error) => {
701
+ if (isAppductCliError(error)) {
702
+ return EXIT_CODE_BY_ERROR_TYPE[error.type];
255
703
  }
256
- if (parsed.eventBufferSize !== void 0) {
257
- config.eventBufferSize = requirePositiveInteger(parsed.eventBufferSize, "eventBufferSize");
704
+ if (error instanceof DaemonVersionMismatchError) {
705
+ return EXIT_CODE_BY_ERROR_TYPE.connection_error;
258
706
  }
259
- if (parsed.auditRetentionDays !== void 0) {
260
- config.auditRetentionDays = requirePositiveInteger(parsed.auditRetentionDays, "auditRetentionDays");
707
+ if (error instanceof DaemonRpcError) {
708
+ const cliErrorClass = rpcErrorClass(error);
709
+ return EXIT_CODE_BY_ERROR_TYPE[cliErrorClass ?? "internal_error"];
261
710
  }
262
- if (parsed.daemonLogMaxBytes !== void 0) {
263
- config.daemonLogMaxBytes = requirePositiveInteger(parsed.daemonLogMaxBytes, "daemonLogMaxBytes");
711
+ if (isDaemonUnreachableError(error)) {
712
+ return EXIT_CODE_BY_ERROR_TYPE.connection_error;
264
713
  }
265
- if (parsed.eventsLogMaxBytes !== void 0) {
266
- config.eventsLogMaxBytes = requirePositiveInteger(parsed.eventsLogMaxBytes, "eventsLogMaxBytes");
714
+ return EXIT_CODE_BY_ERROR_TYPE.internal_error;
715
+ };
716
+
717
+ // src/scheme.ts
718
+ import { dirname as dirname2, join as join5, resolve } from "node:path";
719
+
720
+ // src/native-scheme.ts
721
+ var MAX_PROBE_FILE_BYTES = 2 * 1024 * 1024;
722
+
723
+ // src/scheme.ts
724
+ var PROJECT_CONFIG_DIR = ".appduct";
725
+ var PROJECT_CONFIG_FILENAME = "config.json";
726
+ var PROJECT_CONFIG_RELATIVE_PATH = join5(PROJECT_CONFIG_DIR, PROJECT_CONFIG_FILENAME);
727
+
728
+ // src/link.ts
729
+ var composeWebLink = (pageUrl, result) => {
730
+ const payload = result.deepLinkPayload;
731
+ return {
732
+ sessionId: result.sessionId,
733
+ url: `${pageUrl.split("#")[0]}#appduct=${payload}`,
734
+ script: `window.__APPDUCT__.connect("${payload}")`,
735
+ expiresAt: result.expiresAt
736
+ };
737
+ };
738
+
739
+ // src/daemon/address.ts
740
+ import { networkInterfaces } from "node:os";
741
+ import {
742
+ isLinkLocalIpv6Address,
743
+ isPrivateIpv4Address,
744
+ isUniqueLocalIpv6Address
745
+ } from "@appduct/shared";
746
+ var isDockerDefaultBridgeAddress = (address) => {
747
+ return address.startsWith("172.17.");
748
+ };
749
+ var isCgnatIpv4Address = (address) => {
750
+ const parts = address.split(".");
751
+ if (parts.length !== 4) {
752
+ return false;
267
753
  }
268
- if (parsed.restartDaemonOnVersionMismatch !== void 0) {
269
- config.restartDaemonOnVersionMismatch = requireBoolean(
270
- parsed.restartDaemonOnVersionMismatch,
271
- "restartDaemonOnVersionMismatch"
272
- );
754
+ const first = Number(parts[0]);
755
+ const second = Number(parts[1]);
756
+ return first === 100 && Number.isInteger(second) && second >= 64 && second <= 127;
757
+ };
758
+ var isExcludedIpv4Address = (address) => {
759
+ return isDockerDefaultBridgeAddress(address) || isCgnatIpv4Address(address);
760
+ };
761
+ var detectAdvertisedAddress = (options = {}) => {
762
+ if (options.override) {
763
+ return {
764
+ family: options.override.includes(":") ? 6 : 4,
765
+ address: options.override
766
+ };
273
767
  }
274
- if (parsed.policy !== void 0) {
275
- if (typeof parsed.policy !== "object" || parsed.policy === null || Array.isArray(parsed.policy)) {
276
- throw configError("policy", "must be an object.");
277
- }
278
- const policy = parsed.policy;
279
- for (const key of Object.keys(policy)) {
280
- if (!KNOWN_POLICY_KEYS.has(key)) {
281
- warn(`Unknown Appduct config key "policy.${key}" is ignored.`);
282
- }
283
- }
284
- if (policy.default !== void 0) {
285
- config.policy.default = requirePolicyDecision(policy.default, "policy.default");
286
- }
287
- if (policy.destructive !== void 0) {
288
- config.policy.destructive = requirePolicyDecision(policy.destructive, "policy.destructive");
289
- }
290
- if (policy.tools !== void 0) {
291
- if (typeof policy.tools !== "object" || policy.tools === null || Array.isArray(policy.tools)) {
292
- throw configError("policy.tools", "must be an object.");
768
+ const ipv4Candidates = [];
769
+ const ipv6Candidates = [];
770
+ for (const entries of Object.values(networkInterfaces())) {
771
+ for (const entry of entries ?? []) {
772
+ if (entry.internal) {
773
+ continue;
293
774
  }
294
- const tools = {};
295
- for (const [toolKey, toolValue] of Object.entries(policy.tools)) {
296
- tools[toolKey] = requirePolicyDecision(toolValue, `policy.tools["${toolKey}"]`);
775
+ if (entry.family === "IPv4") {
776
+ if (isExcludedIpv4Address(entry.address)) {
777
+ continue;
778
+ }
779
+ ipv4Candidates.push(entry.address);
780
+ } else if (entry.family === "IPv6") {
781
+ if (isLinkLocalIpv6Address(entry.address)) {
782
+ continue;
783
+ }
784
+ ipv6Candidates.push(entry.address);
297
785
  }
298
- config.policy.tools = tools;
299
786
  }
300
787
  }
301
- return config;
788
+ const preferredIpv4 = ipv4Candidates.find((address) => isPrivateIpv4Address(address)) ?? ipv4Candidates[0];
789
+ if (preferredIpv4) {
790
+ return { family: 4, address: preferredIpv4 };
791
+ }
792
+ const preferredIpv6 = ipv6Candidates.find((address) => isUniqueLocalIpv6Address(address)) ?? ipv6Candidates[0];
793
+ if (preferredIpv6) {
794
+ return { family: 6, address: preferredIpv6 };
795
+ }
796
+ return { family: 4, address: "127.0.0.1" };
302
797
  };
303
798
 
304
799
  // src/daemon/audit.ts
800
+ import { createHash } from "node:crypto";
801
+ import { chmod as chmod2, mkdir as mkdir2, open as open2, readdir, rm as rm2, stat } from "node:fs/promises";
802
+ import { join as join6 } from "node:path";
305
803
  var canonicalize = (value) => {
306
804
  if (Array.isArray(value)) {
307
805
  return value.map(canonicalize);
@@ -338,13 +836,13 @@ var oldestRetainedStamp = (now, retentionDays) => {
338
836
  return Number.isNaN(cutoff.getTime()) ? "0000-00-00" : dayStamp(cutoff);
339
837
  };
340
838
  var auditFilePathForDate = (auditDir, date) => {
341
- return join3(auditDir, `${dayStamp(date)}.jsonl`);
839
+ return join6(auditDir, `${dayStamp(date)}.jsonl`);
342
840
  };
343
841
  var appendLine = async (filePath, line) => {
344
- const handle = await open(filePath, "a", 384);
842
+ const handle = await open2(filePath, "a", 384);
345
843
  try {
346
844
  await handle.appendFile(line);
347
- await chmod(filePath, 384);
845
+ await chmod2(filePath, 384);
348
846
  } finally {
349
847
  await handle.close();
350
848
  }
@@ -397,7 +895,7 @@ var createAuditLogger = (options) => {
397
895
  continue;
398
896
  }
399
897
  try {
400
- await rm(join3(options.auditDir, file.name), { force: true });
898
+ await rm2(join6(options.auditDir, file.name), { force: true });
401
899
  deleted += 1;
402
900
  } catch (error) {
403
901
  failed += 1;
@@ -415,7 +913,7 @@ var createAuditLogger = (options) => {
415
913
  const filePath = auditFilePathForDate(options.auditDir, now);
416
914
  queue = queue.then(async () => {
417
915
  try {
418
- await mkdir(options.auditDir, { recursive: true });
916
+ await mkdir2(options.auditDir, { recursive: true });
419
917
  await appendLine(filePath, line);
420
918
  } catch (error) {
421
919
  failedWrites += 1;
@@ -425,8 +923,8 @@ var createAuditLogger = (options) => {
425
923
  };
426
924
  const prune = () => {
427
925
  let settle;
428
- const result = new Promise((resolve) => {
429
- settle = resolve;
926
+ const result = new Promise((resolve2) => {
927
+ settle = resolve2;
430
928
  });
431
929
  queue = queue.then(async () => {
432
930
  try {
@@ -448,7 +946,7 @@ var createAuditLogger = (options) => {
448
946
  let bytes = 0;
449
947
  for (const file of listing.files) {
450
948
  try {
451
- bytes += (await stat(join3(options.auditDir, file.name))).size;
949
+ bytes += (await stat(join6(options.auditDir, file.name))).size;
452
950
  files += 1;
453
951
  } catch {
454
952
  }
@@ -485,6 +983,7 @@ var RpcApplicationError = class extends Error {
485
983
  }
486
984
  };
487
985
  var appBackgroundedMessage = (alias) => `Session "${alias}" is suspended because the app is in the background. Bring the app to the foreground to resume it.`;
986
+ var appReconnectedMessage = (alias) => `Session "${alias}" reconnected while the call was pending, so its result was lost. The call can be retried.`;
488
987
 
489
988
  // src/daemon/timers.ts
490
989
  var systemTimers = {
@@ -505,6 +1004,7 @@ var clampTimeout = (timeoutMs) => {
505
1004
  };
506
1005
  var TERMINATING_KIND_ERROR_TYPES = {
507
1006
  session_suspended: "session_suspended",
1007
+ session_resumed: "session_suspended",
508
1008
  session_revoked: "unknown_session",
509
1009
  session_expired: "unknown_session"
510
1010
  };
@@ -526,7 +1026,7 @@ var createCallsManager = (options) => {
526
1026
  };
527
1027
  const call = (session, name, args, timeoutMs) => {
528
1028
  const callId = generateCallId();
529
- const result = new Promise((resolve, reject) => {
1029
+ const result = new Promise((resolve2, reject) => {
530
1030
  const effectiveTimeoutMs = clampTimeout(timeoutMs);
531
1031
  const timer = timers.setTimeout(() => {
532
1032
  const pending = takePending(session.sessionId, callId);
@@ -539,7 +1039,7 @@ var createCallsManager = (options) => {
539
1039
  sessionMap = /* @__PURE__ */ new Map();
540
1040
  pendingBySession.set(session.sessionId, sessionMap);
541
1041
  }
542
- sessionMap.set(callId, { alias: session.alias, resolve, reject, timer });
1042
+ sessionMap.set(callId, { alias: session.alias, resolve: resolve2, reject, timer });
543
1043
  const message = {
544
1044
  type: "tool_call",
545
1045
  session_id: session.sessionId,
@@ -598,7 +1098,7 @@ var createCallsManager = (options) => {
598
1098
  pending.reject(
599
1099
  new RpcApplicationError(
600
1100
  errorType,
601
- options2.backgrounded ? appBackgroundedMessage(pending.alias) : "Session transitioned while the call was pending."
1101
+ options2.message?.(pending.alias) ?? "Session transitioned while the call was pending."
602
1102
  )
603
1103
  );
604
1104
  }
@@ -607,7 +1107,8 @@ var createCallsManager = (options) => {
607
1107
  const errorType = TERMINATING_KIND_ERROR_TYPES[event.kind];
608
1108
  if (errorType && event.sessionId) {
609
1109
  const backgrounded = event.kind === "session_suspended" && event.data.reason === "app_backgrounded";
610
- rejectSession(event.sessionId, errorType, { backgrounded });
1110
+ const message = event.kind === "session_resumed" ? appReconnectedMessage : backgrounded ? appBackgroundedMessage : void 0;
1111
+ rejectSession(event.sessionId, errorType, { message });
611
1112
  }
612
1113
  });
613
1114
  const disposeAll = () => {
@@ -788,8 +1289,14 @@ var createEventsLog = (options) => {
788
1289
  };
789
1290
 
790
1291
  // src/daemon/listener.ts
1292
+ import { createServer as createHttpServer } from "node:http";
791
1293
  import { createServer as createHttpsServer } from "node:https";
792
- import { isSessionClaimMessage, isSessionResumeMessage, isSessionBoundMessage } from "@appduct/shared";
1294
+ import { createServer as createNetServer } from "node:net";
1295
+ import {
1296
+ isSessionClaimMessage,
1297
+ isSessionResumeMessage,
1298
+ isSessionBoundMessage
1299
+ } from "@appduct/shared";
793
1300
  import { WebSocketServer } from "ws";
794
1301
 
795
1302
  // src/daemon/sessions.ts
@@ -830,13 +1337,13 @@ var createPendingLinkRegistry = (options) => {
830
1337
  options.eventBus.drop(sessionId);
831
1338
  };
832
1339
  return {
833
- create: (ttlSeconds, addressOverride) => {
1340
+ create: (ttlSeconds, addressOverride, transport = "native") => {
834
1341
  const sessionId = generateSessionId();
835
1342
  const token = randomBytes2(TOKEN_BYTES);
836
1343
  const createdAt = options.clock.now();
837
1344
  const expiresAt = new Date(createdAt.getTime() + ttlSeconds * 1e3);
838
- const detectedEndpoint = options.getEndpoint();
839
- const endpoint = addressOverride ? {
1345
+ const detectedEndpoint = options.getEndpoint(transport);
1346
+ const endpoint = addressOverride && transport === "native" ? {
840
1347
  family: addressOverride.includes(":") ? 6 : 4,
841
1348
  address: addressOverride,
842
1349
  port: detectedEndpoint.port
@@ -848,7 +1355,7 @@ var createPendingLinkRegistry = (options) => {
848
1355
  record.timer = options.timers.setTimeout(() => discard(sessionId), ttlSeconds * 1e3);
849
1356
  }
850
1357
  }, ttlSeconds * 1e3);
851
- const link = { sessionId, token, createdAt, expiresAt };
1358
+ const link = { sessionId, token, createdAt, expiresAt, transport };
852
1359
  records.set(sessionId, { link, attempts: 0, timer });
853
1360
  const deepLinkPayload = encodeBootstrap({
854
1361
  ...endpoint,
@@ -1099,10 +1606,11 @@ var createSessionManager = (options) => {
1099
1606
  }
1100
1607
  }
1101
1608
  }, options.keepaliveIntervalSeconds * 1e3);
1102
- const createLink = (ttlSeconds, addressOverride) => {
1609
+ const createLink = (ttlSeconds, addressOverride, transport) => {
1103
1610
  const { link, deepLinkPayload, endpoint } = pendingLinks.create(
1104
1611
  ttlSeconds ?? options.linkTtlSeconds,
1105
- addressOverride
1612
+ addressOverride,
1613
+ transport
1106
1614
  );
1107
1615
  return {
1108
1616
  sessionId: link.sessionId,
@@ -1111,7 +1619,7 @@ var createSessionManager = (options) => {
1111
1619
  expiresAt: Math.floor(link.expiresAt.getTime() / 1e3)
1112
1620
  };
1113
1621
  };
1114
- const handleClaim = (socket, message) => {
1622
+ const handleClaim = (socket, message, transport) => {
1115
1623
  const pending = pendingLinks.get(message.session_id);
1116
1624
  if (!pending) {
1117
1625
  if (sessions.has(message.session_id)) {
@@ -1121,6 +1629,10 @@ var createSessionManager = (options) => {
1121
1629
  }
1122
1630
  return null;
1123
1631
  }
1632
+ if (pending.transport !== transport) {
1633
+ closeSocket(socket, 1008, "wrong_transport");
1634
+ return null;
1635
+ }
1124
1636
  const nowMs = clock.now().getTime();
1125
1637
  if (pending.expiresAt.getTime() <= nowMs) {
1126
1638
  pendingLinks.discard(pending.sessionId);
@@ -1150,6 +1662,7 @@ var createSessionManager = (options) => {
1150
1662
  device,
1151
1663
  createdAt: pending.createdAt,
1152
1664
  claimedAt: clock.now(),
1665
+ transport,
1153
1666
  resumeToken: randomBytes3(RESUME_TOKEN_BYTES),
1154
1667
  socket,
1155
1668
  missedPongs: 0,
@@ -1166,12 +1679,16 @@ var createSessionManager = (options) => {
1166
1679
  emit("session_claimed", sessionId, alias, { device });
1167
1680
  return sessionId;
1168
1681
  };
1169
- const handleResume = (socket, message) => {
1682
+ const handleResume = (socket, message, transport) => {
1170
1683
  const session = sessions.get(message.session_id);
1171
1684
  if (!session) {
1172
1685
  closeSocket(socket, 1008, "unknown_session");
1173
1686
  return null;
1174
1687
  }
1688
+ if (session.transport !== transport) {
1689
+ closeSocket(socket, 1008, "wrong_transport");
1690
+ return null;
1691
+ }
1175
1692
  const candidateToken = decodeFixedLengthToken(message.resume_token, session.resumeToken.length);
1176
1693
  if (!tokensMatch(candidateToken, session.resumeToken)) {
1177
1694
  closeSocket(socket, 1008, "invalid_resume_token");
@@ -1203,6 +1720,9 @@ var createSessionManager = (options) => {
1203
1720
  closeSocket(socket, 1008, "unknown_session");
1204
1721
  return;
1205
1722
  }
1723
+ if (socket !== session.socket) {
1724
+ return;
1725
+ }
1206
1726
  switch (message.type) {
1207
1727
  case "tool_registry_snapshot": {
1208
1728
  if (!isToolRegistrySnapshotMessage(message)) {
@@ -1352,19 +1872,12 @@ var createSessionManager = (options) => {
1352
1872
  // src/daemon/listener.ts
1353
1873
  var DEFAULT_PRE_CLAIM_TIMEOUT_MS = 1e4;
1354
1874
  var MAX_PAYLOAD_BYTES = 256 * 1024;
1875
+ var MAX_PORT_ATTEMPTS = 5;
1355
1876
  var isRecord = (value) => {
1356
1877
  return typeof value === "object" && value !== null && !Array.isArray(value);
1357
1878
  };
1358
- var startListener = async (options) => {
1359
- const timers = options.timers ?? systemTimers;
1360
- const preClaimTimeoutMs = options.preClaimTimeoutMs ?? DEFAULT_PRE_CLAIM_TIMEOUT_MS;
1361
- const material = options.tls.current();
1362
- const httpsServer = createHttpsServer({ key: material.keyPem, cert: material.certPem });
1363
- httpsServer.on("error", () => {
1364
- });
1365
- const wss = new WebSocketServer({ server: httpsServer, maxPayload: MAX_PAYLOAD_BYTES });
1366
- wss.on("error", () => {
1367
- });
1879
+ var attachFrameGate = (wss, options) => {
1880
+ const { timers, preClaimTimeoutMs } = options;
1368
1881
  wss.on("connection", (socket) => {
1369
1882
  socket.on("error", () => {
1370
1883
  });
@@ -1395,7 +1908,7 @@ var startListener = async (options) => {
1395
1908
  }
1396
1909
  if (claimedSessionId === null) {
1397
1910
  if (isSessionClaimMessage(parsed)) {
1398
- const result = options.sessionManager.handleClaim(socket, parsed);
1911
+ const result = options.sessionManager.handleClaim(socket, parsed, options.transport);
1399
1912
  if (result) {
1400
1913
  claimedSessionId = result;
1401
1914
  timers.clearTimeout(preClaimTimer);
@@ -1403,7 +1916,7 @@ var startListener = async (options) => {
1403
1916
  return;
1404
1917
  }
1405
1918
  if (isSessionResumeMessage(parsed)) {
1406
- const result = options.sessionManager.handleResume(socket, parsed);
1919
+ const result = options.sessionManager.handleResume(socket, parsed, options.transport);
1407
1920
  if (result) {
1408
1921
  claimedSessionId = result;
1409
1922
  timers.clearTimeout(preClaimTimer);
@@ -1424,134 +1937,152 @@ var startListener = async (options) => {
1424
1937
  options.sessionManager.handlePostClaimMessage(claimedSessionId, socket, parsed);
1425
1938
  });
1426
1939
  });
1427
- await new Promise((resolve, reject) => {
1940
+ };
1941
+ var listen = (server, port, host) => {
1942
+ return new Promise((resolve2, reject) => {
1428
1943
  const onError = (error) => {
1429
- httpsServer.off("listening", onListening);
1944
+ server.off("listening", onListening);
1430
1945
  reject(error);
1431
1946
  };
1432
1947
  const onListening = () => {
1433
- httpsServer.off("error", onError);
1434
- resolve();
1948
+ server.off("error", onError);
1949
+ resolve2();
1435
1950
  };
1436
- httpsServer.once("error", onError);
1437
- httpsServer.once("listening", onListening);
1438
- httpsServer.listen(options.port);
1951
+ server.once("error", onError);
1952
+ server.once("listening", onListening);
1953
+ server.listen(port, host);
1439
1954
  });
1440
- return {
1441
- httpsServer,
1442
- wss,
1443
- port: () => {
1444
- const address = httpsServer.address();
1445
- return address && typeof address !== "string" ? address.port : void 0;
1446
- },
1447
- applyTls: (nextMaterial) => {
1448
- const server = httpsServer;
1449
- server.setSecureContext?.({ key: nextMaterial.keyPem, cert: nextMaterial.certPem });
1450
- },
1451
- close: () => new Promise((resolve) => {
1452
- for (const client of wss.clients) {
1453
- client.terminate();
1454
- }
1455
- wss.close(() => {
1456
- httpsServer.closeAllConnections();
1457
- httpsServer.close(() => resolve());
1458
- });
1459
- })
1460
- };
1461
- };
1462
-
1463
- // src/daemon/policy.ts
1464
- var policyOverrideKey = (alias, toolName) => `${alias}/${toolName}`;
1465
- var evaluate = (descriptor, session, policy) => {
1466
- const override = policy.tools?.[policyOverrideKey(session.alias, descriptor.name)];
1467
- if (override !== void 0) {
1468
- return override;
1469
- }
1470
- if (descriptor.annotations?.destructiveHint === true) {
1471
- return policy.destructive;
1472
- }
1473
- return policy.default;
1474
1955
  };
1475
-
1476
- // src/daemon/pidfile.ts
1477
- import { readFileSync as readFileSync3 } from "node:fs";
1478
- import { open as open2, readFile as readFile2, rm as rm2 } from "node:fs/promises";
1479
- var DaemonAlreadyRunningError = class extends Error {
1480
- constructor(pid) {
1481
- super(`Appduct daemon is already running (pid ${pid}).`);
1482
- this.pid = pid;
1483
- this.name = "DaemonAlreadyRunningError";
1484
- }
1956
+ var closeServer = (wss, server) => {
1957
+ return new Promise((resolve2) => {
1958
+ for (const client of wss.clients) {
1959
+ client.terminate();
1960
+ }
1961
+ wss.close(() => {
1962
+ server.closeAllConnections();
1963
+ server.close(() => resolve2());
1964
+ });
1965
+ });
1485
1966
  };
1486
- var readProcStatus = (pid) => {
1487
- try {
1488
- return readFileSync3(`/proc/${pid}/status`, "utf8");
1489
- } catch {
1490
- return void 0;
1491
- }
1967
+ var boundPort = (server) => {
1968
+ const address = server.address();
1969
+ return address && typeof address !== "string" ? address.port : void 0;
1492
1970
  };
1493
- var isZombie = (pid, readStatus) => {
1494
- const status = readStatus(pid);
1495
- if (status === void 0) {
1496
- return false;
1497
- }
1498
- return /^State:\s*Z\b/mu.test(status);
1971
+ var isAddressInUse = (error) => {
1972
+ return error.code === "EADDRINUSE";
1499
1973
  };
1500
- var isProcessAlive = (pid, readStatus = readProcStatus) => {
1501
- try {
1502
- process.kill(pid, 0);
1503
- } catch (error) {
1504
- return error.code === "EPERM";
1505
- }
1506
- return !isZombie(pid, readStatus);
1974
+ var portInUseError = (port) => {
1975
+ return new Error(
1976
+ `Port ${port} is already in use by another program. Stop that program, or set a different wssPort in config.json.`
1977
+ );
1507
1978
  };
1508
- var readPidFromFile = async (pidFilePath) => {
1509
- let contents;
1510
- try {
1511
- contents = await readFile2(pidFilePath, "utf8");
1512
- } catch (error) {
1513
- if (error.code === "ENOENT") {
1514
- return void 0;
1979
+ var startListener = async (options) => {
1980
+ const timers = options.timers ?? systemTimers;
1981
+ const preClaimTimeoutMs = options.preClaimTimeoutMs ?? DEFAULT_PRE_CLAIM_TIMEOUT_MS;
1982
+ const material = options.tls.current();
1983
+ const httpsServer = createHttpsServer({ key: material.keyPem, cert: material.certPem });
1984
+ httpsServer.on("error", () => {
1985
+ });
1986
+ const wss = new WebSocketServer({ server: httpsServer, maxPayload: MAX_PAYLOAD_BYTES });
1987
+ wss.on("error", () => {
1988
+ });
1989
+ attachFrameGate(wss, { sessionManager: options.sessionManager, transport: "native", preClaimTimeoutMs, timers });
1990
+ let loopbackServer;
1991
+ for (let attempt = 1; ; attempt += 1) {
1992
+ try {
1993
+ await listen(httpsServer, options.port);
1994
+ } catch (error) {
1995
+ throw isAddressInUse(error) ? portInUseError(options.port) : error;
1515
1996
  }
1516
- throw error;
1517
- }
1518
- const pid = Number.parseInt(contents.trim(), 10);
1519
- return Number.isInteger(pid) && pid > 0 ? pid : void 0;
1520
- };
1521
- var acquirePidfile = async (pidFilePath, options = {}) => {
1522
- const pid = options.pid ?? process.pid;
1523
- for (let attempt = 0; attempt < 5; attempt += 1) {
1997
+ if (!options.bindLoopback) {
1998
+ break;
1999
+ }
2000
+ const port = boundPort(httpsServer);
2001
+ const candidate = createNetServer((socket) => httpsServer.emit("connection", socket));
2002
+ candidate.on("error", () => {
2003
+ });
1524
2004
  try {
1525
- const handle = await open2(pidFilePath, "wx", 384);
1526
- try {
1527
- await handle.writeFile(String(pid), "utf8");
1528
- } finally {
1529
- await handle.close();
1530
- }
1531
- let released = false;
1532
- return {
1533
- pid,
1534
- release: async () => {
1535
- if (released) {
1536
- return;
1537
- }
1538
- released = true;
1539
- await rm2(pidFilePath, { force: true });
1540
- }
1541
- };
2005
+ await listen(candidate, port, "127.0.0.1");
2006
+ loopbackServer = candidate;
2007
+ break;
1542
2008
  } catch (error) {
1543
- if (error.code !== "EEXIST") {
2009
+ await new Promise((resolve2) => httpsServer.close(() => resolve2()));
2010
+ if (!isAddressInUse(error)) {
1544
2011
  throw error;
1545
2012
  }
1546
- const existingPid = await readPidFromFile(pidFilePath);
1547
- if (existingPid !== void 0 && isProcessAlive(existingPid)) {
1548
- throw new DaemonAlreadyRunningError(existingPid);
2013
+ if (options.port !== 0) {
2014
+ throw portInUseError(port);
2015
+ }
2016
+ if (attempt === MAX_PORT_ATTEMPTS) {
2017
+ throw new Error(`Could not find a free port: ${MAX_PORT_ATTEMPTS} ports in a row were in use on 127.0.0.1.`);
2018
+ }
2019
+ }
2020
+ }
2021
+ return {
2022
+ httpsServer,
2023
+ wss,
2024
+ port: () => boundPort(httpsServer),
2025
+ applyTls: (nextMaterial) => {
2026
+ const server = httpsServer;
2027
+ server.setSecureContext?.({ key: nextMaterial.keyPem, cert: nextMaterial.certPem });
2028
+ },
2029
+ close: () => {
2030
+ loopbackServer?.close();
2031
+ return closeServer(wss, httpsServer);
2032
+ }
2033
+ };
2034
+ };
2035
+ var LOOPBACK_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "[::1]"]);
2036
+ var isLoopbackOrigin = (origin) => {
2037
+ try {
2038
+ const url = new URL(origin);
2039
+ return (url.protocol === "http:" || url.protocol === "https:") && LOOPBACK_HOSTNAMES.has(url.hostname);
2040
+ } catch {
2041
+ return false;
2042
+ }
2043
+ };
2044
+ var startWebListener = async (options) => {
2045
+ const timers = options.timers ?? systemTimers;
2046
+ const preClaimTimeoutMs = options.preClaimTimeoutMs ?? DEFAULT_PRE_CLAIM_TIMEOUT_MS;
2047
+ const allowed = new Set(options.origins);
2048
+ const httpServer = createHttpServer((_request, response) => {
2049
+ response.writeHead(426, { "content-type": "text/plain" });
2050
+ response.end("Appduct web listener: WebSocket upgrades only.");
2051
+ });
2052
+ httpServer.on("error", () => {
2053
+ });
2054
+ const wss = new WebSocketServer({
2055
+ server: httpServer,
2056
+ maxPayload: MAX_PAYLOAD_BYTES,
2057
+ verifyClient: ({ origin }, callback) => {
2058
+ if (origin === void 0 || isLoopbackOrigin(origin) || allowed.has(origin)) {
2059
+ callback(true);
2060
+ } else {
2061
+ callback(false, 403, "Forbidden");
1549
2062
  }
1550
- await rm2(pidFilePath, { force: true });
1551
- await options.onStaleTakeover?.();
1552
2063
  }
2064
+ });
2065
+ wss.on("error", () => {
2066
+ });
2067
+ attachFrameGate(wss, { sessionManager: options.sessionManager, transport: "web", preClaimTimeoutMs, timers });
2068
+ await listen(httpServer, 0, "127.0.0.1");
2069
+ return {
2070
+ port: () => boundPort(httpServer),
2071
+ close: () => closeServer(wss, httpServer)
2072
+ };
2073
+ };
2074
+
2075
+ // src/daemon/policy.ts
2076
+ var policyOverrideKey = (alias, toolName) => `${alias}/${toolName}`;
2077
+ var evaluate = (descriptor, session, policy) => {
2078
+ const override = policy.tools?.[policyOverrideKey(session.alias, descriptor.name)];
2079
+ if (override !== void 0) {
2080
+ return override;
1553
2081
  }
1554
- throw new Error(`Failed to acquire the Appduct daemon pidfile at "${pidFilePath}".`);
2082
+ if (descriptor.annotations?.destructiveHint === true) {
2083
+ return policy.destructive;
2084
+ }
2085
+ return policy.default;
1555
2086
  };
1556
2087
 
1557
2088
  // src/daemon/node-append-only-file.ts
@@ -1587,7 +2118,7 @@ var NodeAppendOnlyFile = class {
1587
2118
 
1588
2119
  // src/daemon/rpc-server.ts
1589
2120
  import { createServer } from "node:net";
1590
- import { chmod as chmod2, rm as rm3 } from "node:fs/promises";
2121
+ import { chmod as chmod3, rm as rm3 } from "node:fs/promises";
1591
2122
  var MAX_LINE_BYTES = 1024 * 1024;
1592
2123
  var MAX_NOTIFY_BUFFERED_BYTES = 4 * 1024 * 1024;
1593
2124
  var isPlainObject = (value) => {
@@ -1725,20 +2256,20 @@ var startRpcServer = async (options) => {
1725
2256
  });
1726
2257
  server.on("error", () => {
1727
2258
  });
1728
- await new Promise((resolve, reject) => {
2259
+ await new Promise((resolve2, reject) => {
1729
2260
  const onError = (error) => {
1730
2261
  server.off("listening", onListening);
1731
2262
  reject(error);
1732
2263
  };
1733
2264
  const onListening = () => {
1734
2265
  server.off("error", onError);
1735
- resolve();
2266
+ resolve2();
1736
2267
  };
1737
2268
  server.once("error", onError);
1738
2269
  server.once("listening", onListening);
1739
2270
  server.listen(options.socketPath);
1740
2271
  });
1741
- await chmod2(options.socketPath, 384);
2272
+ await chmod3(options.socketPath, 384);
1742
2273
  return {
1743
2274
  server,
1744
2275
  notify: (connection, payload) => {
@@ -1752,42 +2283,15 @@ var startRpcServer = async (options) => {
1752
2283
  writeLine(connection.socket, { jsonrpc: "2.0", method: "event", params: payload });
1753
2284
  },
1754
2285
  connections: () => Array.from(connections.values()),
1755
- close: () => new Promise((resolve) => {
2286
+ close: () => new Promise((resolve2) => {
1756
2287
  for (const connection of connections.values()) {
1757
2288
  connection.socket.destroy();
1758
2289
  }
1759
- server.close(() => resolve());
2290
+ server.close(() => resolve2());
1760
2291
  })
1761
2292
  };
1762
2293
  };
1763
2294
 
1764
- // src/daemon/state-dir.ts
1765
- import { join as join4 } from "node:path";
1766
- import { mkdir as mkdir2, chmod as chmod3 } from "node:fs/promises";
1767
- var getStateDirPaths = (stateDir) => {
1768
- return {
1769
- root: stateDir,
1770
- socketPath: join4(stateDir, "daemon.sock"),
1771
- pidFilePath: join4(stateDir, "daemon.pid"),
1772
- logFilePath: join4(stateDir, "daemon.log"),
1773
- eventsLogPath: join4(stateDir, "events.log"),
1774
- keyPath: join4(stateDir, "key.pem"),
1775
- configPath: join4(stateDir, "config.json"),
1776
- auditDir: join4(stateDir, "audit"),
1777
- spawnLockPath: join4(stateDir, "daemon.spawn.lock")
1778
- };
1779
- };
1780
- var getSocketPath = (paths) => {
1781
- return paths.socketPath;
1782
- };
1783
- var ensureStateDir = async (stateDir) => {
1784
- const paths = getStateDirPaths(stateDir);
1785
- await mkdir2(paths.auditDir, { recursive: true });
1786
- await chmod3(paths.root, 448);
1787
- await chmod3(paths.auditDir, 448);
1788
- return paths;
1789
- };
1790
-
1791
2295
  // src/daemon/tls.ts
1792
2296
  import { stat as stat3, readFile as readFile3 } from "node:fs/promises";
1793
2297
 
@@ -1965,7 +2469,7 @@ var generateHostCertificate = async (keyPem, advertisedIp) => {
1965
2469
 
1966
2470
  // src/key-material.ts
1967
2471
  import { generateKeyPairSync } from "node:crypto";
1968
- import { dirname as dirname2 } from "node:path";
2472
+ import { dirname as dirname3 } from "node:path";
1969
2473
  import { mkdir as mkdir3, rename as rename2, rm as rm4, writeFile } from "node:fs/promises";
1970
2474
  var generatePrivateKeyPem = () => {
1971
2475
  const { privateKey } = generateKeyPairSync("rsa", {
@@ -1979,7 +2483,7 @@ var generatePrivateKeyPem = () => {
1979
2483
  };
1980
2484
  var writePrivateKeyAtomically = async (path, keyPem) => {
1981
2485
  const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
1982
- await mkdir3(dirname2(path), { recursive: true });
2486
+ await mkdir3(dirname3(path), { recursive: true });
1983
2487
  try {
1984
2488
  await writeFile(temporaryPath, keyPem, {
1985
2489
  encoding: "utf8",
@@ -2052,13 +2556,14 @@ var toAgentEndpoint = (address, port) => {
2052
2556
  var KNOWN_EVENT_KINDS = new Set(EVENT_KINDS);
2053
2557
  var packageVersion = () => getDaemonReportedVersion();
2054
2558
  var AUDIT_PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1e3;
2055
- var buildStatusResult = async (config, boundWssPort, startedAt, tls, sessionManager, auditLogger, auditDir) => {
2559
+ var buildStatusResult = async (config, boundWssPort, boundWebPort, startedAt, tls, sessionManager, auditLogger, auditDir) => {
2056
2560
  const stats = await auditLogger.stats();
2057
2561
  return {
2058
2562
  version: packageVersion(),
2059
2563
  pid: process.pid,
2060
2564
  startedAt: startedAt.toISOString(),
2061
2565
  wssPort: boundWssPort,
2566
+ webPort: boundWebPort,
2062
2567
  pinnedKeys: tls.pinnedKeys(),
2063
2568
  sessions: sessionManager.list(),
2064
2569
  pendingLinks: sessionManager.pendingLinkCount(),
@@ -2272,11 +2777,51 @@ var asLinkCreateParams = (params) => {
2272
2777
  if (addressOverride !== void 0 && (typeof addressOverride !== "string" || addressOverride.length === 0)) {
2273
2778
  throw new RpcApplicationError("invalid_request", '"addressOverride" must be a non-empty string.');
2274
2779
  }
2275
- return { ttlSeconds, addressOverride };
2780
+ const transport = record.transport;
2781
+ if (transport !== void 0 && transport !== "native" && transport !== "web") {
2782
+ throw new RpcApplicationError("invalid_request", '"transport" must be "native" or "web".');
2783
+ }
2784
+ return { ttlSeconds, addressOverride, transport };
2785
+ };
2786
+ var LOOPBACK_HOSTS = /* @__PURE__ */ new Set(["127.0.0.1", "[::1]", "localhost"]);
2787
+ var asWebAttachParams = (params) => {
2788
+ if (typeof params !== "object" || params === null || Array.isArray(params)) {
2789
+ throw new RpcApplicationError("invalid_request", "Params must be an object.");
2790
+ }
2791
+ const { url, browserUrl, targetId, ttlSeconds } = params;
2792
+ for (const [field, value] of [["url", url], ["browserUrl", browserUrl], ["targetId", targetId]]) {
2793
+ if (value !== void 0 && (typeof value !== "string" || value.length === 0)) {
2794
+ throw new RpcApplicationError("invalid_request", `"${field}" must be a non-empty string.`);
2795
+ }
2796
+ }
2797
+ if (url === void 0 || browserUrl === void 0) {
2798
+ throw new RpcApplicationError("invalid_request", '"url" and "browserUrl" are required.');
2799
+ }
2800
+ let parsedBrowserUrl;
2801
+ try {
2802
+ parsedBrowserUrl = new URL(browserUrl);
2803
+ } catch {
2804
+ }
2805
+ if (parsedBrowserUrl === void 0 || parsedBrowserUrl.protocol !== "http:" && parsedBrowserUrl.protocol !== "https:") {
2806
+ throw new RpcApplicationError("invalid_request", `"browserUrl" must be an http(s) URL such as http://127.0.0.1:9222 (got "${browserUrl}").`);
2807
+ }
2808
+ if (!LOOPBACK_HOSTS.has(parsedBrowserUrl.hostname)) {
2809
+ throw new RpcApplicationError(
2810
+ "invalid_request",
2811
+ `"browserUrl" must be a loopback address (127.0.0.1, [::1] or localhost), because a debugging port gives full control of the browser (got "${browserUrl}").`
2812
+ );
2813
+ }
2814
+ if (ttlSeconds !== void 0 && (typeof ttlSeconds !== "number" || !Number.isInteger(ttlSeconds) || ttlSeconds <= 0)) {
2815
+ throw new RpcApplicationError("invalid_request", '"ttlSeconds" must be a positive integer.');
2816
+ }
2817
+ return { url, browserUrl, targetId, ttlSeconds };
2276
2818
  };
2277
2819
  var startDaemon = async (options) => {
2278
2820
  const clock = options.clock ?? { now: () => /* @__PURE__ */ new Date() };
2279
2821
  const timers = options.timers ?? systemTimers;
2822
+ const connectDevtoolsBrowser = options.connectDevtoolsBrowser ?? connectNodeDevtoolsBrowser;
2823
+ const devtoolsBrowsers = /* @__PURE__ */ new Map();
2824
+ const relayedTabs = /* @__PURE__ */ new Map();
2280
2825
  const paths = getStateDirPaths(options.stateDir);
2281
2826
  await ensureStateDir(options.stateDir);
2282
2827
  const config = await loadConfig(paths, { warn: options.warn });
@@ -2299,15 +2844,17 @@ var startDaemon = async (options) => {
2299
2844
  let pidfile;
2300
2845
  let server;
2301
2846
  let listener;
2847
+ let webListener;
2302
2848
  let tlsManager;
2303
2849
  let sessionManager;
2304
2850
  let callsManager;
2305
2851
  let eventBus;
2306
2852
  let boundWssPort = config.wssPort;
2853
+ let boundWebPort = 0;
2307
2854
  let shuttingDown = false;
2308
2855
  let resolveExited;
2309
- const exited = new Promise((resolve) => {
2310
- resolveExited = resolve;
2856
+ const exited = new Promise((resolve2) => {
2857
+ resolveExited = resolve2;
2311
2858
  });
2312
2859
  const shutdown = async () => {
2313
2860
  if (shuttingDown) {
@@ -2320,8 +2867,10 @@ var startDaemon = async (options) => {
2320
2867
  auditPruneInterval = void 0;
2321
2868
  }
2322
2869
  callsManager?.disposeAll();
2870
+ devtoolsBrowsers.forEach((browser) => browser.close());
2323
2871
  sessionManager?.disposeAll(1001, "daemon_shutdown");
2324
2872
  await listener?.close();
2873
+ await webListener?.close();
2325
2874
  await server?.close();
2326
2875
  await pidfile?.release();
2327
2876
  await rm5(getSocketPath(paths), { force: true });
@@ -2353,7 +2902,8 @@ var startDaemon = async (options) => {
2353
2902
  linkTtlSeconds: config.linkTtlSeconds,
2354
2903
  // `boundWssPort`, not `config.wssPort`: a link minted by a daemon on an OS-assigned port
2355
2904
  // must advertise the port the app can actually reach, not the `0` that asked for one.
2356
- getEndpoint: () => toAgentEndpoint(tls.current().advertisedAddress, boundWssPort),
2905
+ // A web link points at the loopback web listener, never the advertised LAN address.
2906
+ getEndpoint: (transport) => transport === "web" ? { family: 4, address: "127.0.0.1", port: boundWebPort } : toAgentEndpoint(tls.current().advertisedAddress, boundWssPort),
2357
2907
  eventBus: activeEventBus,
2358
2908
  clock,
2359
2909
  onToolFrame: (message) => {
@@ -2386,11 +2936,14 @@ var startDaemon = async (options) => {
2386
2936
  const activeCallsManager = callsManager;
2387
2937
  listener = await startListener({
2388
2938
  port: config.wssPort,
2939
+ bindLoopback: process.platform === "darwin",
2389
2940
  tls,
2390
2941
  sessionManager
2391
2942
  });
2392
2943
  const activeListener = listener;
2393
2944
  boundWssPort = activeListener.port() ?? config.wssPort;
2945
+ webListener = await startWebListener({ sessionManager, origins: config.webOrigins, timers });
2946
+ boundWebPort = webListener.port() ?? 0;
2394
2947
  activeEventBus.subscribe((event) => {
2395
2948
  for (const connection of server?.connections() ?? []) {
2396
2949
  const subscription = connection.state.eventSubscription;
@@ -2413,13 +2966,22 @@ var startDaemon = async (options) => {
2413
2966
  server?.notify(connection, projected);
2414
2967
  }
2415
2968
  });
2969
+ const mintLink = async (ttlSeconds, addressOverride, transport) => {
2970
+ const previousMaterial = tls.current();
2971
+ const nextMaterial = await tls.refresh();
2972
+ if (nextMaterial !== previousMaterial) {
2973
+ activeListener.applyTls(nextMaterial);
2974
+ }
2975
+ return { ...activeSessionManager.createLink(ttlSeconds, addressOverride, transport), pin: tls.pinnedKeys()[0] };
2976
+ };
2416
2977
  server = await startRpcServer({
2417
2978
  socketPath: getSocketPath(paths),
2418
2979
  dispatch: {
2419
- [RPC_METHODS.daemonStatus]: async () => {
2980
+ [RPC_METHODS3.daemonStatus]: async () => {
2420
2981
  return buildStatusResult(
2421
2982
  config,
2422
2983
  boundWssPort,
2984
+ boundWebPort,
2423
2985
  startedAt,
2424
2986
  tls,
2425
2987
  activeSessionManager,
@@ -2427,34 +2989,49 @@ var startDaemon = async (options) => {
2427
2989
  paths.auditDir
2428
2990
  );
2429
2991
  },
2430
- [RPC_METHODS.daemonShutdown]: (_params, context) => {
2992
+ [RPC_METHODS3.daemonShutdown]: (_params, context) => {
2431
2993
  context.afterSend(() => {
2432
2994
  void shutdown();
2433
2995
  });
2434
2996
  return { ok: true };
2435
2997
  },
2436
- [RPC_METHODS.linkCreate]: async (params) => {
2437
- const { ttlSeconds, addressOverride } = asLinkCreateParams(params);
2438
- const previousMaterial = tls.current();
2439
- const nextMaterial = await tls.refresh();
2440
- if (nextMaterial !== previousMaterial) {
2441
- activeListener.applyTls(nextMaterial);
2998
+ [RPC_METHODS3.linkCreate]: async (params) => {
2999
+ const { ttlSeconds, addressOverride, transport } = asLinkCreateParams(params);
3000
+ return mintLink(ttlSeconds, addressOverride, transport);
3001
+ },
3002
+ [RPC_METHODS3.webAttach]: async (params) => {
3003
+ const { url, browserUrl, targetId, ttlSeconds } = asWebAttachParams(params);
3004
+ let browser = devtoolsBrowsers.get(browserUrl);
3005
+ if (browser === void 0) {
3006
+ browser = connectDevtoolsBrowser(browserUrl);
3007
+ devtoolsBrowsers.set(browserUrl, browser);
3008
+ }
3009
+ try {
3010
+ return await attachBrowserTab({
3011
+ browser,
3012
+ relayedTabs,
3013
+ url,
3014
+ targetId,
3015
+ openDaemonSocket: openNodeDaemonSocket,
3016
+ mintLink: async () => composeWebLink(url, await mintLink(ttlSeconds, void 0, "web"))
3017
+ });
3018
+ } catch (error) {
3019
+ throw new RpcApplicationError("invalid_request", error instanceof Error ? error.message : String(error));
2442
3020
  }
2443
- return { ...activeSessionManager.createLink(ttlSeconds, addressOverride), pin: tls.pinnedKeys()[0] };
2444
3021
  },
2445
- [RPC_METHODS.sessionsList]: () => {
3022
+ [RPC_METHODS3.sessionsList]: () => {
2446
3023
  return activeSessionManager.list();
2447
3024
  },
2448
- [RPC_METHODS.sessionsDescribe]: (params) => {
3025
+ [RPC_METHODS3.sessionsDescribe]: (params) => {
2449
3026
  const { selector } = asSelectorParams(params);
2450
3027
  return activeSessionManager.describe(selector);
2451
3028
  },
2452
- [RPC_METHODS.sessionsRevoke]: (params) => {
3029
+ [RPC_METHODS3.sessionsRevoke]: (params) => {
2453
3030
  const { selector } = asSelectorParams(params);
2454
3031
  activeSessionManager.revoke(selector);
2455
3032
  return { ok: true };
2456
3033
  },
2457
- [RPC_METHODS.eventsList]: (params) => {
3034
+ [RPC_METHODS3.eventsList]: (params) => {
2458
3035
  const { selector, name, limit, offset } = asEventsListParams(params);
2459
3036
  const registered = activeSessionManager.resolveForTools(selector).eventRegistry.list();
2460
3037
  const sorted = registered.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
@@ -2465,7 +3042,7 @@ var startDaemon = async (options) => {
2465
3042
  total: matching.length
2466
3043
  };
2467
3044
  },
2468
- [RPC_METHODS.toolsList]: (params) => {
3045
+ [RPC_METHODS3.toolsList]: (params) => {
2469
3046
  const { selector, group, filter, limit, offset } = asToolsListParams(params);
2470
3047
  const resolved = activeSessionManager.resolveForTools(selector);
2471
3048
  const entries = resolved.registry.list().map((descriptor) => ({
@@ -2490,7 +3067,7 @@ var startDaemon = async (options) => {
2490
3067
  // This handler is the single seam every `tools.call` passes through: policy (ARCHITECTURE.md
2491
3068
  // §12) is evaluated once the target tool descriptor is known, and one audit record is
2492
3069
  // written for every attempt that reaches a resolved session, across ok/error/denied.
2493
- [RPC_METHODS.toolsCall]: async (params, context) => {
3070
+ [RPC_METHODS3.toolsCall]: async (params, context) => {
2494
3071
  const { selector, name, args, timeoutMs, caller, consent } = asToolsCallParams(params);
2495
3072
  const effectiveCaller = caller ?? "cli";
2496
3073
  const resolved = activeSessionManager.resolveForTools(selector);
@@ -2596,13 +3173,13 @@ var startDaemon = async (options) => {
2596
3173
  unregisterOnClose();
2597
3174
  }
2598
3175
  },
2599
- [RPC_METHODS.toolsCancel]: (params) => {
3176
+ [RPC_METHODS3.toolsCancel]: (params) => {
2600
3177
  const { selector, callId, reason } = asToolsCancelParams(params);
2601
3178
  const resolved = activeSessionManager.resolveForTools(selector);
2602
3179
  const cancelled = activeCallsManager.cancel(resolved.sessionId, callId, reason ?? "client_cancelled");
2603
3180
  return { cancelled };
2604
3181
  },
2605
- [RPC_METHODS.eventsSubscribe]: (params, context) => {
3182
+ [RPC_METHODS3.eventsSubscribe]: (params, context) => {
2606
3183
  const { sessionSelector, kinds, name, payloadMaxBytes } = asEventsSubscribeParams(params);
2607
3184
  context.connection.state.eventSubscription = {
2608
3185
  sessionSelector,
@@ -2612,7 +3189,7 @@ var startDaemon = async (options) => {
2612
3189
  };
2613
3190
  return { ok: true };
2614
3191
  },
2615
- [RPC_METHODS.eventsSince]: (params) => {
3192
+ [RPC_METHODS3.eventsSince]: (params) => {
2616
3193
  const { selector, since, limit, name, payloadMaxBytes } = asEventsSinceParams(params);
2617
3194
  const resolved = activeSessionManager.describe(selector);
2618
3195
  return activeEventBus.since(resolved.sessionId, { since, limit, name, payloadMaxBytes });
@@ -2631,6 +3208,7 @@ var startDaemon = async (options) => {
2631
3208
  callsManager?.disposeAll();
2632
3209
  sessionManager?.disposeAll(1001, "daemon_startup_failed");
2633
3210
  await listener?.close();
3211
+ await webListener?.close();
2634
3212
  await pidfile?.release();
2635
3213
  throw error;
2636
3214
  }
@@ -2641,6 +3219,7 @@ var startDaemon = async (options) => {
2641
3219
  startedAt,
2642
3220
  server,
2643
3221
  listener,
3222
+ webListener,
2644
3223
  tls: tlsManager,
2645
3224
  eventBus,
2646
3225
  exited,
@@ -2648,175 +3227,6 @@ var startDaemon = async (options) => {
2648
3227
  };
2649
3228
  };
2650
3229
 
2651
- // src/rpc/client.ts
2652
- import { join as join5 } from "node:path";
2653
- import { RPC_METHODS as RPC_METHODS2 } from "@appduct/shared";
2654
- var defaultBinPath = join5(getPackageRoot(), "bin.js");
2655
- var DaemonRpcError = class extends Error {
2656
- constructor(code, message, data) {
2657
- super(message);
2658
- this.code = code;
2659
- this.data = data;
2660
- this.name = "DaemonRpcError";
2661
- }
2662
- };
2663
- var DaemonUnavailableError = class extends Error {
2664
- constructor(message, reason = "unreachable") {
2665
- super(message);
2666
- this.reason = reason;
2667
- this.name = "DaemonUnavailableError";
2668
- }
2669
- };
2670
- var describeLiveState = (state) => {
2671
- const parts = [];
2672
- if (state.sessions > 0) {
2673
- parts.push(`${state.sessions} connected session(s)`);
2674
- }
2675
- if (state.pendingLinks > 0) {
2676
- parts.push(`${state.pendingLinks} unclaimed link(s)`);
2677
- }
2678
- return parts.join(" and ");
2679
- };
2680
- var DaemonVersionMismatchError = class extends Error {
2681
- constructor(daemonVersion, clientVersion, live, message) {
2682
- super(
2683
- message ?? `The running Appduct daemon is version ${daemonVersion}, but this client is version ${clientVersion}. It was not restarted automatically because ${describeLiveState(live)} would be lost. Run "appduct daemon stop" once that state is expendable, or force it with "--daemon-restart".`
2684
- );
2685
- this.daemonVersion = daemonVersion;
2686
- this.clientVersion = clientVersion;
2687
- this.name = "DaemonVersionMismatchError";
2688
- this.sessionCount = live.sessions;
2689
- this.pendingLinkCount = live.pendingLinks;
2690
- }
2691
- sessionCount;
2692
- pendingLinkCount;
2693
- };
2694
- var isConnectionMissingError = (error) => {
2695
- const code = error.code;
2696
- return code === "ENOENT" || code === "ECONNREFUSED";
2697
- };
2698
- var isDaemonUnreachableError = (error) => {
2699
- return error instanceof DaemonUnavailableError || isConnectionMissingError(error);
2700
- };
2701
-
2702
- // src/errors.ts
2703
- var EXIT_CODE_BY_ERROR_TYPE = {
2704
- usage_error: 64,
2705
- validation_error: 65,
2706
- // EX_NOINPUT (66): "an input file did not exist or was not readable" — `doctor` throws this for a
2707
- // missing external tool or an unreadable/corrupt artifact, deliberately distinct from every other
2708
- // code here so a release pipeline can't mistake "we couldn't tell" for either a clean pass or an
2709
- // assertion failure (docs/tasks/08-appduct-doctor.md).
2710
- inspection_error: 66,
2711
- connection_error: 70,
2712
- session_error: 71,
2713
- tool_error: 72,
2714
- permission_error: 77,
2715
- internal_error: 1,
2716
- // `doctor --assert-present`/`--assert-absent`: the artifact was inspected successfully and the
2717
- // assertion did not hold. Distinct from `inspection_error` (66) — this means the answer is known
2718
- // and it's "no", not "couldn't tell" — and distinct from `internal_error` (1) so a caller can't
2719
- // conflate a normal assertion failure with an unexpected crash.
2720
- assertion_error: 3
2721
- };
2722
- var RPC_ERROR_EXIT_CLASS = {
2723
- no_session: "session_error",
2724
- ambiguous_session: "session_error",
2725
- unknown_session: "session_error",
2726
- session_not_active: "session_error",
2727
- session_suspended: "session_error",
2728
- tool_not_found: "tool_error",
2729
- tool_input_validation_error: "tool_error",
2730
- tool_output_validation_error: "tool_error",
2731
- tool_execution_error: "tool_error",
2732
- tool_serialization_error: "tool_error",
2733
- tool_timeout: "tool_error",
2734
- tool_cancelled: "tool_error",
2735
- policy_denied: "permission_error",
2736
- invalid_request: "validation_error"
2737
- };
2738
- var AppductCliError = class extends Error {
2739
- type;
2740
- details;
2741
- constructor(type, message, details) {
2742
- super(message);
2743
- this.name = "AppductCliError";
2744
- this.type = type;
2745
- this.details = details;
2746
- }
2747
- };
2748
- var connectionError = (message, details) => {
2749
- return new AppductCliError("connection_error", message, details);
2750
- };
2751
- var isAppductCliError = (value) => {
2752
- return value instanceof AppductCliError;
2753
- };
2754
- var rpcErrorClass = (error) => {
2755
- const type = error.data?.type;
2756
- return type ? RPC_ERROR_EXIT_CLASS[type] : void 0;
2757
- };
2758
- var toCliError = (error) => {
2759
- if (isAppductCliError(error)) {
2760
- return {
2761
- type: error.type,
2762
- message: error.message,
2763
- details: error.details
2764
- };
2765
- }
2766
- if (error instanceof DaemonVersionMismatchError) {
2767
- return {
2768
- type: "connection_error",
2769
- message: error.message,
2770
- details: {
2771
- daemon_version: error.daemonVersion,
2772
- client_version: error.clientVersion,
2773
- session_count: error.sessionCount,
2774
- pending_link_count: error.pendingLinkCount
2775
- }
2776
- };
2777
- }
2778
- if (error instanceof DaemonRpcError && error.data?.type) {
2779
- return {
2780
- type: error.data.type,
2781
- message: error.message,
2782
- details: error.data.details
2783
- };
2784
- }
2785
- if (isDaemonUnreachableError(error)) {
2786
- return {
2787
- type: "connection_error",
2788
- message: error instanceof Error ? error.message : "The Appduct daemon is unreachable."
2789
- };
2790
- }
2791
- if (error instanceof Error) {
2792
- return {
2793
- type: "internal_error",
2794
- message: error.message
2795
- };
2796
- }
2797
- return {
2798
- type: "internal_error",
2799
- message: "An unexpected error occurred.",
2800
- details: error
2801
- };
2802
- };
2803
- var getExitCodeForError = (error) => {
2804
- if (isAppductCliError(error)) {
2805
- return EXIT_CODE_BY_ERROR_TYPE[error.type];
2806
- }
2807
- if (error instanceof DaemonVersionMismatchError) {
2808
- return EXIT_CODE_BY_ERROR_TYPE.connection_error;
2809
- }
2810
- if (error instanceof DaemonRpcError) {
2811
- const cliErrorClass = rpcErrorClass(error);
2812
- return EXIT_CODE_BY_ERROR_TYPE[cliErrorClass ?? "internal_error"];
2813
- }
2814
- if (isDaemonUnreachableError(error)) {
2815
- return EXIT_CODE_BY_ERROR_TYPE.connection_error;
2816
- }
2817
- return EXIT_CODE_BY_ERROR_TYPE.internal_error;
2818
- };
2819
-
2820
3230
  // src/commands/daemon/run.ts
2821
3231
  var handleDaemonRunCommand = async (context) => {
2822
3232
  let daemon;
@@ -3195,6 +3605,36 @@ var renderInvokeData = (colors, data, flags) => {
3195
3605
  return [colors.green("Result"), formatScalar(data, flags)];
3196
3606
  };
3197
3607
  var renderLinkData = (colors, data, flags, qr) => {
3608
+ if ("attached" in data) {
3609
+ return [
3610
+ colors.green("Page Attached"),
3611
+ ...renderFields(
3612
+ "Page",
3613
+ [
3614
+ ["Session", data.sessionId],
3615
+ ["Tab", data.targetId],
3616
+ ["URL", data.url],
3617
+ ["Expires", new Date(data.expiresAt * 1e3).toISOString()]
3618
+ ],
3619
+ flags
3620
+ )
3621
+ ];
3622
+ }
3623
+ if ("script" in data) {
3624
+ return [
3625
+ colors.green("Link Created"),
3626
+ ...renderFields(
3627
+ "Link",
3628
+ [
3629
+ ["Session", data.sessionId],
3630
+ ["URL", data.url],
3631
+ ["Script", data.script],
3632
+ ["Expires", new Date(data.expiresAt * 1e3).toISOString()]
3633
+ ],
3634
+ flags
3635
+ )
3636
+ ];
3637
+ }
3198
3638
  const lines = [
3199
3639
  colors.green("Link Created"),
3200
3640
  ...renderFields(
@@ -3315,6 +3755,7 @@ var renderDaemonStatusData = (colors, data, flags) => {
3315
3755
  ["PID", data.daemon.pid],
3316
3756
  ["Started at", data.daemon.started_at],
3317
3757
  ["WSS port", data.daemon.wss_port],
3758
+ ["Web port", data.daemon.web_port],
3318
3759
  ["Pinned keys", data.daemon.pinned_keys.length],
3319
3760
  ["Sessions", data.daemon.session_count]
3320
3761
  ],