appduct 0.9.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 (190) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +274 -0
  3. package/bin.js +2 -0
  4. package/dist/artifact-inspect.d.ts +110 -0
  5. package/dist/artifact-inspect.d.ts.map +1 -0
  6. package/dist/artifact-inspect.js +315 -0
  7. package/dist/bin.d.ts +3 -0
  8. package/dist/bin.d.ts.map +1 -0
  9. package/dist/bin.js +4 -0
  10. package/dist/cli/command-options.d.ts +44 -0
  11. package/dist/cli/command-options.d.ts.map +1 -0
  12. package/dist/cli/command-options.js +95 -0
  13. package/dist/cli/create-cli.d.ts +2 -0
  14. package/dist/cli/create-cli.d.ts.map +1 -0
  15. package/dist/cli/create-cli.js +56 -0
  16. package/dist/cli/dispatch.d.ts +3 -0
  17. package/dist/cli/dispatch.d.ts.map +1 -0
  18. package/dist/cli/dispatch.js +275 -0
  19. package/dist/cli/open-target.d.ts +112 -0
  20. package/dist/cli/open-target.d.ts.map +1 -0
  21. package/dist/cli/open-target.js +438 -0
  22. package/dist/cli/result-types.d.ts +194 -0
  23. package/dist/cli/result-types.d.ts.map +1 -0
  24. package/dist/cli/result-types.js +6 -0
  25. package/dist/cli/runner.d.ts +35 -0
  26. package/dist/cli/runner.d.ts.map +1 -0
  27. package/dist/cli/runner.js +111 -0
  28. package/dist/cli/types.d.ts +20 -0
  29. package/dist/cli/types.d.ts.map +1 -0
  30. package/dist/cli/types.js +10 -0
  31. package/dist/cli.d.ts +4 -0
  32. package/dist/cli.d.ts.map +1 -0
  33. package/dist/cli.js +2 -0
  34. package/dist/client/app-client.d.ts +126 -0
  35. package/dist/client/app-client.d.ts.map +1 -0
  36. package/dist/client/app-client.js +223 -0
  37. package/dist/client/bootstrap.d.ts +48 -0
  38. package/dist/client/bootstrap.d.ts.map +1 -0
  39. package/dist/client/bootstrap.js +108 -0
  40. package/dist/client/connect.d.ts +17 -0
  41. package/dist/client/connect.d.ts.map +1 -0
  42. package/dist/client/connect.js +38 -0
  43. package/dist/client/errors.d.ts +26 -0
  44. package/dist/client/errors.d.ts.map +1 -0
  45. package/dist/client/errors.js +40 -0
  46. package/dist/client/index.d.ts +19 -0
  47. package/dist/client/index.d.ts.map +1 -0
  48. package/dist/client/index.js +16 -0
  49. package/dist/commands/daemon.d.ts +22 -0
  50. package/dist/commands/daemon.d.ts.map +1 -0
  51. package/dist/commands/daemon.js +128 -0
  52. package/dist/commands/doctor.d.ts +23 -0
  53. package/dist/commands/doctor.d.ts.map +1 -0
  54. package/dist/commands/doctor.js +42 -0
  55. package/dist/commands/events.d.ts +37 -0
  56. package/dist/commands/events.d.ts.map +1 -0
  57. package/dist/commands/events.js +76 -0
  58. package/dist/commands/init.d.ts +51 -0
  59. package/dist/commands/init.d.ts.map +1 -0
  60. package/dist/commands/init.js +231 -0
  61. package/dist/commands/invoke.d.ts +22 -0
  62. package/dist/commands/invoke.d.ts.map +1 -0
  63. package/dist/commands/invoke.js +54 -0
  64. package/dist/commands/keygen.d.ts +23 -0
  65. package/dist/commands/keygen.d.ts.map +1 -0
  66. package/dist/commands/keygen.js +46 -0
  67. package/dist/commands/link.d.ts +30 -0
  68. package/dist/commands/link.d.ts.map +1 -0
  69. package/dist/commands/link.js +43 -0
  70. package/dist/commands/ls.d.ts +9 -0
  71. package/dist/commands/ls.d.ts.map +1 -0
  72. package/dist/commands/ls.js +7 -0
  73. package/dist/commands/mcp.d.ts +49 -0
  74. package/dist/commands/mcp.d.ts.map +1 -0
  75. package/dist/commands/mcp.js +98 -0
  76. package/dist/commands/revoke.d.ts +12 -0
  77. package/dist/commands/revoke.d.ts.map +1 -0
  78. package/dist/commands/revoke.js +7 -0
  79. package/dist/commands/tools.d.ts +23 -0
  80. package/dist/commands/tools.d.ts.map +1 -0
  81. package/dist/commands/tools.js +52 -0
  82. package/dist/daemon/address.d.ts +30 -0
  83. package/dist/daemon/address.d.ts.map +1 -0
  84. package/dist/daemon/address.js +71 -0
  85. package/dist/daemon/audit.d.ts +107 -0
  86. package/dist/daemon/audit.d.ts.map +1 -0
  87. package/dist/daemon/audit.js +237 -0
  88. package/dist/daemon/calls.d.ts +84 -0
  89. package/dist/daemon/calls.d.ts.map +1 -0
  90. package/dist/daemon/calls.js +158 -0
  91. package/dist/daemon/config.d.ts +79 -0
  92. package/dist/daemon/config.d.ts.map +1 -0
  93. package/dist/daemon/config.js +170 -0
  94. package/dist/daemon/daemon.d.ts +43 -0
  95. package/dist/daemon/daemon.d.ts.map +1 -0
  96. package/dist/daemon/daemon.js +560 -0
  97. package/dist/daemon/event-bus.d.ts +49 -0
  98. package/dist/daemon/event-bus.d.ts.map +1 -0
  99. package/dist/daemon/event-bus.js +111 -0
  100. package/dist/daemon/links.d.ts +54 -0
  101. package/dist/daemon/links.d.ts.map +1 -0
  102. package/dist/daemon/links.js +118 -0
  103. package/dist/daemon/listener.d.ts +34 -0
  104. package/dist/daemon/listener.d.ts.map +1 -0
  105. package/dist/daemon/listener.js +132 -0
  106. package/dist/daemon/log-rotation.d.ts +82 -0
  107. package/dist/daemon/log-rotation.d.ts.map +1 -0
  108. package/dist/daemon/log-rotation.js +136 -0
  109. package/dist/daemon/pidfile.d.ts +38 -0
  110. package/dist/daemon/pidfile.d.ts.map +1 -0
  111. package/dist/daemon/pidfile.js +90 -0
  112. package/dist/daemon/policy.d.ts +22 -0
  113. package/dist/daemon/policy.d.ts.map +1 -0
  114. package/dist/daemon/policy.js +24 -0
  115. package/dist/daemon/registry.d.ts +19 -0
  116. package/dist/daemon/registry.d.ts.map +1 -0
  117. package/dist/daemon/registry.js +31 -0
  118. package/dist/daemon/rpc-server.d.ts +54 -0
  119. package/dist/daemon/rpc-server.d.ts.map +1 -0
  120. package/dist/daemon/rpc-server.js +203 -0
  121. package/dist/daemon/sessions.d.ts +86 -0
  122. package/dist/daemon/sessions.d.ts.map +1 -0
  123. package/dist/daemon/sessions.js +429 -0
  124. package/dist/daemon/socket-probe.d.ts +16 -0
  125. package/dist/daemon/socket-probe.d.ts.map +1 -0
  126. package/dist/daemon/socket-probe.js +28 -0
  127. package/dist/daemon/state-dir.d.ts +31 -0
  128. package/dist/daemon/state-dir.d.ts.map +1 -0
  129. package/dist/daemon/state-dir.js +43 -0
  130. package/dist/daemon/timers.d.ts +16 -0
  131. package/dist/daemon/timers.d.ts.map +1 -0
  132. package/dist/daemon/timers.js +12 -0
  133. package/dist/daemon/tls.d.ts +58 -0
  134. package/dist/daemon/tls.d.ts.map +1 -0
  135. package/dist/daemon/tls.js +79 -0
  136. package/dist/errors.d.ts +28 -0
  137. package/dist/errors.d.ts.map +1 -0
  138. package/dist/errors.js +162 -0
  139. package/dist/host-certificate.d.ts +8 -0
  140. package/dist/host-certificate.d.ts.map +1 -0
  141. package/dist/host-certificate.js +111 -0
  142. package/dist/index.d.ts +4 -0
  143. package/dist/index.d.ts.map +1 -0
  144. package/dist/index.js +3 -0
  145. package/dist/key-material.d.ts +14 -0
  146. package/dist/key-material.d.ts.map +1 -0
  147. package/dist/key-material.js +42 -0
  148. package/dist/link.d.ts +76 -0
  149. package/dist/link.d.ts.map +1 -0
  150. package/dist/link.js +117 -0
  151. package/dist/mcp/connect-tool.d.ts +115 -0
  152. package/dist/mcp/connect-tool.d.ts.map +1 -0
  153. package/dist/mcp/connect-tool.js +396 -0
  154. package/dist/mcp/daemon-tools.d.ts +13 -0
  155. package/dist/mcp/daemon-tools.d.ts.map +1 -0
  156. package/dist/mcp/daemon-tools.js +29 -0
  157. package/dist/mcp/events-tool.d.ts +111 -0
  158. package/dist/mcp/events-tool.d.ts.map +1 -0
  159. package/dist/mcp/events-tool.js +322 -0
  160. package/dist/mcp/server.d.ts +58 -0
  161. package/dist/mcp/server.d.ts.map +1 -0
  162. package/dist/mcp/server.js +538 -0
  163. package/dist/mcp/tool-mapping.d.ts +58 -0
  164. package/dist/mcp/tool-mapping.d.ts.map +1 -0
  165. package/dist/mcp/tool-mapping.js +143 -0
  166. package/dist/mcp/tool-namespace.d.ts +40 -0
  167. package/dist/mcp/tool-namespace.d.ts.map +1 -0
  168. package/dist/mcp/tool-namespace.js +65 -0
  169. package/dist/native-scheme.d.ts +59 -0
  170. package/dist/native-scheme.d.ts.map +1 -0
  171. package/dist/native-scheme.js +465 -0
  172. package/dist/output.d.ts +28 -0
  173. package/dist/output.d.ts.map +1 -0
  174. package/dist/output.js +342 -0
  175. package/dist/package-version.d.ts +30 -0
  176. package/dist/package-version.d.ts.map +1 -0
  177. package/dist/package-version.js +47 -0
  178. package/dist/qr-terminal.d.ts +13 -0
  179. package/dist/qr-terminal.d.ts.map +1 -0
  180. package/dist/qr-terminal.js +45 -0
  181. package/dist/rpc/client.d.ts +137 -0
  182. package/dist/rpc/client.d.ts.map +1 -0
  183. package/dist/rpc/client.js +819 -0
  184. package/dist/scheme.d.ts +181 -0
  185. package/dist/scheme.d.ts.map +1 -0
  186. package/dist/scheme.js +321 -0
  187. package/dist/spki-pin.d.ts +3 -0
  188. package/dist/spki-pin.d.ts.map +1 -0
  189. package/dist/spki-pin.js +14 -0
  190. package/package.json +65 -0
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Typed in-process event bus (ARCHITECTURE.md §5 event kinds). The RPC layer (for
3
+ * `events.subscribe`/`events.since`) and audit subscribe here; this module owns fan-out plus a
4
+ * per-session retention ring buffer, and must never let a throwing subscriber take down the daemon
5
+ * or another subscriber.
6
+ *
7
+ * Retention (issue #6): every session-scoped event (`sessionId` set), except the high-frequency
8
+ * kinds in `EXCLUDED_FROM_RETENTION`, is appended to a per-session ring buffer with a
9
+ * monotonically increasing `seq`, capped at `bufferSize` entries (oldest dropped first). A
10
+ * session's buffer is discarded the moment it emits a terminal event (`session_expired` /
11
+ * `session_revoked`) — "terminal states free the alias" (sessions.ts) applies to retained events
12
+ * too, matching "no persisted history" (ARCHITECTURE.md §3/§13) — or via an explicit `drop()` for
13
+ * the pending-link discard paths that never reach one of those event kinds. Daemon-wide events (no
14
+ * `sessionId`, e.g. `daemon_started`) are fanned out but never buffered.
15
+ */
16
+ /** Terminal event kinds whose arrival for a session discards that session's retained buffer. */
17
+ const TERMINAL_EVENT_KINDS = new Set(["session_expired", "session_revoked"]);
18
+ /** Fanned out live like any other event, but never appended to the retention buffer: a single
19
+ * chatty `tools.call` can emit far more of these than `bufferSize` allows, which would otherwise
20
+ * evict every retained `app_event` for the session — defeating the reason the buffer exists.
21
+ * `tool_call_started`/`tool_call_finished` (one pair per call, not per progress tick) are still
22
+ * retained. */
23
+ const EXCLUDED_FROM_RETENTION = new Set(["tool_call_progress"]);
24
+ export const createEventBus = (options = {}) => {
25
+ const clock = options.clock ?? { now: () => new Date() };
26
+ const bufferSize = options.bufferSize ?? 256;
27
+ const listeners = new Set();
28
+ const buffers = new Map();
29
+ const cursors = new Map();
30
+ const appendToBuffer = (sessionId, notification) => {
31
+ const buffer = buffers.get(sessionId) ?? [];
32
+ buffer.push(notification);
33
+ if (buffer.length > bufferSize) {
34
+ buffer.splice(0, buffer.length - bufferSize);
35
+ }
36
+ buffers.set(sessionId, buffer);
37
+ };
38
+ return {
39
+ emit: (event) => {
40
+ const sessionId = event.sessionId;
41
+ const seq = sessionId !== undefined ? (cursors.get(sessionId) ?? 0) + 1 : 0;
42
+ if (sessionId !== undefined) {
43
+ cursors.set(sessionId, seq);
44
+ }
45
+ const notification = {
46
+ ...event,
47
+ ts: event.ts ?? clock.now().getTime(),
48
+ seq,
49
+ };
50
+ if (sessionId !== undefined) {
51
+ if (TERMINAL_EVENT_KINDS.has(notification.kind)) {
52
+ // The terminal event itself is still delivered live (fan-out below) and reported through
53
+ // `since`'s `cursor`, but is not retained — nothing can legitimately ask for it again once
54
+ // the session is gone.
55
+ buffers.delete(sessionId);
56
+ cursors.delete(sessionId);
57
+ }
58
+ else if (!EXCLUDED_FROM_RETENTION.has(notification.kind)) {
59
+ appendToBuffer(sessionId, notification);
60
+ }
61
+ }
62
+ for (const listener of listeners) {
63
+ try {
64
+ listener(notification);
65
+ }
66
+ catch {
67
+ // A misbehaving subscriber must never crash the daemon or block other subscribers.
68
+ }
69
+ }
70
+ },
71
+ subscribe: (listener) => {
72
+ listeners.add(listener);
73
+ return () => {
74
+ listeners.delete(listener);
75
+ };
76
+ },
77
+ since: (sessionId, query = {}) => {
78
+ const buffer = buffers.get(sessionId) ?? [];
79
+ const sessionCursor = cursors.get(sessionId) ?? 0;
80
+ // Never hand back the live buffer array itself — `emit()` keeps pushing into it after this
81
+ // call returns, and a caller that gets `events` by reference (the common case: no filter
82
+ // narrows it below) would see it mutate underneath it.
83
+ let events = buffer.slice();
84
+ if (query.since !== undefined) {
85
+ events = events.filter((event) => event.seq > query.since);
86
+ }
87
+ if (query.kinds !== undefined) {
88
+ const kinds = new Set(query.kinds);
89
+ events = events.filter((event) => kinds.has(event.kind));
90
+ }
91
+ if (query.limit !== undefined && events.length > query.limit) {
92
+ // Keep the OLDEST N, not the newest: `limit` exists to bound one response, not to skip
93
+ // ahead — a caller pages forward by re-calling with `since` set to the response's own
94
+ // `cursor`. Keeping the newest N instead would make that same re-call return the same
95
+ // window forever, silently and unrecoverably losing whatever `limit` cut off.
96
+ events = events.slice(0, query.limit);
97
+ }
98
+ // The cursor a caller should resume from: the last event actually returned, so paging
99
+ // through a `limit`-truncated response with `since: cursor` always advances. Only when
100
+ // nothing was returned (empty buffer, or every retained event was filtered out) does it fall
101
+ // back to the session's true high-water mark, so an empty page still lets a caller skip past
102
+ // events it explicitly filtered out (by `kinds`) rather than re-fetching them forever.
103
+ const cursor = events.length > 0 ? events[events.length - 1].seq : sessionCursor;
104
+ return { events, cursor };
105
+ },
106
+ drop: (sessionId) => {
107
+ buffers.delete(sessionId);
108
+ cursors.delete(sessionId);
109
+ },
110
+ };
111
+ };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Pending-link minting (ARCHITECTURE.md §6 PENDING state, §8 bootstrap payload). A link is a
3
+ * short-lived, single-use claim ticket: `sessionId` + `token`, encoded into the deep-link
4
+ * bootstrap payload and backed by a TTL timer that discards it if nobody claims it in time.
5
+ * Pending links are cheap and re-issuable — they are never persisted and never appear in
6
+ * `sessions.list`/`sessions.describe` (only claimed sessions do; see sessions.ts).
7
+ */
8
+ import { type AgentEndpoint } from "@appduct/shared";
9
+ import type { Clock } from "../cli/types.js";
10
+ import type { EventBus } from "./event-bus.js";
11
+ import type { TimerFns } from "./timers.js";
12
+ export type PendingLink = {
13
+ sessionId: string;
14
+ /** Raw 32-byte token; compared with `crypto.timingSafeEqual`, never logged. */
15
+ token: Buffer;
16
+ createdAt: Date;
17
+ expiresAt: Date;
18
+ };
19
+ export type CreatedLink = {
20
+ link: PendingLink;
21
+ deepLinkPayload: string;
22
+ endpoint: AgentEndpoint;
23
+ };
24
+ export type PendingLinkRegistryOptions = {
25
+ /** Resolves the current advertised endpoint at mint time (address may be re-detected). */
26
+ getEndpoint: () => AgentEndpoint;
27
+ clock: Clock;
28
+ timers: TimerFns;
29
+ eventBus: EventBus;
30
+ };
31
+ export type PendingLinkRegistry = {
32
+ /** `addressOverride` forces the endpoint address encoded into this one link's bootstrap payload
33
+ * (the emulator/simulator fast path: `127.0.0.1`), leaving the detected endpoint used by
34
+ * every other link unaffected. */
35
+ create: (ttlSeconds: number, addressOverride?: string) => CreatedLink;
36
+ /** Looks up a pending link without consuming it (used to distinguish "unknown" from "already claimed"). */
37
+ get: (sessionId: string) => PendingLink | undefined;
38
+ /** Removes and returns the link (successful claim consumes its single use). */
39
+ consume: (sessionId: string) => PendingLink | undefined;
40
+ /** Records a failed claim attempt; returns the attempt count so far. */
41
+ recordFailedAttempt: (sessionId: string) => number;
42
+ /** Discards a link outright (TTL expiry, attempt-limit exceeded, or explicit revoke). */
43
+ discard: (sessionId: string) => void;
44
+ /** How many links are still claimable right now: minted, unclaimed, and not past their TTL
45
+ * (`daemon.status`'s `pendingLinks`). Deliberately not `records.size` — a record outlives its
46
+ * TTL by a further grace window purely so a late claim can be told "expired" rather than
47
+ * "unknown", and an expired QR nobody will scan must not hold off a daemon upgrade for another
48
+ * whole TTL (issue #30). */
49
+ claimableCount: () => number;
50
+ /** Clears every outstanding TTL timer (daemon shutdown). */
51
+ disposeAll: () => void;
52
+ };
53
+ export declare const createPendingLinkRegistry: (options: PendingLinkRegistryOptions) => PendingLinkRegistry;
54
+ //# sourceMappingURL=links.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"links.d.ts","sourceRoot":"","sources":["../../src/daemon/links.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,EAAuC,KAAK,aAAa,EAAE,MAAM,iBAAiB,CAAC;AAE1F,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAC7C,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC/C,OAAO,KAAK,EAAE,QAAQ,EAAe,MAAM,aAAa,CAAC;AAKzD,MAAM,MAAM,WAAW,GAAG;IACxB,SAAS,EAAE,MAAM,CAAC;IAClB,+EAA+E;IAC/E,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,IAAI,CAAC;IAChB,SAAS,EAAE,IAAI,CAAC;CACjB,CAAC;AAEF,MAAM,MAAM,WAAW,GAAG;IACxB,IAAI,EAAE,WAAW,CAAC;IAClB,eAAe,EAAE,MAAM,CAAC;IACxB,QAAQ,EAAE,aAAa,CAAC;CACzB,CAAC;AAEF,MAAM,MAAM,0BAA0B,GAAG;IACvC,0FAA0F;IAC1F,WAAW,EAAE,MAAM,aAAa,CAAC;IACjC,KAAK,EAAE,KAAK,CAAC;IACb,MAAM,EAAE,QAAQ,CAAC;IACjB,QAAQ,EAAE,QAAQ,CAAC;CACpB,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC;;sCAEkC;IAClC,MAAM,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,eAAe,CAAC,EAAE,MAAM,KAAK,WAAW,CAAC;IACtE,2GAA2G;IAC3G,GAAG,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,WAAW,GAAG,SAAS,CAAC;IACpD,+EAA+E;IAC/E,OAAO,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,WAAW,GAAG,SAAS,CAAC;IACxD,wEAAwE;IACxE,mBAAmB,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,MAAM,CAAC;IACnD,yFAAyF;IACzF,OAAO,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IACrC;;;;gCAI4B;IAC5B,cAAc,EAAE,MAAM,MAAM,CAAC;IAC7B,4DAA4D;IAC5D,UAAU,EAAE,MAAM,IAAI,CAAC;CACxB,CAAC;AAoBF,eAAO,MAAM,yBAAyB,GAAI,SAAS,0BAA0B,KAAG,mBAkH/E,CAAC"}
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Pending-link minting (ARCHITECTURE.md §6 PENDING state, §8 bootstrap payload). A link is a
3
+ * short-lived, single-use claim ticket: `sessionId` + `token`, encoded into the deep-link
4
+ * bootstrap payload and backed by a TTL timer that discards it if nobody claims it in time.
5
+ * Pending links are cheap and re-issuable — they are never persisted and never appear in
6
+ * `sessions.list`/`sessions.describe` (only claimed sessions do; see sessions.ts).
7
+ */
8
+ import { randomBytes } from "node:crypto";
9
+ import { encodeBootstrap } from "@appduct/shared";
10
+ const SESSION_ID_BYTES = 12;
11
+ const TOKEN_BYTES = 32;
12
+ const generateSessionId = () => {
13
+ // Re-rolled if it starts with "-": v1 ids starting with "-" broke CLI flag parsing
14
+ // (e.g. `appduct tools -abc123...` was parsed as a flag, not a selector).
15
+ let candidate = randomBytes(SESSION_ID_BYTES).toString("base64url");
16
+ while (candidate.startsWith("-")) {
17
+ candidate = randomBytes(SESSION_ID_BYTES).toString("base64url");
18
+ }
19
+ return candidate;
20
+ };
21
+ export const createPendingLinkRegistry = (options) => {
22
+ const records = new Map();
23
+ const discard = (sessionId) => {
24
+ const record = records.get(sessionId);
25
+ if (!record) {
26
+ return;
27
+ }
28
+ options.timers.clearTimeout(record.timer);
29
+ records.delete(sessionId);
30
+ // Covers every discard path uniformly, including the ones with no dedicated event kind
31
+ // (attempt-limit exceeded, the TTL free-timer) — `link_expired`/`session_revoked` also reach
32
+ // here for the paths that do emit one, so this is a harmless no-op in those cases (the
33
+ // corresponding event already dropped the buffer, if `event-bus.ts` treats that kind as
34
+ // terminal) or the only thing that does (issue #6: an unclaimed link must not retain events
35
+ // forever).
36
+ options.eventBus.drop(sessionId);
37
+ };
38
+ return {
39
+ create: (ttlSeconds, addressOverride) => {
40
+ const sessionId = generateSessionId();
41
+ const token = randomBytes(TOKEN_BYTES);
42
+ const createdAt = options.clock.now();
43
+ const expiresAt = new Date(createdAt.getTime() + ttlSeconds * 1000);
44
+ const detectedEndpoint = options.getEndpoint();
45
+ // Overriding only ever changes the address (and its family); the port stays the daemon's
46
+ // real wss port, since the listener already binds all interfaces (ARCHITECTURE.md §8).
47
+ const endpoint = addressOverride
48
+ ? {
49
+ family: (addressOverride.includes(":") ? 6 : 4),
50
+ address: addressOverride,
51
+ port: detectedEndpoint.port,
52
+ }
53
+ : detectedEndpoint;
54
+ // Deliberately does not delete the record the instant the TTL fires: a claim attempt arriving
55
+ // just after must still see the (now-expired) link and be told "expired" rather than
56
+ // "unknown" — the distinction the rejection matrix requires. `handleClaim`'s own `expiresAt`
57
+ // check discards the record (and reports `link_expired`) the moment anything touches it.
58
+ // But the record must not live forever if nobody ever touches it again: a second timer frees
59
+ // it after an equal further grace window (late claims within that window still see
60
+ // `link_expired`; a long-lived daemon with many unclaimed links does not leak memory
61
+ // unboundedly). `disposeAll` clears whichever timer (TTL or free) is still outstanding on
62
+ // daemon shutdown.
63
+ const timer = options.timers.setTimeout(() => {
64
+ // `link_expired` is its own event kind, distinct from `session_expired` (reserved for the
65
+ // SUSPENDED -> EXPIRED grace-window transition of a *claimed* session — ARCHITECTURE.md §6).
66
+ options.eventBus.emit({ kind: "link_expired", sessionId, data: { reason: "link_ttl_expired" } });
67
+ const record = records.get(sessionId);
68
+ if (record) {
69
+ record.timer = options.timers.setTimeout(() => discard(sessionId), ttlSeconds * 1000);
70
+ }
71
+ }, ttlSeconds * 1000);
72
+ const link = { sessionId, token, createdAt, expiresAt };
73
+ records.set(sessionId, { link, attempts: 0, timer });
74
+ const deepLinkPayload = encodeBootstrap({
75
+ ...endpoint,
76
+ sessionId,
77
+ token: token.toString("base64url"),
78
+ expiresAt: Math.floor(expiresAt.getTime() / 1000),
79
+ });
80
+ options.eventBus.emit({ kind: "link_created", sessionId, data: { expiresAt: link.expiresAt.toISOString() } });
81
+ return { link, deepLinkPayload, endpoint };
82
+ },
83
+ get: (sessionId) => records.get(sessionId)?.link,
84
+ consume: (sessionId) => {
85
+ const record = records.get(sessionId);
86
+ if (!record) {
87
+ return undefined;
88
+ }
89
+ options.timers.clearTimeout(record.timer);
90
+ records.delete(sessionId);
91
+ return record.link;
92
+ },
93
+ recordFailedAttempt: (sessionId) => {
94
+ const record = records.get(sessionId);
95
+ if (!record) {
96
+ return 0;
97
+ }
98
+ record.attempts += 1;
99
+ return record.attempts;
100
+ },
101
+ discard,
102
+ claimableCount: () => {
103
+ const now = options.clock.now().getTime();
104
+ let count = 0;
105
+ for (const record of records.values()) {
106
+ if (record.link.expiresAt.getTime() > now) {
107
+ count += 1;
108
+ }
109
+ }
110
+ return count;
111
+ },
112
+ disposeAll: () => {
113
+ for (const sessionId of Array.from(records.keys())) {
114
+ discard(sessionId);
115
+ }
116
+ },
117
+ };
118
+ };
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The pinned-TLS WebSocket listener (ARCHITECTURE.md §7). A thin frame-level gate in front of
3
+ * `SessionManager`: JSON/binary/size hygiene, the pre-claim timeout, and post-claim
4
+ * type/session-id routing. All state-machine decisions (claim/resume/registry/close-code choice)
5
+ * live in sessions.ts — this module never inspects session state itself.
6
+ *
7
+ * Every socket gets an `error` listener the instant it is accepted, and the server itself gets one
8
+ * too: v1's fatal defect was an unhandled `'error'` event crashing the whole daemon. A socket
9
+ * closing — for any reason — must never stop the listener or any other session.
10
+ */
11
+ import { type Server as HttpsServer } from "node:https";
12
+ import { WebSocketServer } from "ws";
13
+ import { type SessionManager } from "./sessions.js";
14
+ import type { TlsManager, TlsMaterial } from "./tls.js";
15
+ import { type TimerFns } from "./timers.js";
16
+ export type ListenerOptions = {
17
+ port: number;
18
+ tls: TlsManager;
19
+ sessionManager: SessionManager;
20
+ preClaimTimeoutMs?: number;
21
+ timers?: TimerFns;
22
+ };
23
+ export type DaemonListener = {
24
+ httpsServer: HttpsServer;
25
+ wss: WebSocketServer;
26
+ port: () => number | undefined;
27
+ /** Applies freshly-minted cert/key material to already-accepted-connections' future TLS
28
+ * handshakes (`tls.Server.setSecureContext`, ARCHITECTURE.md §4/§8: re-mint on advertised-IP
29
+ * change). Existing connections are unaffected — only new handshakes see the new SAN. */
30
+ applyTls: (material: Pick<TlsMaterial, "certPem" | "keyPem">) => void;
31
+ close: () => Promise<void>;
32
+ };
33
+ export declare const startListener: (options: ListenerOptions) => Promise<DaemonListener>;
34
+ //# sourceMappingURL=listener.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"listener.d.ts","sourceRoot":"","sources":["../../src/daemon/listener.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EAAqC,KAAK,MAAM,IAAI,WAAW,EAAE,MAAM,YAAY,CAAC;AAG3F,OAAO,EAAE,eAAe,EAAE,MAAM,IAAI,CAAC;AAErC,OAAO,EAA0B,KAAK,cAAc,EAAE,MAAM,eAAe,CAAC;AAC5E,OAAO,KAAK,EAAE,UAAU,EAAE,WAAW,EAAE,MAAM,UAAU,CAAC;AACxD,OAAO,EAAgB,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AAK1D,MAAM,MAAM,eAAe,GAAG;IAC5B,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,UAAU,CAAC;IAChB,cAAc,EAAE,cAAc,CAAC;IAC/B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,MAAM,CAAC,EAAE,QAAQ,CAAC;CACnB,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,WAAW,EAAE,WAAW,CAAC;IACzB,GAAG,EAAE,eAAe,CAAC;IACrB,IAAI,EAAE,MAAM,MAAM,GAAG,SAAS,CAAC;IAC/B;;6FAEyF;IACzF,QAAQ,EAAE,CAAC,QAAQ,EAAE,IAAI,CAAC,WAAW,EAAE,SAAS,GAAG,QAAQ,CAAC,KAAK,IAAI,CAAC;IACtE,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5B,CAAC;AAMF,eAAO,MAAM,aAAa,GAAU,SAAS,eAAe,KAAG,OAAO,CAAC,cAAc,CA4IpF,CAAC"}
@@ -0,0 +1,132 @@
1
+ /**
2
+ * The pinned-TLS WebSocket listener (ARCHITECTURE.md §7). A thin frame-level gate in front of
3
+ * `SessionManager`: JSON/binary/size hygiene, the pre-claim timeout, and post-claim
4
+ * type/session-id routing. All state-machine decisions (claim/resume/registry/close-code choice)
5
+ * live in sessions.ts — this module never inspects session state itself.
6
+ *
7
+ * Every socket gets an `error` listener the instant it is accepted, and the server itself gets one
8
+ * too: v1's fatal defect was an unhandled `'error'` event crashing the whole daemon. A socket
9
+ * closing — for any reason — must never stop the listener or any other session.
10
+ */
11
+ import { createServer as createHttpsServer } from "node:https";
12
+ import { isSessionClaimMessage, isSessionResumeMessage, isSessionBoundMessage } from "@appduct/shared";
13
+ import { WebSocketServer } from "ws";
14
+ import { isPostClaimMessageType } from "./sessions.js";
15
+ import { systemTimers } from "./timers.js";
16
+ const DEFAULT_PRE_CLAIM_TIMEOUT_MS = 10_000;
17
+ const MAX_PAYLOAD_BYTES = 256 * 1024;
18
+ const isRecord = (value) => {
19
+ return typeof value === "object" && value !== null && !Array.isArray(value);
20
+ };
21
+ export const startListener = async (options) => {
22
+ const timers = options.timers ?? systemTimers;
23
+ const preClaimTimeoutMs = options.preClaimTimeoutMs ?? DEFAULT_PRE_CLAIM_TIMEOUT_MS;
24
+ const material = options.tls.current();
25
+ const httpsServer = createHttpsServer({ key: material.keyPem, cert: material.certPem });
26
+ httpsServer.on("error", () => {
27
+ // A listener-level error (e.g. a transient accept failure) must never crash the daemon.
28
+ // Startup failures are still surfaced via the one-shot listener below.
29
+ });
30
+ const wss = new WebSocketServer({ server: httpsServer, maxPayload: MAX_PAYLOAD_BYTES });
31
+ wss.on("error", () => {
32
+ // Same contract as the https server: never let a listener-level error take the daemon down.
33
+ });
34
+ wss.on("connection", (socket) => {
35
+ socket.on("error", () => {
36
+ // The 'close' event still fires after 'error' and drives session suspend/cleanup.
37
+ });
38
+ let claimedSessionId = null;
39
+ const preClaimTimer = timers.setTimeout(() => {
40
+ if (!claimedSessionId) {
41
+ socket.close(1008, "pre_claim_timeout");
42
+ }
43
+ }, preClaimTimeoutMs);
44
+ socket.once("close", () => {
45
+ timers.clearTimeout(preClaimTimer);
46
+ });
47
+ socket.on("message", (data, isBinary) => {
48
+ if (isBinary) {
49
+ socket.close(1003, "binary_frame_not_supported");
50
+ return;
51
+ }
52
+ let parsed;
53
+ try {
54
+ parsed = JSON.parse(data.toString("utf8"));
55
+ }
56
+ catch {
57
+ socket.close(1008, "invalid_json");
58
+ return;
59
+ }
60
+ if (!isRecord(parsed)) {
61
+ socket.close(1008, "invalid_json");
62
+ return;
63
+ }
64
+ if (claimedSessionId === null) {
65
+ if (isSessionClaimMessage(parsed)) {
66
+ const result = options.sessionManager.handleClaim(socket, parsed);
67
+ if (result) {
68
+ claimedSessionId = result;
69
+ timers.clearTimeout(preClaimTimer);
70
+ }
71
+ return;
72
+ }
73
+ if (isSessionResumeMessage(parsed)) {
74
+ const result = options.sessionManager.handleResume(socket, parsed);
75
+ if (result) {
76
+ claimedSessionId = result;
77
+ timers.clearTimeout(preClaimTimer);
78
+ }
79
+ return;
80
+ }
81
+ socket.close(1008, "expected_claim_or_resume");
82
+ return;
83
+ }
84
+ if (!isPostClaimMessageType(parsed.type)) {
85
+ socket.close(1008, "unknown_message_type");
86
+ return;
87
+ }
88
+ if (!isSessionBoundMessage(parsed) || parsed.session_id !== claimedSessionId) {
89
+ socket.close(1008, "session_mismatch");
90
+ return;
91
+ }
92
+ options.sessionManager.handlePostClaimMessage(claimedSessionId, socket, parsed);
93
+ });
94
+ });
95
+ await new Promise((resolve, reject) => {
96
+ const onError = (error) => {
97
+ httpsServer.off("listening", onListening);
98
+ reject(error);
99
+ };
100
+ const onListening = () => {
101
+ httpsServer.off("error", onError);
102
+ resolve();
103
+ };
104
+ httpsServer.once("error", onError);
105
+ httpsServer.once("listening", onListening);
106
+ httpsServer.listen(options.port);
107
+ });
108
+ return {
109
+ httpsServer,
110
+ wss,
111
+ port: () => {
112
+ const address = httpsServer.address();
113
+ return address && typeof address !== "string" ? address.port : undefined;
114
+ },
115
+ applyTls: (nextMaterial) => {
116
+ // Keep this guard for compatibility with runtimes whose `node:https` shim omits the method.
117
+ const server = httpsServer;
118
+ server.setSecureContext?.({ key: nextMaterial.keyPem, cert: nextMaterial.certPem });
119
+ },
120
+ close: () => new Promise((resolve) => {
121
+ for (const client of wss.clients) {
122
+ client.terminate();
123
+ }
124
+ wss.close(() => {
125
+ // `server.close()` alone only stops accepting new connections — it waits for existing
126
+ // (including idle keep-alive) sockets to end, which could hang shutdown indefinitely.
127
+ httpsServer.closeAllConnections();
128
+ httpsServer.close(() => resolve());
129
+ });
130
+ }),
131
+ };
132
+ };
@@ -0,0 +1,82 @@
1
+ /**
2
+ * `daemon.log` rotation (ARCHITECTURE.md §3/§4, issue #32). The daemon's stdout/stderr is appended
3
+ * to `daemon.log` for the whole life of the process, so nothing inside a running daemon can safely
4
+ * truncate it — the only safe moment is just before a *new* daemon is spawned, while no writer
5
+ * holds the file open. {@link rotateDaemonLogIfNeeded} is therefore called from the spawn path
6
+ * (`rpc/client.ts`'s `defaultSpawn`, which is also what `appduct daemon start` spawns through),
7
+ * never from the daemon itself.
8
+ *
9
+ * "Nothing answered the socket" is not by itself proof that nothing is writing the log: a daemon
10
+ * that is still booting, wedged, or mid-shutdown holds its log fd while its socket is unreachable,
11
+ * and the spawn lock is released as soon as the child is spawned rather than when it is ready. A
12
+ * rename under such a process would send its output to `daemon.log.1`, and the *next* rotation
13
+ * would then unlink the inode it is still writing to — output lost, with nothing on disk to show
14
+ * for it. So rotation asks twice, as late as it can, and declines on either answer:
15
+ *
16
+ * - the pidfile must not name a live process (the same probe the pidfile takeover and the
17
+ * stale-socket unlink use); and
18
+ * - `daemon.sock` must not accept a connection, re-checked immediately before the rename.
19
+ *
20
+ * **This narrows the window; it does not close it.** A daemon spawned microseconds ago holds its
21
+ * log fd before it has written `daemon.pid` or bound its socket, and no check performed before the
22
+ * rename can see it — the file it is writing to is simply not distinguishable, from the outside,
23
+ * from one nobody holds. What is left is a race measured in the milliseconds between a child being
24
+ * spawned and it writing its pidfile, entered only by a *second* spawner that also finds the log
25
+ * over its cap. The consequence is bounded (that daemon's output continues into `daemon.log.1`,
26
+ * and is lost if a later rotation replaces that backup), and a just-spawned daemon's log is far
27
+ * below any sane cap, so in practice the second spawner does not rotate at all. Closing it
28
+ * properly needs an advisory lock held across spawn-and-ready rather than released at spawn.
29
+ *
30
+ * One backup is enough (`daemon.log.1`): the point is a bounded footprint, not a full history. A
31
+ * rotation failure is warned and swallowed — a daemon that cannot rotate its log must still start.
32
+ */
33
+ export declare const daemonLogBackupPath: (logFilePath: string) => string;
34
+ export type RotateDaemonLogOptions = {
35
+ logFilePath: string;
36
+ /** Rotate once the log is strictly larger than this. */
37
+ maxBytes: number;
38
+ /** When given, an over-cap log is left alone if this pidfile names a live process — see the
39
+ * module doc comment. Omit only when the caller has already established that nothing holds the
40
+ * log open. */
41
+ pidFilePath?: string;
42
+ /** When given, an over-cap log is left alone if this control socket accepts a connection,
43
+ * re-checked immediately before the rename. Catches a daemon that is up but was unreachable
44
+ * when the caller decided to spawn. */
45
+ socketPath?: string;
46
+ /** Sink for rotation-failure diagnostics; defaults to `process.stderr`. Injectable for tests. */
47
+ warn?: (message: string) => void;
48
+ };
49
+ export type RotateDaemonLogResult = {
50
+ rotated: boolean;
51
+ /** Size of the log that was moved aside; only set when `rotated` is `true`. */
52
+ rotatedBytes?: number;
53
+ /** Why an over-cap log was deliberately left in place. */
54
+ skipped?: "daemon_running";
55
+ };
56
+ /**
57
+ * Moves `daemon.log` to `daemon.log.1` when it exceeds `maxBytes`, replacing any previous backup.
58
+ * A missing log, a log within the cap, a log a live daemon still holds, or any failure is a no-op
59
+ * — never throws.
60
+ */
61
+ export declare const rotateDaemonLogIfNeeded: (options: RotateDaemonLogOptions) => Promise<RotateDaemonLogResult>;
62
+ /**
63
+ * Best-effort enforcement of `daemon.log`'s ARCHITECTURE.md §3 mode. `open(path, "a", 0o600)`
64
+ * already creates it at `0600` (the umask can only remove bits), so this exists for the file that
65
+ * was *not* created by us — an operator's `touch`, a `cp` from elsewhere — and is a no-op
66
+ * otherwise.
67
+ *
68
+ * Never throws. It sits directly on the auto-spawn path, where every CLI and MCP command that
69
+ * needs a daemon passes through: a filesystem that answers `chmod` with EPERM or ENOTSUP (a
70
+ * drvfs/NFS/SMB-backed state dir, a root-owned log under a non-root user) would otherwise brick
71
+ * every one of those commands over a permission bit. A daemon that cannot tighten its log mode
72
+ * must still start — loudly, but it must start.
73
+ */
74
+ export declare const ensureDaemonLogMode: (logFilePath: string, warn?: (message: string) => void) => Promise<boolean>;
75
+ /**
76
+ * `config.daemonLogMaxBytes` for a state dir, falling back to the default when `config.json` is
77
+ * missing or unreadable. Deliberately forgiving: this runs on the spawn path, where a bad config
78
+ * is the daemon's problem to report properly once it starts — not a reason to fail rotation (and
79
+ * with it the spawn) with a duplicate error.
80
+ */
81
+ export declare const resolveDaemonLogMaxBytes: (stateDir: string) => Promise<number>;
82
+ //# sourceMappingURL=log-rotation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"log-rotation.d.ts","sourceRoot":"","sources":["../../src/daemon/log-rotation.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAYH,eAAO,MAAM,mBAAmB,GAAI,aAAa,MAAM,KAAG,MAA4B,CAAC;AAEvF,MAAM,MAAM,sBAAsB,GAAG;IACnC,WAAW,EAAE,MAAM,CAAC;IACpB,wDAAwD;IACxD,QAAQ,EAAE,MAAM,CAAC;IACjB;;mBAEe;IACf,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;;2CAEuC;IACvC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,iGAAiG;IACjG,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CAClC,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,OAAO,EAAE,OAAO,CAAC;IACjB,+EAA+E;IAC/E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,0DAA0D;IAC1D,OAAO,CAAC,EAAE,gBAAgB,CAAC;CAC5B,CAAC;AAcF;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,GAClC,SAAS,sBAAsB,KAC9B,OAAO,CAAC,qBAAqB,CAsD/B,CAAC;AAEF;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,mBAAmB,GAC9B,aAAa,MAAM,EACnB,OAAM,CAAC,OAAO,EAAE,MAAM,KAAK,IAAwD,KAClF,OAAO,CAAC,OAAO,CAQjB,CAAC;AAEF;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,GAAU,UAAU,MAAM,KAAG,OAAO,CAAC,MAAM,CAM/E,CAAC"}