appduct 0.14.0 → 0.15.1

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