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,136 @@
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
+ import { chmod, rename, stat } from "node:fs/promises";
34
+ import { DEFAULT_DAEMON_LOG_MAX_BYTES, loadConfig } from "./config.js";
35
+ import { isProcessAlive, readPidFromFile } from "./pidfile.js";
36
+ import { isSocketConnectable } from "./socket-probe.js";
37
+ import { getStateDirPaths } from "./state-dir.js";
38
+ /** Mode every file under the state dir is held at (ARCHITECTURE.md §3). */
39
+ const STATE_FILE_MODE = 0o600;
40
+ export const daemonLogBackupPath = (logFilePath) => `${logFilePath}.1`;
41
+ /** True when `pidFilePath` names a process that is still around. A missing/garbage pidfile, or an
42
+ * unreadable one, reads as "nothing is running" — the same conclusion the pidfile takeover and the
43
+ * stale-socket unlink draw from it. */
44
+ const isDaemonHoldingLog = async (pidFilePath) => {
45
+ try {
46
+ const pid = await readPidFromFile(pidFilePath);
47
+ return pid !== undefined && isProcessAlive(pid);
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ };
53
+ /**
54
+ * Moves `daemon.log` to `daemon.log.1` when it exceeds `maxBytes`, replacing any previous backup.
55
+ * A missing log, a log within the cap, a log a live daemon still holds, or any failure is a no-op
56
+ * — never throws.
57
+ */
58
+ export const rotateDaemonLogIfNeeded = async (options) => {
59
+ const warn = options.warn ??
60
+ ((message) => {
61
+ process.stderr.write(`${message}\n`);
62
+ });
63
+ try {
64
+ const stats = await stat(options.logFilePath);
65
+ if (!stats.isFile() || stats.size <= options.maxBytes) {
66
+ return { rotated: false };
67
+ }
68
+ // Both checks run only once rotation is actually on the table, so the overwhelmingly common
69
+ // within-cap spawn stays two syscalls — and as late as possible, to keep the window in which
70
+ // a daemon can appear between the check and the rename as small as it can be made.
71
+ const held = (options.pidFilePath !== undefined && (await isDaemonHoldingLog(options.pidFilePath))) ||
72
+ (options.socketPath !== undefined && (await isSocketConnectable(options.socketPath)));
73
+ if (held) {
74
+ warn(`appduct: "${options.logFilePath}" is over its size cap but a daemon still holds it; leaving it in place.`);
75
+ return { rotated: false, skipped: "daemon_running" };
76
+ }
77
+ const backupPath = daemonLogBackupPath(options.logFilePath);
78
+ // `rename` is atomic within the state dir, so a concurrent spawner either sees the old log or
79
+ // the rotated one — never a half-copied file.
80
+ await rename(options.logFilePath, backupPath);
81
+ // Deliberately its own try/catch, *after* the rename has already succeeded. `rename` preserves
82
+ // the log's mode, so this only ever repairs a backup that predates this code (or an operator's
83
+ // `cp`) — and a filesystem that refuses the chmod must not make a completed rotation report
84
+ // itself as "not rotated" under a warning naming the wrong step.
85
+ try {
86
+ await chmod(backupPath, STATE_FILE_MODE);
87
+ }
88
+ catch (error) {
89
+ warn(`appduct: rotated "${options.logFilePath}" but could not set 0600 on "${backupPath}": ${error.message}`);
90
+ }
91
+ return { rotated: true, rotatedBytes: stats.size };
92
+ }
93
+ catch (error) {
94
+ if (error.code === "ENOENT") {
95
+ return { rotated: false };
96
+ }
97
+ warn(`appduct: failed to rotate "${options.logFilePath}": ${error.message}`);
98
+ return { rotated: false };
99
+ }
100
+ };
101
+ /**
102
+ * Best-effort enforcement of `daemon.log`'s ARCHITECTURE.md §3 mode. `open(path, "a", 0o600)`
103
+ * already creates it at `0600` (the umask can only remove bits), so this exists for the file that
104
+ * was *not* created by us — an operator's `touch`, a `cp` from elsewhere — and is a no-op
105
+ * otherwise.
106
+ *
107
+ * Never throws. It sits directly on the auto-spawn path, where every CLI and MCP command that
108
+ * needs a daemon passes through: a filesystem that answers `chmod` with EPERM or ENOTSUP (a
109
+ * drvfs/NFS/SMB-backed state dir, a root-owned log under a non-root user) would otherwise brick
110
+ * every one of those commands over a permission bit. A daemon that cannot tighten its log mode
111
+ * must still start — loudly, but it must start.
112
+ */
113
+ export const ensureDaemonLogMode = async (logFilePath, warn = (message) => process.stderr.write(`${message}\n`)) => {
114
+ try {
115
+ await chmod(logFilePath, STATE_FILE_MODE);
116
+ return true;
117
+ }
118
+ catch (error) {
119
+ warn(`appduct: could not set 0600 on "${logFilePath}": ${error.message}`);
120
+ return false;
121
+ }
122
+ };
123
+ /**
124
+ * `config.daemonLogMaxBytes` for a state dir, falling back to the default when `config.json` is
125
+ * missing or unreadable. Deliberately forgiving: this runs on the spawn path, where a bad config
126
+ * is the daemon's problem to report properly once it starts — not a reason to fail rotation (and
127
+ * with it the spawn) with a duplicate error.
128
+ */
129
+ export const resolveDaemonLogMaxBytes = async (stateDir) => {
130
+ try {
131
+ return (await loadConfig(getStateDirPaths(stateDir))).daemonLogMaxBytes;
132
+ }
133
+ catch {
134
+ return DEFAULT_DAEMON_LOG_MAX_BYTES;
135
+ }
136
+ };
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Pidfile single-instancing (ARCHITECTURE.md §4). Acquired with `O_EXCL`; on conflict, liveness
3
+ * is checked with `process.kill(pid, 0)` and the pidfile is only taken over if the owning
4
+ * process is dead.
5
+ */
6
+ export declare class DaemonAlreadyRunningError extends Error {
7
+ readonly pid: number;
8
+ constructor(pid: number);
9
+ }
10
+ export type PidfileHandle = {
11
+ readonly pid: number;
12
+ /** Idempotent: safe to call more than once. */
13
+ release: () => Promise<void>;
14
+ };
15
+ /**
16
+ * Liveness probe for a pid this process does not own (ARCHITECTURE.md §4's `process.kill(pid, 0)`
17
+ * check). Exported because every "is a daemon still there?" decision in the codebase must answer
18
+ * it the same way — pidfile takeover, the auto-spawn path's stale-socket unlink, and log rotation
19
+ * all hinge on it, and a second implementation that disagreed about `EPERM` would mean one of
20
+ * them quietly clobbering a live daemon's state.
21
+ */
22
+ export declare const isProcessAlive: (pid: number) => boolean;
23
+ export declare const readPidFromFile: (pidFilePath: string) => Promise<number | undefined>;
24
+ export type AcquirePidfileOptions = {
25
+ pid?: number;
26
+ /**
27
+ * Invoked once, after a stale (dead-owner) pidfile is detected and removed, before the pidfile
28
+ * is re-acquired. Lets the caller clean up other stale state (e.g. `daemon.sock`) tied to the
29
+ * dead process.
30
+ */
31
+ onStaleTakeover?: () => Promise<void>;
32
+ };
33
+ /**
34
+ * Acquires the daemon pidfile, taking over a stale one (owner process is dead or the file is
35
+ * unparseable) but throwing {@link DaemonAlreadyRunningError} when a live daemon holds it.
36
+ */
37
+ export declare const acquirePidfile: (pidFilePath: string, options?: AcquirePidfileOptions) => Promise<PidfileHandle>;
38
+ //# sourceMappingURL=pidfile.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"pidfile.d.ts","sourceRoot":"","sources":["../../src/daemon/pidfile.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAIH,qBAAa,yBAA0B,SAAQ,KAAK;IACtC,QAAQ,CAAC,GAAG,EAAE,MAAM;gBAAX,GAAG,EAAE,MAAM;CAIjC;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,+CAA+C;IAC/C,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC9B,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,cAAc,GAAI,KAAK,MAAM,KAAG,OAQ5C,CAAC;AAEF,eAAO,MAAM,eAAe,GAAU,aAAa,MAAM,KAAG,OAAO,CAAC,MAAM,GAAG,SAAS,CAgBrF,CAAC;AAEF,MAAM,MAAM,qBAAqB,GAAG;IAClC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CACvC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,cAAc,GACzB,aAAa,MAAM,EACnB,UAAS,qBAA0B,KAClC,OAAO,CAAC,aAAa,CA+CvB,CAAC"}
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Pidfile single-instancing (ARCHITECTURE.md §4). Acquired with `O_EXCL`; on conflict, liveness
3
+ * is checked with `process.kill(pid, 0)` and the pidfile is only taken over if the owning
4
+ * process is dead.
5
+ */
6
+ import { open, readFile, rm } from "node:fs/promises";
7
+ export class DaemonAlreadyRunningError extends Error {
8
+ pid;
9
+ constructor(pid) {
10
+ super(`Appduct daemon is already running (pid ${pid}).`);
11
+ this.pid = pid;
12
+ this.name = "DaemonAlreadyRunningError";
13
+ }
14
+ }
15
+ /**
16
+ * Liveness probe for a pid this process does not own (ARCHITECTURE.md §4's `process.kill(pid, 0)`
17
+ * check). Exported because every "is a daemon still there?" decision in the codebase must answer
18
+ * it the same way — pidfile takeover, the auto-spawn path's stale-socket unlink, and log rotation
19
+ * all hinge on it, and a second implementation that disagreed about `EPERM` would mean one of
20
+ * them quietly clobbering a live daemon's state.
21
+ */
22
+ export const isProcessAlive = (pid) => {
23
+ try {
24
+ process.kill(pid, 0);
25
+ return true;
26
+ }
27
+ catch (error) {
28
+ // EPERM means the process exists but we lack permission to signal it — still alive.
29
+ return error.code === "EPERM";
30
+ }
31
+ };
32
+ export const readPidFromFile = async (pidFilePath) => {
33
+ let contents;
34
+ try {
35
+ contents = await readFile(pidFilePath, "utf8");
36
+ }
37
+ catch (error) {
38
+ if (error.code === "ENOENT") {
39
+ return undefined;
40
+ }
41
+ throw error;
42
+ }
43
+ const pid = Number.parseInt(contents.trim(), 10);
44
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
45
+ };
46
+ /**
47
+ * Acquires the daemon pidfile, taking over a stale one (owner process is dead or the file is
48
+ * unparseable) but throwing {@link DaemonAlreadyRunningError} when a live daemon holds it.
49
+ */
50
+ export const acquirePidfile = async (pidFilePath, options = {}) => {
51
+ const pid = options.pid ?? process.pid;
52
+ // Bounded retry: another process could win the takeover race between our stale-check and our
53
+ // write attempt. A handful of retries is enough to make forward progress deterministic in tests
54
+ // without looping forever on a persistently-contended pidfile.
55
+ for (let attempt = 0; attempt < 5; attempt += 1) {
56
+ try {
57
+ const handle = await open(pidFilePath, "wx", 0o600);
58
+ try {
59
+ await handle.writeFile(String(pid), "utf8");
60
+ }
61
+ finally {
62
+ await handle.close();
63
+ }
64
+ let released = false;
65
+ return {
66
+ pid,
67
+ release: async () => {
68
+ if (released) {
69
+ return;
70
+ }
71
+ released = true;
72
+ await rm(pidFilePath, { force: true });
73
+ },
74
+ };
75
+ }
76
+ catch (error) {
77
+ if (error.code !== "EEXIST") {
78
+ throw error;
79
+ }
80
+ const existingPid = await readPidFromFile(pidFilePath);
81
+ if (existingPid !== undefined && isProcessAlive(existingPid)) {
82
+ throw new DaemonAlreadyRunningError(existingPid);
83
+ }
84
+ // Stale pidfile (dead owner, or unparseable contents): remove and retry.
85
+ await rm(pidFilePath, { force: true });
86
+ await options.onStaleTakeover?.();
87
+ }
88
+ }
89
+ throw new Error(`Failed to acquire the Appduct daemon pidfile at "${pidFilePath}".`);
90
+ };
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Tool-call policy evaluation (ARCHITECTURE.md §12). Every `tools.call` — CLI and MCP alike —
3
+ * passes through {@link evaluate} before the daemon sends anything to the app. Precedence:
4
+ *
5
+ * 1. a per-tool override, `policy.tools["<alias>/<name>"]`, wins outright;
6
+ * 2. else `policy.destructive` applies to tools whose descriptor sets
7
+ * `annotations.destructiveHint === true`;
8
+ * 3. else `policy.default`.
9
+ *
10
+ * This module is pure and synchronous — it knows nothing about sockets, audit, or the RPC layer;
11
+ * the daemon composition root (`daemon.ts`) is the single seam that calls it and reacts to the
12
+ * decision (deny → `policy_denied`, no frame to the app, per ARCHITECTURE.md §5/§12).
13
+ */
14
+ import type { ToolDescriptor } from "@appduct/shared";
15
+ import type { AppductPolicyConfig, PolicyDecision } from "./config.js";
16
+ /** The pieces of a resolved session {@link evaluate} needs to key a per-tool override. */
17
+ export type PolicySession = {
18
+ alias: string;
19
+ };
20
+ export declare const policyOverrideKey: (alias: string, toolName: string) => string;
21
+ export declare const evaluate: (descriptor: ToolDescriptor, session: PolicySession, policy: AppductPolicyConfig) => PolicyDecision;
22
+ //# sourceMappingURL=policy.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../../src/daemon/policy.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtD,OAAO,KAAK,EAAE,mBAAmB,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC;AAEvE,0FAA0F;AAC1F,MAAM,MAAM,aAAa,GAAG;IAC1B,KAAK,EAAE,MAAM,CAAC;CACf,CAAC;AAEF,eAAO,MAAM,iBAAiB,GAAI,OAAO,MAAM,EAAE,UAAU,MAAM,KAAG,MAAgC,CAAC;AAErG,eAAO,MAAM,QAAQ,GACnB,YAAY,cAAc,EAC1B,SAAS,aAAa,EACtB,QAAQ,mBAAmB,KAC1B,cAYF,CAAC"}
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Tool-call policy evaluation (ARCHITECTURE.md §12). Every `tools.call` — CLI and MCP alike —
3
+ * passes through {@link evaluate} before the daemon sends anything to the app. Precedence:
4
+ *
5
+ * 1. a per-tool override, `policy.tools["<alias>/<name>"]`, wins outright;
6
+ * 2. else `policy.destructive` applies to tools whose descriptor sets
7
+ * `annotations.destructiveHint === true`;
8
+ * 3. else `policy.default`.
9
+ *
10
+ * This module is pure and synchronous — it knows nothing about sockets, audit, or the RPC layer;
11
+ * the daemon composition root (`daemon.ts`) is the single seam that calls it and reacts to the
12
+ * decision (deny → `policy_denied`, no frame to the app, per ARCHITECTURE.md §5/§12).
13
+ */
14
+ export const policyOverrideKey = (alias, toolName) => `${alias}/${toolName}`;
15
+ export const evaluate = (descriptor, session, policy) => {
16
+ const override = policy.tools?.[policyOverrideKey(session.alias, descriptor.name)];
17
+ if (override !== undefined) {
18
+ return override;
19
+ }
20
+ if (descriptor.annotations?.destructiveHint === true) {
21
+ return policy.destructive;
22
+ }
23
+ return policy.default;
24
+ };
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Per-session tool registry (ARCHITECTURE.md §7). Callers (sessions.ts/listener.ts) must validate
3
+ * incoming `tool_registry_snapshot`/`tool_registry_delta` wire messages with the shared guards
4
+ * *before* calling into this module — v1 crashed by indexing into an unvalidated `tools: [null]`
5
+ * snapshot; this module only ever stores already-validated {@link ToolDescriptor}s.
6
+ */
7
+ import type { ToolDescriptor } from "@appduct/shared";
8
+ export type ToolRegistry = {
9
+ /** Authoritative replace: used for the initial snapshot and every post-resume re-sync. */
10
+ snapshot: (tools: ToolDescriptor[]) => void;
11
+ upsert: (tool: ToolDescriptor) => void;
12
+ remove: (name: string) => void;
13
+ list: () => ToolDescriptor[];
14
+ get: (name: string) => ToolDescriptor | undefined;
15
+ count: () => number;
16
+ };
17
+ /** `onChange` fires after every mutation (snapshot/upsert/remove) — used to emit `tools_changed`. */
18
+ export declare const createToolRegistry: (onChange?: () => void) => ToolRegistry;
19
+ //# sourceMappingURL=registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../../src/daemon/registry.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAEtD,MAAM,MAAM,YAAY,GAAG;IACzB,0FAA0F;IAC1F,QAAQ,EAAE,CAAC,KAAK,EAAE,cAAc,EAAE,KAAK,IAAI,CAAC;IAC5C,MAAM,EAAE,CAAC,IAAI,EAAE,cAAc,KAAK,IAAI,CAAC;IACvC,MAAM,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/B,IAAI,EAAE,MAAM,cAAc,EAAE,CAAC;IAC7B,GAAG,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,cAAc,GAAG,SAAS,CAAC;IAClD,KAAK,EAAE,MAAM,MAAM,CAAC;CACrB,CAAC;AAEF,qGAAqG;AACrG,eAAO,MAAM,kBAAkB,GAAI,WAAW,MAAM,IAAI,KAAG,YAyB1D,CAAC"}
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Per-session tool registry (ARCHITECTURE.md §7). Callers (sessions.ts/listener.ts) must validate
3
+ * incoming `tool_registry_snapshot`/`tool_registry_delta` wire messages with the shared guards
4
+ * *before* calling into this module — v1 crashed by indexing into an unvalidated `tools: [null]`
5
+ * snapshot; this module only ever stores already-validated {@link ToolDescriptor}s.
6
+ */
7
+ /** `onChange` fires after every mutation (snapshot/upsert/remove) — used to emit `tools_changed`. */
8
+ export const createToolRegistry = (onChange) => {
9
+ let tools = new Map();
10
+ const notify = () => {
11
+ onChange?.();
12
+ };
13
+ return {
14
+ snapshot: (incoming) => {
15
+ tools = new Map(incoming.map((tool) => [tool.name, tool]));
16
+ notify();
17
+ },
18
+ upsert: (tool) => {
19
+ tools.set(tool.name, tool);
20
+ notify();
21
+ },
22
+ remove: (name) => {
23
+ if (tools.delete(name)) {
24
+ notify();
25
+ }
26
+ },
27
+ list: () => Array.from(tools.values()),
28
+ get: (name) => tools.get(name),
29
+ count: () => tools.size,
30
+ };
31
+ };
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The control-plane UDS JSON-RPC server (ARCHITECTURE.md §5). Framing: one JSON-RPC 2.0 message
3
+ * per line (`\n`-delimited). Server→client notifications use method `"event"` and are pushed via
4
+ * {@link notify} on a targeted connection — this module only exposes the primitive.
5
+ */
6
+ import { type Server, type Socket } from "node:net";
7
+ import type { ErrorType } from "@appduct/shared";
8
+ /** 1 MiB — lines beyond this are dropped by destroying the connection (ARCHITECTURE.md task notes). */
9
+ export declare const MAX_LINE_BYTES: number;
10
+ /** 4 MiB — a subscriber connection (`events.subscribe`) whose outbound buffer grows past this
11
+ * (slow/dead reader) is dropped outright rather than left to back up the daemon indefinitely. */
12
+ export declare const MAX_NOTIFY_BUFFERED_BYTES: number;
13
+ export declare class RpcApplicationError extends Error {
14
+ readonly type: ErrorType;
15
+ /** JSON-RPC error code; defaults to the shared "server error" range. */
16
+ readonly code: number;
17
+ readonly details?: unknown | undefined;
18
+ constructor(type: ErrorType, message: string,
19
+ /** JSON-RPC error code; defaults to the shared "server error" range. */
20
+ code?: number, details?: unknown | undefined);
21
+ }
22
+ export type RpcConnection = {
23
+ readonly id: number;
24
+ readonly socket: Socket;
25
+ /** Extensibility bag for future wiring (e.g. `events.subscribe` state). */
26
+ readonly state: Record<string, unknown>;
27
+ };
28
+ export type RpcHandlerContext = {
29
+ connection: RpcConnection;
30
+ /** Registers a callback to run once the in-flight response has been flushed to the socket. */
31
+ afterSend: (callback: () => void) => void;
32
+ /** Registers a callback to run once this connection closes — e.g. auto-cancelling a `tools.call`
33
+ * still in flight when the connection that issued it drops. Callbacks run at most once. Returns
34
+ * an unregister function — callers whose call already settled must use it, or the callback (and
35
+ * whatever it closed over) leaks for the lifetime of a long-lived connection (e.g. the MCP
36
+ * server's persistent stream, which issues many `tools.call`s over one connection). */
37
+ onClose: (callback: () => void) => () => void;
38
+ };
39
+ export type RpcHandler = (params: unknown, context: RpcHandlerContext) => unknown | Promise<unknown>;
40
+ export type RpcDispatchTable = Record<string, RpcHandler>;
41
+ export type RpcServerOptions = {
42
+ socketPath: string;
43
+ dispatch: RpcDispatchTable;
44
+ maxLineBytes?: number;
45
+ };
46
+ export type RpcServer = {
47
+ server: Server;
48
+ /** Pushes a server→client `"event"` notification (no `id`) on the given connection. */
49
+ notify: (connection: RpcConnection, payload: unknown) => void;
50
+ connections: () => RpcConnection[];
51
+ close: () => Promise<void>;
52
+ };
53
+ export declare const startRpcServer: (options: RpcServerOptions) => Promise<RpcServer>;
54
+ //# sourceMappingURL=rpc-server.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rpc-server.d.ts","sourceRoot":"","sources":["../../src/daemon/rpc-server.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EAAgB,KAAK,MAAM,EAAE,KAAK,MAAM,EAAE,MAAM,UAAU,CAAC;AAGlE,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,iBAAiB,CAAC;AAEjD,uGAAuG;AACvG,eAAO,MAAM,cAAc,QAAc,CAAC;AAE1C;iGACiG;AACjG,eAAO,MAAM,yBAAyB,QAAkB,CAAC;AAEzD,qBAAa,mBAAoB,SAAQ,KAAK;IAE1C,QAAQ,CAAC,IAAI,EAAE,SAAS;IAExB,wEAAwE;IACxE,QAAQ,CAAC,IAAI;IACb,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO;gBAJjB,IAAI,EAAE,SAAS,EACxB,OAAO,EAAE,MAAM;IACf,wEAAwE;IAC/D,IAAI,SAAS,EACb,OAAO,CAAC,EAAE,OAAO,YAAA;CAK7B;AAED,MAAM,MAAM,aAAa,GAAG;IAC1B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,2EAA2E;IAC3E,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACzC,CAAC;AAEF,MAAM,MAAM,iBAAiB,GAAG;IAC9B,UAAU,EAAE,aAAa,CAAC;IAC1B,8FAA8F;IAC9F,SAAS,EAAE,CAAC,QAAQ,EAAE,MAAM,IAAI,KAAK,IAAI,CAAC;IAC1C;;;;2FAIuF;IACvF,OAAO,EAAE,CAAC,QAAQ,EAAE,MAAM,IAAI,KAAK,MAAM,IAAI,CAAC;CAC/C,CAAC;AAEF,MAAM,MAAM,UAAU,GAAG,CAAC,MAAM,EAAE,OAAO,EAAE,OAAO,EAAE,iBAAiB,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;AAErG,MAAM,MAAM,gBAAgB,GAAG,MAAM,CAAC,MAAM,EAAE,UAAU,CAAC,CAAC;AA2C1D,MAAM,MAAM,gBAAgB,GAAG;IAC7B,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAAC;AAEF,MAAM,MAAM,SAAS,GAAG;IACtB,MAAM,EAAE,MAAM,CAAC;IACf,uFAAuF;IACvF,MAAM,EAAE,CAAC,UAAU,EAAE,aAAa,EAAE,OAAO,EAAE,OAAO,KAAK,IAAI,CAAC;IAC9D,WAAW,EAAE,MAAM,aAAa,EAAE,CAAC;IACnC,KAAK,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5B,CAAC;AAsEF,eAAO,MAAM,cAAc,GAAU,SAAS,gBAAgB,KAAG,OAAO,CAAC,SAAS,CAiIjF,CAAC"}
@@ -0,0 +1,203 @@
1
+ /**
2
+ * The control-plane UDS JSON-RPC server (ARCHITECTURE.md §5). Framing: one JSON-RPC 2.0 message
3
+ * per line (`\n`-delimited). Server→client notifications use method `"event"` and are pushed via
4
+ * {@link notify} on a targeted connection — this module only exposes the primitive.
5
+ */
6
+ import { createServer } from "node:net";
7
+ import { chmod, rm } from "node:fs/promises";
8
+ /** 1 MiB — lines beyond this are dropped by destroying the connection (ARCHITECTURE.md task notes). */
9
+ export const MAX_LINE_BYTES = 1024 * 1024;
10
+ /** 4 MiB — a subscriber connection (`events.subscribe`) whose outbound buffer grows past this
11
+ * (slow/dead reader) is dropped outright rather than left to back up the daemon indefinitely. */
12
+ export const MAX_NOTIFY_BUFFERED_BYTES = 4 * 1024 * 1024;
13
+ export class RpcApplicationError extends Error {
14
+ type;
15
+ code;
16
+ details;
17
+ constructor(type, message,
18
+ /** JSON-RPC error code; defaults to the shared "server error" range. */
19
+ code = -32000, details) {
20
+ super(message);
21
+ this.type = type;
22
+ this.code = code;
23
+ this.details = details;
24
+ this.name = "RpcApplicationError";
25
+ }
26
+ }
27
+ const isPlainObject = (value) => {
28
+ return typeof value === "object" && value !== null && !Array.isArray(value);
29
+ };
30
+ const hasRequestId = (value) => {
31
+ return "id" in value;
32
+ };
33
+ const writeErrorLine = (socket, id, code, message, data) => {
34
+ writeLine(socket, {
35
+ jsonrpc: "2.0",
36
+ id,
37
+ error: { code, message, ...(data ? { data } : {}) },
38
+ });
39
+ };
40
+ const writeLine = (socket, payload, onFlushed) => {
41
+ if (socket.destroyed) {
42
+ return;
43
+ }
44
+ socket.write(`${JSON.stringify(payload)}\n`, (error) => {
45
+ if (!error) {
46
+ onFlushed?.();
47
+ }
48
+ });
49
+ };
50
+ const handleLine = async (line, connection, dispatch, registerOnClose) => {
51
+ const { socket } = connection;
52
+ let request;
53
+ try {
54
+ const parsed = JSON.parse(line);
55
+ if (!isPlainObject(parsed)) {
56
+ writeErrorLine(socket, null, -32600, "Invalid Request: expected a JSON object.");
57
+ return;
58
+ }
59
+ request = parsed;
60
+ }
61
+ catch {
62
+ writeErrorLine(socket, null, -32700, "Parse error: invalid JSON.");
63
+ return;
64
+ }
65
+ const id = hasRequestId(request) ? request.id : null;
66
+ if (typeof request.method !== "string" || request.method.length === 0) {
67
+ writeErrorLine(socket, id, -32600, 'Invalid Request: "method" must be a non-empty string.');
68
+ return;
69
+ }
70
+ const handler = dispatch[request.method];
71
+ if (!handler) {
72
+ writeErrorLine(socket, id, -32601, `Method not found: "${request.method}".`);
73
+ return;
74
+ }
75
+ const afterSendCallbacks = [];
76
+ const context = {
77
+ connection,
78
+ afterSend: (callback) => {
79
+ afterSendCallbacks.push(callback);
80
+ },
81
+ onClose: registerOnClose,
82
+ };
83
+ try {
84
+ const result = await handler(request.params, context);
85
+ writeLine(socket, { jsonrpc: "2.0", id, result: result ?? null }, () => {
86
+ for (const callback of afterSendCallbacks) {
87
+ callback();
88
+ }
89
+ });
90
+ }
91
+ catch (error) {
92
+ if (error instanceof RpcApplicationError) {
93
+ writeErrorLine(socket, id, error.code, error.message, {
94
+ type: error.type,
95
+ ...(error.details !== undefined ? { details: error.details } : {}),
96
+ });
97
+ return;
98
+ }
99
+ const message = error instanceof Error ? error.message : "Internal error.";
100
+ writeErrorLine(socket, id, -32603, message, { type: "invalid_request" });
101
+ }
102
+ };
103
+ export const startRpcServer = async (options) => {
104
+ const maxLineBytes = options.maxLineBytes ?? MAX_LINE_BYTES;
105
+ const connections = new Map();
106
+ let nextConnectionId = 1;
107
+ await rm(options.socketPath, { force: true });
108
+ const server = createServer((socket) => {
109
+ socket.on("error", () => {
110
+ // A dropped/reset client connection must never crash the daemon.
111
+ });
112
+ const id = nextConnectionId;
113
+ nextConnectionId += 1;
114
+ const connection = { id, socket, state: {} };
115
+ connections.set(id, connection);
116
+ const closeCallbacks = new Set();
117
+ const registerOnClose = (callback) => {
118
+ closeCallbacks.add(callback);
119
+ return () => {
120
+ closeCallbacks.delete(callback);
121
+ };
122
+ };
123
+ let buffer = "";
124
+ socket.on("data", (chunk) => {
125
+ buffer += chunk.toString("utf8");
126
+ if (Buffer.byteLength(buffer, "utf8") > maxLineBytes && !buffer.includes("\n")) {
127
+ socket.destroy();
128
+ return;
129
+ }
130
+ let newlineIndex = buffer.indexOf("\n");
131
+ while (newlineIndex !== -1) {
132
+ const line = buffer.slice(0, newlineIndex).replace(/\r$/u, "");
133
+ buffer = buffer.slice(newlineIndex + 1);
134
+ if (line.length > 0) {
135
+ if (Buffer.byteLength(line, "utf8") > maxLineBytes) {
136
+ // ARCHITECTURE.md task notes: cap line length at 1 MiB, dropping the connection beyond.
137
+ writeErrorLine(socket, null, -32600, `Invalid Request: line exceeds the ${maxLineBytes}-byte limit.`);
138
+ socket.destroy();
139
+ return;
140
+ }
141
+ void handleLine(line, connection, options.dispatch, registerOnClose);
142
+ }
143
+ newlineIndex = buffer.indexOf("\n");
144
+ }
145
+ });
146
+ socket.on("close", () => {
147
+ connections.delete(id);
148
+ const callbacks = Array.from(closeCallbacks);
149
+ closeCallbacks.clear();
150
+ for (const callback of callbacks) {
151
+ try {
152
+ callback();
153
+ }
154
+ catch {
155
+ // A close callback must never crash the daemon.
156
+ }
157
+ }
158
+ });
159
+ });
160
+ server.on("error", () => {
161
+ // Never let a listener-level error (e.g. EMFILE after startup) crash the process. Startup
162
+ // failures are additionally surfaced via the one-shot listener below, which rejects `listen()`.
163
+ // Surfacing post-startup errors would require a caller-supplied sink; the daemon composition
164
+ // root owns observability wiring in a later task.
165
+ });
166
+ await new Promise((resolve, reject) => {
167
+ const onError = (error) => {
168
+ server.off("listening", onListening);
169
+ reject(error);
170
+ };
171
+ const onListening = () => {
172
+ server.off("error", onError);
173
+ resolve();
174
+ };
175
+ server.once("error", onError);
176
+ server.once("listening", onListening);
177
+ server.listen(options.socketPath);
178
+ });
179
+ await chmod(options.socketPath, 0o600);
180
+ return {
181
+ server,
182
+ notify: (connection, payload) => {
183
+ if (connection.socket.destroyed) {
184
+ return;
185
+ }
186
+ // Backpressure guard: a slow/dead subscriber must never block the daemon or other
187
+ // connections. `writableLength` reflects bytes already queued but not yet flushed to the
188
+ // kernel; once it crosses the cap the connection is beyond saving and gets dropped.
189
+ if (connection.socket.writableLength > MAX_NOTIFY_BUFFERED_BYTES) {
190
+ connection.socket.destroy();
191
+ return;
192
+ }
193
+ writeLine(connection.socket, { jsonrpc: "2.0", method: "event", params: payload });
194
+ },
195
+ connections: () => Array.from(connections.values()),
196
+ close: () => new Promise((resolve) => {
197
+ for (const connection of connections.values()) {
198
+ connection.socket.destroy();
199
+ }
200
+ server.close(() => resolve());
201
+ }),
202
+ };
203
+ };