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,560 @@
1
+ /**
2
+ * Daemon composition root (ARCHITECTURE.md §4): state dir → config → pidfile → TLS → session
3
+ * engine → listener → RPC server. Implements `daemon.status`/`daemon.shutdown`, the session RPC
4
+ * surface (`link.create`, `sessions.list`, `sessions.describe`, `sessions.revoke`), and the
5
+ * invocation surface (`tools.list`, `tools.call`, `events.subscribe`), and policy + audit
6
+ * (ARCHITECTURE.md §12): every `tools.call` attempt is evaluated against `config.policy` and
7
+ * recorded to the audit log inline in the `tools.call` handler below.
8
+ */
9
+ import { rm } from "node:fs/promises";
10
+ import { RPC_METHODS, EVENT_KINDS, } from "@appduct/shared";
11
+ import { getDaemonReportedVersion } from "../package-version.js";
12
+ import { detectAdvertisedAddress } from "./address.js";
13
+ import { argsSha256, createAuditLogger } from "./audit.js";
14
+ import { createCallsManager } from "./calls.js";
15
+ import { loadConfig } from "./config.js";
16
+ import { createEventBus } from "./event-bus.js";
17
+ import { startListener } from "./listener.js";
18
+ import { evaluate as evaluatePolicy } from "./policy.js";
19
+ import { acquirePidfile } from "./pidfile.js";
20
+ import { RpcApplicationError, startRpcServer } from "./rpc-server.js";
21
+ import { createSessionManager } from "./sessions.js";
22
+ import { ensureStateDir, getSocketPath, getStateDirPaths } from "./state-dir.js";
23
+ import { systemTimers } from "./timers.js";
24
+ import { createTlsManager, toAgentEndpoint } from "./tls.js";
25
+ /** Used to validate `events.subscribe`/`events.since`'s `kinds` filter. */
26
+ const KNOWN_EVENT_KINDS = new Set(EVENT_KINDS);
27
+ /**
28
+ * What this daemon reports as its version over `daemon.status` and in `daemon_started`. Read per
29
+ * call rather than at module load so `APPDUCT_DAEMON_VERSION_OVERRIDE` (issue #30's test seam)
30
+ * takes effect for an in-process daemon started by a test that set it after import.
31
+ */
32
+ const packageVersion = () => getDaemonReportedVersion();
33
+ /** How often audit retention runs after the startup prune (ARCHITECTURE.md §3: "once a day"). */
34
+ export const AUDIT_PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
35
+ const buildStatusResult = async (config, startedAt, tls, sessionManager, auditLogger, auditDir) => {
36
+ // The audit directory footprint is measured on demand rather than tracked incrementally: this
37
+ // handler runs at most once per `appduct daemon status`, and a stat per day file is cheaper
38
+ // and far harder to get wrong than a running total that must survive pruning and restarts.
39
+ const stats = await auditLogger.stats();
40
+ return {
41
+ version: packageVersion(),
42
+ pid: process.pid,
43
+ startedAt: startedAt.toISOString(),
44
+ wssPort: config.wssPort,
45
+ pinnedKeys: tls.pinnedKeys(),
46
+ sessions: sessionManager.list(),
47
+ pendingLinks: sessionManager.pendingLinkCount(),
48
+ policy: config.policy,
49
+ audit: {
50
+ path: auditDir,
51
+ failedWrites: auditLogger.failedWrites(),
52
+ failedPrunes: auditLogger.failedPrunes(),
53
+ retentionDays: config.auditRetentionDays,
54
+ files: stats.files,
55
+ bytes: stats.bytes,
56
+ },
57
+ };
58
+ };
59
+ const asSelectorParams = (params) => {
60
+ if (params === undefined || params === null) {
61
+ return {};
62
+ }
63
+ if (typeof params !== "object" || Array.isArray(params)) {
64
+ throw new RpcApplicationError("invalid_request", "Params must be an object.");
65
+ }
66
+ const selector = params.selector;
67
+ if (selector !== undefined && typeof selector !== "string") {
68
+ throw new RpcApplicationError("invalid_request", '"selector" must be a string.');
69
+ }
70
+ return { selector };
71
+ };
72
+ const asRecordParams = (params) => {
73
+ if (params === undefined || params === null) {
74
+ return {};
75
+ }
76
+ if (typeof params !== "object" || Array.isArray(params)) {
77
+ throw new RpcApplicationError("invalid_request", "Params must be an object.");
78
+ }
79
+ return params;
80
+ };
81
+ const asToolsCallParams = (params) => {
82
+ const record = asRecordParams(params);
83
+ const selector = record.selector;
84
+ if (selector !== undefined && typeof selector !== "string") {
85
+ throw new RpcApplicationError("invalid_request", '"selector" must be a string.');
86
+ }
87
+ if (typeof record.name !== "string" || record.name.length === 0) {
88
+ throw new RpcApplicationError("invalid_request", '"name" must be a non-empty string.');
89
+ }
90
+ const args = record.args;
91
+ if (typeof args !== "object" || args === null || Array.isArray(args)) {
92
+ throw new RpcApplicationError("invalid_request", '"args" must be a JSON object.');
93
+ }
94
+ const timeoutMs = record.timeoutMs;
95
+ if (timeoutMs !== undefined && (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs))) {
96
+ throw new RpcApplicationError("invalid_request", '"timeoutMs" must be a number.');
97
+ }
98
+ const caller = record.caller;
99
+ if (caller !== undefined && caller !== "cli" && caller !== "mcp" && caller !== "client") {
100
+ throw new RpcApplicationError("invalid_request", '"caller" must be "cli", "mcp", or "client".');
101
+ }
102
+ const consent = record.consent;
103
+ if (consent !== undefined && consent !== "client" && consent !== "elicitation") {
104
+ throw new RpcApplicationError("invalid_request", '"consent" must be "client" or "elicitation".');
105
+ }
106
+ return {
107
+ selector: selector,
108
+ name: record.name,
109
+ args: args,
110
+ timeoutMs: timeoutMs,
111
+ caller: caller,
112
+ consent: consent,
113
+ };
114
+ };
115
+ const asToolsCancelParams = (params) => {
116
+ const record = asRecordParams(params);
117
+ const selector = record.selector;
118
+ if (selector !== undefined && typeof selector !== "string") {
119
+ throw new RpcApplicationError("invalid_request", '"selector" must be a string.');
120
+ }
121
+ if (typeof record.callId !== "string" || record.callId.length === 0) {
122
+ throw new RpcApplicationError("invalid_request", '"callId" must be a non-empty string.');
123
+ }
124
+ const reason = record.reason;
125
+ if (reason !== undefined && typeof reason !== "string") {
126
+ throw new RpcApplicationError("invalid_request", '"reason" must be a string.');
127
+ }
128
+ return {
129
+ selector: selector,
130
+ callId: record.callId,
131
+ reason: reason,
132
+ };
133
+ };
134
+ const asEventsSubscribeParams = (params) => {
135
+ const record = asRecordParams(params);
136
+ const sessionSelector = record.sessionSelector;
137
+ if (sessionSelector !== undefined && typeof sessionSelector !== "string") {
138
+ throw new RpcApplicationError("invalid_request", '"sessionSelector" must be a string.');
139
+ }
140
+ const kindsRaw = record.kinds;
141
+ let kinds;
142
+ if (kindsRaw !== undefined) {
143
+ if (!Array.isArray(kindsRaw) || !kindsRaw.every((kind) => typeof kind === "string" && KNOWN_EVENT_KINDS.has(kind))) {
144
+ throw new RpcApplicationError("invalid_request", '"kinds" must be an array of known event kinds.');
145
+ }
146
+ kinds = kindsRaw;
147
+ }
148
+ return { sessionSelector: sessionSelector, kinds };
149
+ };
150
+ const asEventsSinceParams = (params) => {
151
+ const record = asRecordParams(params);
152
+ const selector = record.selector;
153
+ if (selector !== undefined && typeof selector !== "string") {
154
+ throw new RpcApplicationError("invalid_request", '"selector" must be a string.');
155
+ }
156
+ const since = record.since;
157
+ if (since !== undefined && (typeof since !== "number" || !Number.isInteger(since) || since < 0)) {
158
+ throw new RpcApplicationError("invalid_request", '"since" must be a non-negative integer.');
159
+ }
160
+ const kindsRaw = record.kinds;
161
+ let kinds;
162
+ if (kindsRaw !== undefined) {
163
+ if (!Array.isArray(kindsRaw) || !kindsRaw.every((kind) => typeof kind === "string" && KNOWN_EVENT_KINDS.has(kind))) {
164
+ throw new RpcApplicationError("invalid_request", '"kinds" must be an array of known event kinds.');
165
+ }
166
+ kinds = kindsRaw;
167
+ }
168
+ const limit = record.limit;
169
+ if (limit !== undefined && (typeof limit !== "number" || !Number.isInteger(limit) || limit <= 0)) {
170
+ throw new RpcApplicationError("invalid_request", '"limit" must be a positive integer.');
171
+ }
172
+ return {
173
+ selector: selector,
174
+ since: since,
175
+ kinds,
176
+ limit: limit,
177
+ };
178
+ };
179
+ const asLinkCreateParams = (params) => {
180
+ if (params === undefined || params === null) {
181
+ return {};
182
+ }
183
+ if (typeof params !== "object" || Array.isArray(params)) {
184
+ throw new RpcApplicationError("invalid_request", "Params must be an object.");
185
+ }
186
+ const record = params;
187
+ const ttlSeconds = record.ttlSeconds;
188
+ if (ttlSeconds !== undefined && (typeof ttlSeconds !== "number" || !Number.isInteger(ttlSeconds) || ttlSeconds <= 0)) {
189
+ throw new RpcApplicationError("invalid_request", '"ttlSeconds" must be a positive integer.');
190
+ }
191
+ const addressOverride = record.addressOverride;
192
+ if (addressOverride !== undefined && (typeof addressOverride !== "string" || addressOverride.length === 0)) {
193
+ throw new RpcApplicationError("invalid_request", '"addressOverride" must be a non-empty string.');
194
+ }
195
+ return { ttlSeconds, addressOverride: addressOverride };
196
+ };
197
+ export const startDaemon = async (options) => {
198
+ const clock = options.clock ?? { now: () => new Date() };
199
+ const timers = options.timers ?? systemTimers;
200
+ const paths = getStateDirPaths(options.stateDir);
201
+ await ensureStateDir(options.stateDir);
202
+ const config = await loadConfig(paths, { warn: options.warn });
203
+ const startedAt = clock.now();
204
+ // Created eagerly (before anything else can fail) so shutdown can always flush it, and so a
205
+ // startup failure before the RPC server exists still gets a chance to record what happened.
206
+ const auditLogger = createAuditLogger({
207
+ auditDir: paths.auditDir,
208
+ clock,
209
+ retentionDays: config.auditRetentionDays,
210
+ warn: options.warn,
211
+ });
212
+ let auditPruneInterval;
213
+ let pidfile;
214
+ let server;
215
+ let listener;
216
+ let sessionManager;
217
+ let callsManager;
218
+ let eventBus;
219
+ let shuttingDown = false;
220
+ let resolveExited;
221
+ const exited = new Promise((resolve) => {
222
+ resolveExited = resolve;
223
+ });
224
+ const shutdown = async () => {
225
+ if (shuttingDown) {
226
+ return exited;
227
+ }
228
+ shuttingDown = true;
229
+ try {
230
+ // Cleared first so a long teardown can't have the daily sweep fire into a half-torn-down
231
+ // daemon, and so nothing keeps the event loop (or a test runner) alive after `exited`.
232
+ if (auditPruneInterval !== undefined) {
233
+ timers.clearInterval(auditPruneInterval);
234
+ auditPruneInterval = undefined;
235
+ }
236
+ // Reject any calls still in flight before the sockets that would have answered them go away.
237
+ callsManager?.disposeAll();
238
+ // ARCHITECTURE.md §4: close all device sockets (1001) before tearing down the control plane.
239
+ sessionManager?.disposeAll(1001, "daemon_shutdown");
240
+ await listener?.close();
241
+ await server?.close();
242
+ await pidfile?.release();
243
+ await rm(getSocketPath(paths), { force: true });
244
+ // Flush the audit queue last: nothing else here writes audit records, but this makes sure a
245
+ // record enqueued by the very last `tools.call` before shutdown actually lands on disk.
246
+ await auditLogger.flush();
247
+ }
248
+ finally {
249
+ resolveExited();
250
+ }
251
+ };
252
+ try {
253
+ pidfile = await acquirePidfile(paths.pidFilePath, {
254
+ onStaleTakeover: async () => {
255
+ await rm(getSocketPath(paths), { force: true });
256
+ },
257
+ });
258
+ eventBus = createEventBus({ clock, bufferSize: config.eventBufferSize });
259
+ const activeEventBus = eventBus;
260
+ const detectAddress = options.detectAddress ??
261
+ (() => detectAdvertisedAddress({ override: config.advertisedIp }));
262
+ const tls = await createTlsManager({ keyPath: config.keyPath, detectAddress, warn: options.warn });
263
+ sessionManager = createSessionManager({
264
+ graceSeconds: config.graceSeconds,
265
+ keepaliveIntervalSeconds: config.keepaliveIntervalSeconds,
266
+ linkTtlSeconds: config.linkTtlSeconds,
267
+ getEndpoint: () => toAgentEndpoint(tls.current().advertisedAddress, config.wssPort),
268
+ eventBus: activeEventBus,
269
+ clock,
270
+ onToolFrame: (message) => {
271
+ switch (message.type) {
272
+ case "tool_result":
273
+ callsManager?.handleToolResult(message);
274
+ return;
275
+ case "tool_error":
276
+ callsManager?.handleToolError(message);
277
+ return;
278
+ case "tool_call_progress":
279
+ callsManager?.handleToolCallProgress(message);
280
+ }
281
+ },
282
+ onAppEvent: (sessionId, alias, message) => {
283
+ activeEventBus.emit({
284
+ kind: "app_event",
285
+ sessionId,
286
+ alias,
287
+ data: { name: message.name, payload: message.payload, ts: message.ts },
288
+ });
289
+ },
290
+ });
291
+ const activeSessionManager = sessionManager;
292
+ callsManager = createCallsManager({
293
+ send: (sessionId, message) => activeSessionManager.sendToolCall(sessionId, message),
294
+ sendCancel: (sessionId, message) => activeSessionManager.sendToolCancel(sessionId, message),
295
+ eventBus: activeEventBus,
296
+ });
297
+ const activeCallsManager = callsManager;
298
+ listener = await startListener({
299
+ port: config.wssPort,
300
+ tls,
301
+ sessionManager,
302
+ });
303
+ const activeListener = listener;
304
+ // `events.subscribe` fan-out: one global listener pushes matching notifications to every RPC
305
+ // connection currently marked as a subscriber (state stashed by the `events.subscribe` handler
306
+ // below). `RpcServer.notify` itself guards against a slow/dead subscriber backing up the daemon.
307
+ activeEventBus.subscribe((event) => {
308
+ for (const connection of server?.connections() ?? []) {
309
+ const subscription = connection.state.eventSubscription;
310
+ if (!subscription) {
311
+ continue;
312
+ }
313
+ if (subscription.kinds && !subscription.kinds.has(event.kind)) {
314
+ continue;
315
+ }
316
+ if (subscription.sessionSelector !== undefined &&
317
+ event.sessionId !== subscription.sessionSelector &&
318
+ event.alias !== subscription.sessionSelector) {
319
+ continue;
320
+ }
321
+ server?.notify(connection, event);
322
+ }
323
+ });
324
+ server = await startRpcServer({
325
+ socketPath: getSocketPath(paths),
326
+ dispatch: {
327
+ [RPC_METHODS.daemonStatus]: async () => {
328
+ return buildStatusResult(config, startedAt, tls, activeSessionManager, auditLogger, paths.auditDir);
329
+ },
330
+ [RPC_METHODS.daemonShutdown]: (_params, context) => {
331
+ context.afterSend(() => {
332
+ void shutdown();
333
+ });
334
+ return { ok: true };
335
+ },
336
+ [RPC_METHODS.linkCreate]: async (params) => {
337
+ const { ttlSeconds, addressOverride } = asLinkCreateParams(params);
338
+ // Re-detect the advertised address on every mint (ARCHITECTURE.md §4/§8): a long-lived
339
+ // daemon that changed networks must not keep minting links with a stale address/SAN.
340
+ // `refresh` only re-mints the certificate when the address actually changed; applying the
341
+ // (possibly unchanged) secure context is a cheap no-op the rest of the time, but comparing
342
+ // material identity avoids even that when nothing changed.
343
+ const previousMaterial = tls.current();
344
+ const nextMaterial = await tls.refresh();
345
+ if (nextMaterial !== previousMaterial) {
346
+ activeListener.applyTls(nextMaterial);
347
+ }
348
+ // `tls.refresh()` just ran above, so `pinnedKeys()[0]` reflects the material this link's
349
+ // endpoint will actually serve (opt-in hardening dev-mode: the CLI composes this into the
350
+ // deep link's `pin` query param — see `LinkCreateResult.pin`).
351
+ return { ...activeSessionManager.createLink(ttlSeconds, addressOverride), pin: tls.pinnedKeys()[0] };
352
+ },
353
+ [RPC_METHODS.sessionsList]: () => {
354
+ return activeSessionManager.list();
355
+ },
356
+ [RPC_METHODS.sessionsDescribe]: (params) => {
357
+ const { selector } = asSelectorParams(params);
358
+ return activeSessionManager.describe(selector);
359
+ },
360
+ [RPC_METHODS.sessionsRevoke]: (params) => {
361
+ const { selector } = asSelectorParams(params);
362
+ activeSessionManager.revoke(selector);
363
+ return { ok: true };
364
+ },
365
+ [RPC_METHODS.toolsList]: (params) => {
366
+ const { selector } = asSelectorParams(params);
367
+ // ARCHITECTURE.md §5: tools.list works for ACTIVE and SUSPENDED sessions alike (the
368
+ // retained registry survives suspend); only tools.call requires ACTIVE.
369
+ const resolved = activeSessionManager.resolveForTools(selector);
370
+ // Each entry carries its effective policy decision (ARCHITECTURE.md §12) so the MCP
371
+ // server can emit `_meta["anthropic/requiresUserInteraction"]` for "prompt" tools
372
+ // without a second round trip (issue #14).
373
+ return resolved.registry.list().map((descriptor) => ({
374
+ ...descriptor,
375
+ policy: evaluatePolicy(descriptor, { alias: resolved.alias }, config.policy),
376
+ }));
377
+ },
378
+ // This handler is the single seam every `tools.call` passes through: policy (ARCHITECTURE.md
379
+ // §12) is evaluated once the target tool descriptor is known, and one audit record is
380
+ // written for every attempt that reaches a resolved session, across ok/error/denied.
381
+ [RPC_METHODS.toolsCall]: async (params, context) => {
382
+ const { selector, name, args, timeoutMs, caller, consent } = asToolsCallParams(params);
383
+ const effectiveCaller = caller ?? "cli";
384
+ const resolved = activeSessionManager.resolveForTools(selector);
385
+ const auditStartedAt = clock.now().getTime();
386
+ const writeAudit = (outcome, errorType, grantedConsent, deniedReason) => {
387
+ auditLogger.record({
388
+ sessionId: resolved.sessionId,
389
+ alias: resolved.alias,
390
+ tool: name,
391
+ argsSha256: argsSha256(args),
392
+ outcome,
393
+ errorType,
394
+ deniedReason,
395
+ durationMs: clock.now().getTime() - auditStartedAt,
396
+ caller: effectiveCaller,
397
+ consent: grantedConsent,
398
+ });
399
+ };
400
+ if (resolved.state !== "active") {
401
+ const errorType = resolved.state === "suspended" ? "session_suspended" : "session_not_active";
402
+ writeAudit("error", errorType);
403
+ throw new RpcApplicationError(errorType, `Session "${resolved.alias}" is not active.`);
404
+ }
405
+ const tool = resolved.registry.get(name);
406
+ if (!tool) {
407
+ // Deliberately no frame is sent to the app for an unknown tool.
408
+ writeAudit("error", "tool_not_found");
409
+ throw new RpcApplicationError("tool_not_found", `Tool "${name}" is not registered.`);
410
+ }
411
+ const policyDecision = evaluatePolicy(tool, { alias: resolved.alias }, config.policy);
412
+ // "prompt" fails closed (ARCHITECTURE.md §12): it proceeds only when the caller carried
413
+ // `consent: "client"` (issue #14) or `consent: "elicitation"` (issue #10). Either value is
414
+ // trusted verbatim once present — the daemon does not and cannot re-derive whether an MCP
415
+ // client's `_meta` flag was actually honored or its elicitation reply actually came from a
416
+ // human, the same way it doesn't re-verify any other RPC param. `consent` is only ever
417
+ // justified when set by this codebase's own MCP server (mcp/server.ts), which sets
418
+ // `"client"` solely after confirming both that it emitted
419
+ // `_meta["anthropic/requiresUserInteraction"]` for this exact tool on this connection's
420
+ // most recent listing and that the connected client is known to enforce it, and sets
421
+ // `"elicitation"` solely after sending an `elicitation/create` request over this
422
+ // connection and receiving `action: "accept"` back. Any other local process with access to
423
+ // `daemon.sock` — including the CLI, or an agent with shell access, which is the typical
424
+ // setup this feature targets — could send either value directly; that is not a bypass of
425
+ // this feature so much as a restatement of this codebase's existing trust boundary
426
+ // (docs/SECURITY.md: anything that can reach the socket already has full daemon control).
427
+ // "prompt" guards against a compliant MCP client silently auto-approving on the caller's
428
+ // behalf, not against a hostile process on the operator's own machine.
429
+ const grantedConsent = policyDecision === "prompt" && (consent === "client" || consent === "elicitation") ? consent : undefined;
430
+ if (policyDecision === "deny" || (policyDecision === "prompt" && grantedConsent === undefined)) {
431
+ // Denied → no frame reaches the app, and the call never starts.
432
+ activeEventBus.emit({
433
+ kind: "tool_call_finished",
434
+ sessionId: resolved.sessionId,
435
+ alias: resolved.alias,
436
+ data: { name, outcome: "denied" },
437
+ });
438
+ if (policyDecision === "prompt") {
439
+ writeAudit("denied", undefined, undefined, "no_consent_channel");
440
+ throw new RpcApplicationError("policy_denied", `Policy requires prompt consent to call "${name}" on session "${resolved.alias}", but no consent channel confirmed it.`, -32000, {
441
+ reason: "no_consent_channel",
442
+ hint: `This caller did not confirm human consent for "${name}". An MCP client that declares the "elicitation" capability confirms it via an elicitation/create prompt, and Claude Code ≥ v2.1.199 confirms it via the requiresUserInteraction flag, automatically over MCP; every other caller (including the CLI) is denied by design. To change this tool's policy, edit "policy.tools[\"${resolved.alias}/${name}\"]" (or policy.default/policy.destructive) in ${paths.configPath}.`,
443
+ });
444
+ }
445
+ writeAudit("denied", undefined, undefined, "policy");
446
+ throw new RpcApplicationError("policy_denied", `Policy denies calling "${name}" on session "${resolved.alias}".`, -32000, { hint: `Edit "policy" in ${paths.configPath} (policy.default/policy.destructive/policy.tools) to change this.` });
447
+ }
448
+ const startedAt = clock.now().getTime();
449
+ const session = { sessionId: resolved.sessionId, alias: resolved.alias };
450
+ // `callId` is available synchronously (before the app has answered) precisely so it can
451
+ // be exposed to the RPC caller and stamped on every event in this call's lifecycle — the
452
+ // MCP server correlates its own in-flight `tools.call` against `tool_call_progress`
453
+ // notifications by this id, unambiguous under concurrent calls.
454
+ const { callId, result: callResult } = activeCallsManager.call(session, name, args,
455
+ // The caller's explicit `timeoutMs` wins; otherwise the tool's own declared deadline is
456
+ // the default, and only if it declares none does `clampTimeout` fall back to
457
+ // DEFAULT_CALL_TIMEOUT_MS (issue #25).
458
+ timeoutMs ?? tool.timeout_ms);
459
+ // If the connection that issued this tools.call drops while the call is still pending
460
+ // (CLI Ctrl-C, MCP client disconnect), tell the app to stop rather than leaving an
461
+ // orphaned handler running for a caller nobody is waiting on anymore. Unregistered once
462
+ // this call settles either way — otherwise a long-lived connection issuing many calls
463
+ // (e.g. the MCP server's persistent stream) leaks one closure per call for its lifetime.
464
+ const unregisterOnClose = context.onClose(() => {
465
+ activeCallsManager.cancel(resolved.sessionId, callId, "connection_closed");
466
+ });
467
+ activeEventBus.emit({
468
+ kind: "tool_call_started",
469
+ sessionId: resolved.sessionId,
470
+ alias: resolved.alias,
471
+ data: { name, callId },
472
+ });
473
+ try {
474
+ const result = await callResult;
475
+ activeEventBus.emit({
476
+ kind: "tool_call_finished",
477
+ sessionId: resolved.sessionId,
478
+ alias: resolved.alias,
479
+ data: { name, callId, durationMs: clock.now().getTime() - startedAt, outcome: "ok" },
480
+ });
481
+ writeAudit("ok", undefined, grantedConsent);
482
+ return { result, callId };
483
+ }
484
+ catch (error) {
485
+ const errorType = error instanceof RpcApplicationError ? error.type : "tool_execution_error";
486
+ const outcome = errorType === "tool_cancelled" ? "cancelled" : "error";
487
+ activeEventBus.emit({
488
+ kind: "tool_call_finished",
489
+ sessionId: resolved.sessionId,
490
+ alias: resolved.alias,
491
+ data: { name, callId, durationMs: clock.now().getTime() - startedAt, outcome, errorType },
492
+ });
493
+ writeAudit(outcome, errorType, grantedConsent);
494
+ throw error;
495
+ }
496
+ finally {
497
+ unregisterOnClose();
498
+ }
499
+ },
500
+ [RPC_METHODS.toolsCancel]: (params) => {
501
+ const { selector, callId, reason } = asToolsCancelParams(params);
502
+ const resolved = activeSessionManager.resolveForTools(selector);
503
+ const cancelled = activeCallsManager.cancel(resolved.sessionId, callId, reason ?? "client_cancelled");
504
+ return { cancelled };
505
+ },
506
+ [RPC_METHODS.eventsSubscribe]: (params, context) => {
507
+ const { sessionSelector, kinds } = asEventsSubscribeParams(params);
508
+ context.connection.state.eventSubscription = {
509
+ sessionSelector,
510
+ kinds: kinds ? new Set(kinds) : undefined,
511
+ };
512
+ return { ok: true };
513
+ },
514
+ [RPC_METHODS.eventsSince]: (params) => {
515
+ const { selector, since, kinds, limit } = asEventsSinceParams(params);
516
+ // Resolved the same way as every other selector-taking method (`sessions.describe`,
517
+ // `tools.list`): defaults to the sole active/suspended session, errors on ambiguity, and
518
+ // works for a suspended session too — a suspended app's already-retained events are still
519
+ // fair game to drain.
520
+ const resolved = activeSessionManager.describe(selector);
521
+ return activeEventBus.since(resolved.sessionId, { since, kinds, limit });
522
+ },
523
+ },
524
+ });
525
+ // ARCHITECTURE.md §3: audit retention runs once at startup and once a day thereafter. Both are
526
+ // fire-and-forget — `prune` never rejects, and a daemon must start (and keep running) whether
527
+ // or not it can delete an old day file. The interval is cleared in `shutdown` (and in the
528
+ // startup-failure path below) so it never outlives the daemon that scheduled it.
529
+ auditPruneInterval = timers.setInterval(() => {
530
+ void auditLogger.prune();
531
+ }, AUDIT_PRUNE_INTERVAL_MS);
532
+ void auditLogger.prune();
533
+ }
534
+ catch (error) {
535
+ // Never leave a half-acquired pidfile/listener/socket behind when startup fails partway through.
536
+ if (auditPruneInterval !== undefined) {
537
+ timers.clearInterval(auditPruneInterval);
538
+ auditPruneInterval = undefined;
539
+ }
540
+ callsManager?.disposeAll();
541
+ sessionManager?.disposeAll(1001, "daemon_startup_failed");
542
+ await listener?.close();
543
+ await pidfile?.release();
544
+ throw error;
545
+ }
546
+ // ARCHITECTURE.md §5 lists `daemon_started` among the real event kinds; emitted once startup has
547
+ // fully succeeded (pidfile/TLS/listener/RPC server all up) so a subscriber never sees it followed
548
+ // by a startup failure.
549
+ eventBus.emit({ kind: "daemon_started", data: { version: packageVersion(), pid: process.pid } });
550
+ return {
551
+ paths,
552
+ config,
553
+ startedAt,
554
+ server,
555
+ listener,
556
+ eventBus,
557
+ exited,
558
+ shutdown,
559
+ };
560
+ };
@@ -0,0 +1,49 @@
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
+ import type { EventKind, EventNotification } from "@appduct/shared";
17
+ import type { Clock } from "../cli/types.js";
18
+ export type EventBusEmitInput = Omit<EventNotification, "ts" | "seq"> & {
19
+ kind: EventKind;
20
+ ts?: number;
21
+ };
22
+ export type EventBusListener = (event: EventNotification) => void;
23
+ export type EventsSinceQuery = {
24
+ since?: number;
25
+ kinds?: EventKind[];
26
+ limit?: number;
27
+ };
28
+ export type EventsSinceQueryResult = {
29
+ events: EventNotification[];
30
+ cursor: number;
31
+ };
32
+ export type EventBus = {
33
+ emit: (event: EventBusEmitInput) => void;
34
+ subscribe: (listener: EventBusListener) => () => void;
35
+ /** Drains the retained buffer for `sessionId`. Returns an empty result (`cursor: 0`) for a
36
+ * session with no retained events (nothing emitted yet, or its buffer already discarded). */
37
+ since: (sessionId: string, query?: EventsSinceQuery) => EventsSinceQueryResult;
38
+ /** Discards `sessionId`'s retained buffer outright, with no event required. Covers the pending-
39
+ * link discard paths that never reach a terminal event kind (attempt-limit exceeded, the TTL
40
+ * free-timer) — `TERMINAL_EVENT_KINDS` only catches the ones that *do* emit one. Idempotent. */
41
+ drop: (sessionId: string) => void;
42
+ };
43
+ export type CreateEventBusOptions = {
44
+ clock?: Clock;
45
+ /** Max retained events per session (ARCHITECTURE.md §5's `eventBufferSize`); default 256. */
46
+ bufferSize?: number;
47
+ };
48
+ export declare const createEventBus: (options?: CreateEventBusOptions) => EventBus;
49
+ //# sourceMappingURL=event-bus.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"event-bus.d.ts","sourceRoot":"","sources":["../../src/daemon/event-bus.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;GAcG;AAEH,OAAO,KAAK,EAAE,SAAS,EAAE,iBAAiB,EAAE,MAAM,iBAAiB,CAAC;AAEpE,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAE7C,MAAM,MAAM,iBAAiB,GAAG,IAAI,CAAC,iBAAiB,EAAE,IAAI,GAAG,KAAK,CAAC,GAAG;IAAE,IAAI,EAAE,SAAS,CAAC;IAAC,EAAE,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAEzG,MAAM,MAAM,gBAAgB,GAAG,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;AAYlE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,KAAK,CAAC,EAAE,SAAS,EAAE,CAAC;IACpB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,sBAAsB,GAAG;IACnC,MAAM,EAAE,iBAAiB,EAAE,CAAC;IAC5B,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF,MAAM,MAAM,QAAQ,GAAG;IACrB,IAAI,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IACzC,SAAS,EAAE,CAAC,QAAQ,EAAE,gBAAgB,KAAK,MAAM,IAAI,CAAC;IACtD;iGAC6F;IAC7F,KAAK,EAAE,CAAC,SAAS,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,gBAAgB,KAAK,sBAAsB,CAAC;IAC/E;;oGAEgG;IAChG,IAAI,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,KAAK,CAAC,EAAE,KAAK,CAAC;IACd,6FAA6F;IAC7F,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AAEF,eAAO,MAAM,cAAc,GAAI,UAAS,qBAA0B,KAAG,QAqGpE,CAAC"}