@mnemom/mnemom 0.17.34-next.1 → 0.17.34-next.2

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 (195) hide show
  1. package/dist/base-url-proxy.mjs +18 -4
  2. package/dist/commands/agent-config.js +5 -1
  3. package/dist/commands/agent-doctor.d.ts +9 -0
  4. package/dist/commands/agent-doctor.js +4 -0
  5. package/dist/commands/agent-session.d.ts +1 -2
  6. package/dist/commands/agent-session.js +1 -2
  7. package/dist/commands/agent-setup.d.ts +6 -0
  8. package/dist/commands/agent-setup.js +12 -1
  9. package/dist/commands/agent.d.ts +11 -0
  10. package/dist/commands/agent.js +145 -46
  11. package/dist/commands/durable-doctor.d.ts +50 -0
  12. package/dist/commands/durable-doctor.js +186 -0
  13. package/dist/commands/durable-lock.d.ts +254 -0
  14. package/dist/commands/durable-lock.js +595 -0
  15. package/dist/commands/durable-sessions.d.ts +87 -0
  16. package/dist/commands/durable-sessions.js +303 -0
  17. package/dist/commands/durable-verbs.d.ts +116 -0
  18. package/dist/commands/durable-verbs.js +393 -0
  19. package/dist/durable/flock.d.ts +26 -0
  20. package/dist/durable/flock.js +80 -0
  21. package/dist/durable/launch-spec.d.ts +7 -1
  22. package/dist/durable/launch-spec.js +10 -3
  23. package/dist/durable/seat-proxy-entry.d.ts +11 -0
  24. package/dist/durable/seat-proxy-entry.js +25 -0
  25. package/dist/durable/seat-proxy.d.ts +107 -0
  26. package/dist/durable/seat-proxy.js +599 -0
  27. package/dist/durable/seat-socket.d.ts +46 -0
  28. package/dist/durable/seat-socket.js +179 -0
  29. package/dist/durable/seat-spec-validator.d.ts +28 -0
  30. package/dist/durable/seat-spec-validator.js +59 -0
  31. package/dist/durable/test-mode.mjs +81 -0
  32. package/dist/durable/turn-chain.d.ts +55 -0
  33. package/dist/durable/turn-chain.js +178 -0
  34. package/dist/host/auto-continue.d.ts +67 -0
  35. package/dist/host/auto-continue.js +118 -0
  36. package/dist/host/bootid.d.ts +13 -0
  37. package/dist/host/bootid.js +41 -0
  38. package/dist/host/client.d.ts +123 -0
  39. package/dist/host/client.js +171 -0
  40. package/dist/host/commands.d.ts +2 -0
  41. package/dist/host/commands.js +122 -0
  42. package/dist/host/control.d.ts +33 -0
  43. package/dist/host/control.js +144 -0
  44. package/dist/host/daemon.d.ts +217 -0
  45. package/dist/host/daemon.js +606 -0
  46. package/dist/host/gate.d.ts +46 -0
  47. package/dist/host/gate.js +82 -0
  48. package/dist/host/grant.d.ts +24 -0
  49. package/dist/host/grant.js +24 -0
  50. package/dist/host/instance.d.ts +35 -0
  51. package/dist/host/instance.js +62 -0
  52. package/dist/host/integrity.d.ts +35 -0
  53. package/dist/host/integrity.js +115 -0
  54. package/dist/host/launchd.d.ts +16 -0
  55. package/dist/host/launchd.js +45 -0
  56. package/dist/host/lock.d.ts +20 -0
  57. package/dist/host/lock.js +63 -0
  58. package/dist/host/owner.d.ts +55 -0
  59. package/dist/host/owner.js +121 -0
  60. package/dist/host/plist.d.ts +16 -0
  61. package/dist/host/plist.js +85 -0
  62. package/dist/host/procs.d.ts +27 -0
  63. package/dist/host/procs.js +105 -0
  64. package/dist/host/run.d.ts +66 -0
  65. package/dist/host/run.js +314 -0
  66. package/dist/host/runtime.d.ts +8 -0
  67. package/dist/host/runtime.js +22 -0
  68. package/dist/host/s1-names.d.ts +22 -0
  69. package/dist/host/s1-names.js +21 -0
  70. package/dist/host/seat-credential.d.ts +27 -0
  71. package/dist/host/seat-credential.js +41 -0
  72. package/dist/host/seat-launch-main.d.ts +1 -0
  73. package/dist/host/seat-launch-main.js +59 -0
  74. package/dist/host/seat-launch.d.ts +116 -0
  75. package/dist/host/seat-launch.js +302 -0
  76. package/dist/host/seat.d.ts +48 -0
  77. package/dist/host/seat.js +149 -0
  78. package/dist/host/state.d.ts +33 -0
  79. package/dist/host/state.js +117 -0
  80. package/dist/host/status.d.ts +101 -0
  81. package/dist/host/status.js +307 -0
  82. package/dist/host/tmux.d.ts +56 -0
  83. package/dist/host/tmux.js +182 -0
  84. package/dist/host/transcript.d.ts +30 -0
  85. package/dist/host/transcript.js +130 -0
  86. package/dist/host/verbs.d.ts +50 -0
  87. package/dist/host/verbs.js +271 -0
  88. package/dist/index.d.ts +8 -6
  89. package/dist/index.js +197 -35
  90. package/dist/lib/agent.d.ts +8 -26
  91. package/dist/lib/agent.js +11 -26
  92. package/dist/lib/claude-plugin.d.ts +11 -2
  93. package/dist/lib/claude-plugin.js +10 -1
  94. package/dist/lib/durable/api.d.ts +136 -0
  95. package/dist/lib/durable/api.js +220 -0
  96. package/dist/lib/durable/codes.d.ts +8 -0
  97. package/dist/lib/durable/codes.js +2 -0
  98. package/dist/lib/durable/first-turn.d.ts +50 -0
  99. package/dist/lib/durable/first-turn.js +102 -0
  100. package/dist/lib/durable/gate.d.ts +48 -0
  101. package/dist/lib/durable/gate.js +122 -0
  102. package/dist/lib/durable/handover.d.ts +101 -0
  103. package/dist/lib/durable/handover.js +175 -0
  104. package/dist/lib/durable/launch.d.ts +182 -0
  105. package/dist/lib/durable/launch.js +443 -0
  106. package/dist/lib/durable/launching.d.ts +15 -0
  107. package/dist/lib/durable/launching.js +70 -0
  108. package/dist/lib/durable/live.d.ts +194 -0
  109. package/dist/lib/durable/live.js +635 -0
  110. package/dist/lib/durable/refusal.d.ts +57 -0
  111. package/dist/lib/durable/refusal.js +78 -0
  112. package/dist/lib/durable/view.d.ts +83 -0
  113. package/dist/lib/durable/view.js +244 -0
  114. package/dist/lib/experimental.d.ts +0 -9
  115. package/dist/lib/experimental.js +18 -17
  116. package/dist/lib/launcher-config.d.ts +7 -0
  117. package/dist/lib/launcher-config.js +21 -0
  118. package/dist/lib/scan/report-email.d.ts +1 -1
  119. package/dist/lib/scan/report-email.js +9 -1
  120. package/dist/lib/subscription-login.d.ts +15 -0
  121. package/dist/lib/subscription-login.js +28 -1
  122. package/dist/vendor/durable-contract/index.js +240 -18
  123. package/dist/vendor/shared/durable-contract/admission.d.ts +240 -0
  124. package/dist/vendor/shared/durable-contract/admission.js +533 -0
  125. package/dist/vendor/shared/durable-contract/arm.d.ts +51 -0
  126. package/dist/vendor/shared/durable-contract/arm.js +133 -0
  127. package/dist/vendor/shared/durable-contract/canonical.d.ts +29 -0
  128. package/dist/vendor/shared/durable-contract/canonical.js +98 -0
  129. package/dist/vendor/shared/durable-contract/cell-id.d.ts +22 -0
  130. package/dist/vendor/shared/durable-contract/cell-id.js +38 -0
  131. package/dist/vendor/shared/durable-contract/cli-output.d.ts +342 -0
  132. package/dist/vendor/shared/durable-contract/cli-output.js +201 -0
  133. package/dist/vendor/shared/durable-contract/cli-surface.d.ts +119 -0
  134. package/dist/vendor/shared/durable-contract/cli-surface.js +270 -0
  135. package/dist/vendor/shared/durable-contract/codes.d.ts +537 -0
  136. package/dist/vendor/shared/durable-contract/codes.js +475 -0
  137. package/dist/vendor/shared/durable-contract/constants.d.ts +153 -0
  138. package/dist/vendor/shared/durable-contract/constants.js +179 -0
  139. package/dist/vendor/shared/durable-contract/doors.d.ts +119 -0
  140. package/dist/vendor/shared/durable-contract/doors.js +249 -0
  141. package/dist/vendor/shared/durable-contract/envelope.d.ts +103 -0
  142. package/dist/vendor/shared/durable-contract/envelope.js +317 -0
  143. package/dist/vendor/shared/durable-contract/events.d.ts +147 -0
  144. package/dist/vendor/shared/durable-contract/events.js +630 -0
  145. package/dist/vendor/shared/durable-contract/executor-state.d.ts +41 -0
  146. package/dist/vendor/shared/durable-contract/executor-state.js +32 -0
  147. package/dist/vendor/shared/durable-contract/fixlines.d.ts +318 -0
  148. package/dist/vendor/shared/durable-contract/fixlines.js +483 -0
  149. package/dist/vendor/shared/durable-contract/freeze-record.d.ts +787 -0
  150. package/dist/vendor/shared/durable-contract/freeze-record.js +361 -0
  151. package/dist/vendor/shared/durable-contract/index.d.ts +36 -0
  152. package/dist/vendor/shared/durable-contract/index.js +37 -0
  153. package/dist/vendor/shared/durable-contract/journal-approvals.d.ts +29 -0
  154. package/dist/vendor/shared/durable-contract/journal-approvals.js +60 -0
  155. package/dist/vendor/shared/durable-contract/keys.d.ts +24 -0
  156. package/dist/vendor/shared/durable-contract/keys.js +66 -0
  157. package/dist/vendor/shared/durable-contract/lock.d.ts +128 -0
  158. package/dist/vendor/shared/durable-contract/lock.js +151 -0
  159. package/dist/vendor/shared/durable-contract/names.d.ts +25 -0
  160. package/dist/vendor/shared/durable-contract/names.js +78 -0
  161. package/dist/vendor/shared/durable-contract/owner-verbs.d.ts +175 -0
  162. package/dist/vendor/shared/durable-contract/owner-verbs.js +403 -0
  163. package/dist/vendor/shared/durable-contract/paths.d.ts +23 -0
  164. package/dist/vendor/shared/durable-contract/paths.js +35 -0
  165. package/dist/vendor/shared/durable-contract/pure-fn.d.ts +48 -0
  166. package/dist/vendor/shared/durable-contract/pure-fn.js +89 -0
  167. package/dist/vendor/shared/durable-contract/routes.d.ts +205 -0
  168. package/dist/vendor/shared/durable-contract/routes.js +315 -0
  169. package/dist/vendor/shared/durable-contract/schema.d.ts +126 -0
  170. package/dist/vendor/shared/durable-contract/schema.js +441 -0
  171. package/dist/vendor/shared/durable-contract/seal.d.ts +54 -0
  172. package/dist/vendor/shared/durable-contract/seal.js +99 -0
  173. package/dist/vendor/shared/durable-contract/secret-scope.d.ts +28 -0
  174. package/dist/vendor/shared/durable-contract/secret-scope.js +76 -0
  175. package/dist/vendor/shared/durable-contract/session-id.d.ts +21 -0
  176. package/dist/vendor/shared/durable-contract/session-id.js +58 -0
  177. package/dist/vendor/shared/durable-contract/spec.d.ts +162 -0
  178. package/dist/vendor/shared/durable-contract/spec.js +286 -0
  179. package/dist/vendor/shared/durable-contract/states.d.ts +96 -0
  180. package/dist/vendor/shared/durable-contract/states.js +218 -0
  181. package/dist/vendor/shared/durable-contract/telemetry.d.ts +94 -0
  182. package/dist/vendor/shared/durable-contract/telemetry.js +180 -0
  183. package/dist/vendor/shared/durable-contract/test-mode.d.ts +62 -0
  184. package/dist/vendor/shared/durable-contract/test-mode.js +140 -0
  185. package/dist/vendor/shared/durable-contract/turn.d.ts +57 -0
  186. package/dist/vendor/shared/durable-contract/turn.js +81 -0
  187. package/dist/vendor/shared/durable-contract/vectors.d.ts +191 -0
  188. package/dist/vendor/shared/durable-contract/vectors.js +208 -0
  189. package/package.json +3 -2
  190. package/plugins/integrity-manifest.json +35 -0
  191. package/plugins/mnemom/hooks/hooks.json +15 -1
  192. package/plugins/mnemom/hooks/register.tsx +60 -2
  193. package/plugins/mnemom/hooks/seat-hello.mjs +116 -0
  194. package/dist/commands/agent-implicit-notice.d.ts +0 -31
  195. package/dist/commands/agent-implicit-notice.js +0 -73
@@ -0,0 +1,599 @@
1
+ // The durable seat proxy: the fail-closed terminal credential proxy in front of one durable seat
2
+ // (BACKBONE C3; C13-R1..R12, R14, R15). Run as the hidden `mnemom agent host seat-proxy`, which in
3
+ // product only the host daemon spawns:
4
+ //
5
+ // mnemom agent host seat-proxy --conversation <uuid> --agent-id <uuid> --door <url> --cwd <dir>
6
+ // [--session <uuid> --epoch <n> --spawn-id <uuid> [--hello-relay <unix socket>]]
7
+ //
8
+ // The lease key arrives as ONE line on stdin and is never read from the environment, written to a
9
+ // file or logged. The owner's provider credential (claude's x-api-key, or its claude.ai login in
10
+ // authorization) passes through to the door unchanged, exactly as on a `mnemom agent` launch, and is
11
+ // never held past the request, written or logged (dogfood B6). CLAUDE_CONFIG_DIR (env) locates Claude Code's transcript. The optional seat ids
12
+ // (all non-secret) are the seat the daemon registered for this epoch; only they get a hello through
13
+ // the seat socket. stdout gets exactly one line, `READY <port> <socket path>`, once listening.
14
+ //
15
+ // Exit codes (C13 test hooks): 2 config or key refused (no READY), 3 conversation lock held,
16
+ // 4 the door said the key is dead (409 durable_lease_fenced, 401 durable_key_revoked, 410
17
+ // durable_session_ended or durable_unlocked). Log lines (MNEMOM_DURABLE_PROXY_LOG, else stderr)
18
+ // carry the drop reason enum and the exit reason, never a credential, header value or body byte.
19
+ import fs from "node:fs";
20
+ import http from "node:http";
21
+ import https from "node:https";
22
+ import os from "node:os";
23
+ import { randomBytes } from "node:crypto";
24
+ import { TurnOrigins, claudeConfigDir, TURN_HEADER } from "../turn-origin.mjs";
25
+ import { httpDoorAllowed } from "./test-mode.mjs";
26
+ import { durableTurnHeaderFor } from "./turn-chain.js";
27
+ import { tryLockFile } from "./flock.js";
28
+ import { startSeatSocket, seatSocketPath, unixLineRelay, } from "./seat-socket.js";
29
+ import { ARM_DROP_BODY, ARM_DROP_HEADERS, ARM_DROP_STATUS, ARM_HEADER, armForwardsFor, COUNT_TOKENS_PATH, isWellFormedLeaseKey, parseArm, proxyLockPath, } from "../vendor/durable-contract/index.js";
30
+ export const EXIT_CONFIG = 2;
31
+ export const EXIT_LOCK_HELD = 3;
32
+ export const EXIT_KEY_DEAD = 4;
33
+ const UUID_I = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
34
+ /** A pinned Mnemom agent id, the x-mnemom-agent-id the gateway honours (mirrors AGENT_ID_RE in lib/agent.ts). */
35
+ const AGENT_ID_RE = /^mnm-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
36
+ const MESSAGES_PATH = "/v1/messages";
37
+ /** The most a dead-key-status (401/409/410) body is buffered to; a longer one is dropped, never truncated. */
38
+ export const DEAD_BODY_CAP = 1024 * 1024;
39
+ /** The largest request body forwarded (the Messages API request limit); a larger one gets a local 413. */
40
+ export const REQUEST_BODY_CAP = 32 * 1024 * 1024;
41
+ const HOP = new Set([
42
+ "connection",
43
+ "proxy-connection",
44
+ "keep-alive",
45
+ "upgrade",
46
+ "host",
47
+ "te",
48
+ "trailer",
49
+ "transfer-encoding",
50
+ "content-length",
51
+ ]);
52
+ /**
53
+ * Client headers that never pass: hop-by-hop, proxy-authorization and every x-mnemom-* (the proxy
54
+ * sets the Mnemom ones from the lease). The provider credential (authorization, x-api-key) passes:
55
+ * the seat carries it exactly as `mnemom agent` does (dogfood B6 ruling, superseding C13-R2/R8).
56
+ */
57
+ export function isStrippedClientHeader(name) {
58
+ const n = name.toLowerCase();
59
+ return HOP.has(n) || n.startsWith("x-mnemom-") || n === "proxy-authorization";
60
+ }
61
+ const OPTION_KEYS = {
62
+ "--conversation": "conversation",
63
+ "--agent-id": "agentId",
64
+ "--door": "door",
65
+ "--cwd": "cwd",
66
+ "--session": "session",
67
+ "--epoch": "epoch",
68
+ "--spawn-id": "spawnId",
69
+ "--hello-relay": "helloRelay",
70
+ };
71
+ /**
72
+ * Parse and validate the argv after `seat-proxy`. Errors name the option, never echo a value.
73
+ * `isDir` and `env` are injected so the check is pure.
74
+ */
75
+ export function parseSeatProxyArgs(argv, env, isDir) {
76
+ const raw = {};
77
+ for (let i = 0; i < argv.length; i++) {
78
+ const tok = argv[i];
79
+ const eq = tok.indexOf("=");
80
+ const name = eq > 0 ? tok.slice(0, eq) : tok;
81
+ const key = OPTION_KEYS[name];
82
+ if (!key)
83
+ return { ok: false, error: "unknown argument" };
84
+ if (key in raw)
85
+ return { ok: false, error: `${name} given twice` };
86
+ let value;
87
+ if (eq > 0)
88
+ value = tok.slice(eq + 1);
89
+ else {
90
+ value = argv[i + 1];
91
+ i++;
92
+ }
93
+ if (value === undefined)
94
+ return { ok: false, error: `${name} needs a value` };
95
+ raw[key] = value;
96
+ }
97
+ if (!raw.conversation || !UUID_I.test(raw.conversation))
98
+ return { ok: false, error: "--conversation must be the registry conversation uuid" };
99
+ if (!raw.agentId || !AGENT_ID_RE.test(raw.agentId))
100
+ return { ok: false, error: "--agent-id must be the pinned agent uuid" };
101
+ if (!raw.cwd || !raw.cwd.startsWith("/") || !isDir(raw.cwd))
102
+ return { ok: false, error: "--cwd must be an absolute directory" };
103
+ let door;
104
+ try {
105
+ door = new URL(raw.door ?? "");
106
+ }
107
+ catch {
108
+ return { ok: false, error: "--door must be a URL" };
109
+ }
110
+ if (door.protocol !== "https:" && door.protocol !== "http:")
111
+ return { ok: false, error: "--door must be https" };
112
+ if (door.username || door.password)
113
+ return { ok: false, error: "--door must carry no userinfo" };
114
+ // T3 (C13-R11): an http door only on a loopback host, only under the CLI test-mode guard.
115
+ if (door.protocol === "http:" && !httpDoorAllowed(door.href, env))
116
+ return { ok: false, error: "--door must be https" };
117
+ const seatGiven = ["session", "epoch", "spawnId", "helloRelay"].some((k) => k in raw);
118
+ let seat = null;
119
+ if (seatGiven) {
120
+ const epoch = Number(raw.epoch);
121
+ if (!raw.session || !UUID_I.test(raw.session))
122
+ return { ok: false, error: "--session must be a uuid" };
123
+ if (!raw.spawnId || !UUID_I.test(raw.spawnId))
124
+ return { ok: false, error: "--spawn-id must be a uuid" };
125
+ if (!/^\d{1,15}$/.test(raw.epoch ?? "") || !Number.isSafeInteger(epoch))
126
+ return { ok: false, error: "--epoch must be a non-negative integer" };
127
+ if (raw.helloRelay !== undefined && !raw.helloRelay.startsWith("/"))
128
+ return { ok: false, error: "--hello-relay must be an absolute socket path" };
129
+ seat = { sessionId: raw.session, epoch, spawnId: raw.spawnId };
130
+ }
131
+ return {
132
+ ok: true,
133
+ args: {
134
+ conversation: raw.conversation,
135
+ agentId: raw.agentId,
136
+ door,
137
+ cwd: raw.cwd,
138
+ seat,
139
+ helloRelay: raw.helloRelay ?? null,
140
+ },
141
+ };
142
+ }
143
+ /** The first stdin line (CR stripped), or "" on EOF / timeout. Only the first line is ever read. */
144
+ export function readKeyLine(stream, timeoutMs = 10_000) {
145
+ return new Promise((resolve) => {
146
+ let buf = "";
147
+ const finish = (v) => {
148
+ clearTimeout(timer);
149
+ stream.removeListener("data", onData);
150
+ stream.removeListener("end", onEnd);
151
+ stream.removeListener("error", onEnd);
152
+ if (typeof stream.pause === "function")
153
+ stream.pause();
154
+ resolve(v.replace(/\r$/, ""));
155
+ };
156
+ const onData = (d) => {
157
+ buf += d.toString();
158
+ const i = buf.indexOf("\n");
159
+ if (i >= 0)
160
+ finish(buf.slice(0, i));
161
+ else if (buf.length > 4096)
162
+ finish("");
163
+ };
164
+ const onEnd = () => finish(buf);
165
+ const timer = setTimeout(() => finish(""), timeoutMs);
166
+ stream.on("data", onData);
167
+ stream.on("end", onEnd);
168
+ stream.on("error", onEnd);
169
+ });
170
+ }
171
+ // ---------------------------------------------------------------- response decisions
172
+ /** The arm verdict for one door response: forward, or the drop reason. */
173
+ export function armVerdict(pathname, rawHeaders) {
174
+ const values = [];
175
+ for (let i = 0; i + 1 < rawHeaders.length; i += 2)
176
+ if (rawHeaders[i].toLowerCase() === ARM_HEADER)
177
+ values.push(rawHeaders[i + 1]);
178
+ if (values.length === 0)
179
+ return { forward: false, reason: "arm_missing" };
180
+ if (values.length > 1)
181
+ return { forward: false, reason: "arm_unparseable" };
182
+ const v = values[0];
183
+ if (armForwardsFor(pathname, v))
184
+ return { forward: true };
185
+ const a = parseArm(v);
186
+ if (a === null || a.posture !== "unattended")
187
+ return { forward: false, reason: "arm_unparseable" };
188
+ return {
189
+ forward: false,
190
+ reason: a.hold !== "on" ? "unattended_hold_off" : "unattended_fence_off",
191
+ };
192
+ }
193
+ /** The dead-key signal in a door response (C13-R7), read from the JSON body's error.type. */
194
+ export function deadKeyReason(status, body) {
195
+ if (status !== 401 && status !== 409 && status !== 410)
196
+ return null;
197
+ let type;
198
+ try {
199
+ const j = JSON.parse(body);
200
+ type =
201
+ j && typeof j === "object" && j.error && typeof j.error === "object"
202
+ ? j.error.type
203
+ : undefined;
204
+ }
205
+ catch {
206
+ return null;
207
+ }
208
+ if (status === 409 && type === "durable_lease_fenced")
209
+ return "fenced_exit";
210
+ if (status === 401 && type === "durable_key_revoked")
211
+ return "revoked_exit";
212
+ if (status === 410 && (type === "durable_session_ended" || type === "durable_unlocked"))
213
+ return "ended_exit";
214
+ return null;
215
+ }
216
+ /** Whether a request path is origin-form and free of dot segments (raw or percent-encoded). */
217
+ export function isCleanOriginForm(url) {
218
+ if (!url.startsWith("/") || url.startsWith("//"))
219
+ return false;
220
+ const p = url.split("?")[0];
221
+ let decoded;
222
+ try {
223
+ decoded = decodeURIComponent(p);
224
+ }
225
+ catch {
226
+ return false;
227
+ }
228
+ return !decoded.split("/").some((seg) => seg === "." || seg === "..") && !decoded.includes("\\");
229
+ }
230
+ /** Where a request goes: the two model POSTs are forwarded; startup paths are answered locally. */
231
+ export function routeOf(method, pathname) {
232
+ if (method === "POST" && pathname === MESSAGES_PATH)
233
+ return "messages";
234
+ if (method === "POST" && pathname === COUNT_TOKENS_PATH)
235
+ return "count_tokens";
236
+ if ((method === "HEAD" || method === "GET") && pathname === "/api/hello")
237
+ return "hello";
238
+ if (method === "POST" && pathname.startsWith("/api/event_logging/"))
239
+ return "telemetry";
240
+ return "refused";
241
+ }
242
+ function sendDrop(res, requestId) {
243
+ if (res.headersSent) {
244
+ res.destroy();
245
+ return;
246
+ }
247
+ res.sendDate = false;
248
+ res.writeHead(ARM_DROP_STATUS, { ...ARM_DROP_HEADERS, "request-id": requestId });
249
+ res.end(ARM_DROP_BODY);
250
+ }
251
+ function sendLocal(res, status, type, message) {
252
+ res.writeHead(status, { "content-type": "application/json" });
253
+ res.end(JSON.stringify({ type: "error", error: { type, message } }));
254
+ }
255
+ /** Thrown by readAll when a body passes its cap. */
256
+ class OverCap extends Error {
257
+ }
258
+ /**
259
+ * The whole body. Rejects with OverCap as soon as it passes `cap` bytes (it never yields a truncated
260
+ * body), and with an Error when the stream closes before its end.
261
+ */
262
+ function readAll(stream, cap) {
263
+ return new Promise((resolve, reject) => {
264
+ const parts = [];
265
+ let n = 0;
266
+ let settled = false;
267
+ const onData = (d) => {
268
+ n += d.length;
269
+ if (n <= cap) {
270
+ parts.push(d);
271
+ return;
272
+ }
273
+ settled = true;
274
+ stream.removeListener("data", onData);
275
+ reject(new OverCap());
276
+ };
277
+ stream.on("data", onData);
278
+ stream.on("end", () => {
279
+ if (settled)
280
+ return;
281
+ settled = true;
282
+ resolve(Buffer.concat(parts));
283
+ });
284
+ stream.on("error", (e) => {
285
+ if (settled)
286
+ return;
287
+ settled = true;
288
+ reject(e);
289
+ });
290
+ stream.on("close", () => {
291
+ if (settled)
292
+ return;
293
+ settled = true;
294
+ reject(new Error("closed before end"));
295
+ });
296
+ });
297
+ }
298
+ function responseHeaders(rawHeaders) {
299
+ const out = {};
300
+ for (let i = 0; i + 1 < rawHeaders.length; i += 2) {
301
+ const name = rawHeaders[i].toLowerCase();
302
+ if (HOP.has(name) && name !== "content-length")
303
+ continue;
304
+ const prev = out[name];
305
+ out[name] =
306
+ prev === undefined ? rawHeaders[i + 1] : [].concat(prev, rawHeaders[i + 1]);
307
+ }
308
+ return out;
309
+ }
310
+ /** The request handler of a seat proxy for `cfg` with lease key `key`. */
311
+ export function createSeatProxyHandler(cfg, key, deps, state = { dead: null }) {
312
+ const doorPath = cfg.door.pathname.replace(/\/$/, "");
313
+ const transport = cfg.door.protocol === "https:" ? https : http;
314
+ const agent = new transport.Agent({ keepAlive: true, maxSockets: 32 });
315
+ const reqId = deps.requestId ?? (() => `req_mnmdrop_${randomBytes(12).toString("hex")}`);
316
+ const requestBodyCap = deps.requestBodyCap ?? REQUEST_BODY_CAP;
317
+ const deadBodyCap = deps.deadBodyCap ?? DEAD_BODY_CAP;
318
+ return (req, res) => {
319
+ const url = req.url ?? "";
320
+ // Node routes CONNECT to the server's 'connect' event, which refuseConnect (wired in
321
+ // seatProxyMain) answers; the method check here still refuses one that reaches the handler.
322
+ if (req.method === "CONNECT" || !isCleanOriginForm(url)) {
323
+ deps.log(`${req.method ?? "?"} refused (not origin-form)`);
324
+ req.resume();
325
+ sendLocal(res, 400, "invalid_request_error", "mnemom seat proxy: origin-form request path required");
326
+ return;
327
+ }
328
+ const pathname = url.split("?")[0];
329
+ const route = routeOf(req.method ?? "", pathname);
330
+ if (route === "hello") {
331
+ req.resume();
332
+ res.writeHead(200, { "content-type": "application/json" });
333
+ res.end(req.method === "HEAD" ? undefined : "{}");
334
+ return;
335
+ }
336
+ if (route === "telemetry") {
337
+ req.resume();
338
+ res.writeHead(200, { "content-type": "application/json" });
339
+ res.end("{}");
340
+ return;
341
+ }
342
+ if (route === "refused") {
343
+ req.resume();
344
+ deps.log(`${req.method} ${pathname.slice(0, 80)} refused (path not allowed)`);
345
+ sendLocal(res, 404, "not_found_error", "mnemom seat proxy: path not allowed");
346
+ return;
347
+ }
348
+ void readAll(req, requestBodyCap)
349
+ .then(async (body) => {
350
+ const turn = await durableTurnHeaderFor(deps.turns, pathname, req.headers, {
351
+ durable: true,
352
+ });
353
+ if (turn.kind === "local") {
354
+ deps.log(`${pathname} drop ${turn.reason}`);
355
+ sendDrop(res, reqId());
356
+ return;
357
+ }
358
+ if (state.dead) {
359
+ // After a dead-key answer nothing is forwarded again (C13-R7).
360
+ sendDrop(res, reqId());
361
+ return;
362
+ }
363
+ const headers = {};
364
+ for (const [k, v] of Object.entries(req.headers))
365
+ if (v !== undefined && !isStrippedClientHeader(k))
366
+ headers[k] = v;
367
+ // The lease key replaces any x-mnemom-api-key claude sent (stripped above).
368
+ headers["x-mnemom-api-key"] = key;
369
+ headers["x-mnemom-agent-id"] = cfg.agentId;
370
+ if (turn.kind === "chained")
371
+ headers[TURN_HEADER] = turn.value;
372
+ headers["content-length"] = String(body.length);
373
+ forward(pathname, url, headers, body, res, turn.note);
374
+ })
375
+ .catch((e) => {
376
+ if (!(e instanceof OverCap)) {
377
+ sendDrop(res, reqId());
378
+ return;
379
+ }
380
+ // Discard the rest of the upload and refuse locally; nothing goes upstream.
381
+ req.resume();
382
+ deps.log(`${pathname} refused request_too_large`);
383
+ res.shouldKeepAlive = false;
384
+ sendLocal(res, 413, "request_too_large", "mnemom seat proxy: request body too large");
385
+ });
386
+ };
387
+ function forward(pathname, url, headers, body, res, note) {
388
+ const target = new URL(cfg.door.origin + doorPath + url);
389
+ const started = Date.now();
390
+ const up = transport.request(target, { method: "POST", headers, agent }, (ur) => {
391
+ const status = ur.statusCode;
392
+ const verdict = armVerdict(pathname, ur.rawHeaders);
393
+ const deadCandidate = status === 401 || status === 409 || status === 410;
394
+ if (deadCandidate) {
395
+ void readAll(ur, deadBodyCap).then((buf) => {
396
+ const dead = deadKeyReason(status, buf.toString("utf8"));
397
+ if (dead)
398
+ state.dead = dead;
399
+ if (verdict.forward) {
400
+ res.writeHead(status, responseHeaders(ur.rawHeaders));
401
+ res.end(buf);
402
+ }
403
+ else {
404
+ deps.log(`${pathname} ${status} drop ${verdict.reason}`);
405
+ sendDrop(res, reqId());
406
+ }
407
+ if (dead) {
408
+ deps.log(`${dead} after ${status}`);
409
+ let exited = false;
410
+ const exitOnce = () => {
411
+ if (exited)
412
+ return;
413
+ exited = true;
414
+ deps.exit(EXIT_KEY_DEAD);
415
+ };
416
+ res.once("finish", exitOnce);
417
+ res.once("close", exitOnce);
418
+ }
419
+ }, (e) => {
420
+ if (e instanceof OverCap) {
421
+ // Never forward a truncated body (its content-length would lie): drop, no exit.
422
+ ur.destroy();
423
+ deps.log(`${pathname} ${status} drop dead_body_over_cap`);
424
+ }
425
+ sendDrop(res, reqId());
426
+ });
427
+ return;
428
+ }
429
+ if (!verdict.forward) {
430
+ ur.resume();
431
+ deps.log(`${pathname} ${status} drop ${verdict.reason}`);
432
+ sendDrop(res, reqId());
433
+ return;
434
+ }
435
+ res.writeHead(status, responseHeaders(ur.rawHeaders));
436
+ ur.pipe(res);
437
+ ur.on("end", () => deps.log(`${pathname} -> ${status} ${Date.now() - started}ms turn=${note}`));
438
+ // A cut upstream ends the client response too (Node signals it by close without complete).
439
+ ur.on("close", () => {
440
+ if (!ur.complete)
441
+ res.destroy();
442
+ });
443
+ });
444
+ up.on("error", () => {
445
+ deps.log(`${pathname} upstream_error`);
446
+ sendDrop(res, reqId());
447
+ });
448
+ res.on("close", () => {
449
+ if (!res.writableFinished)
450
+ up.destroy();
451
+ });
452
+ up.end(body);
453
+ }
454
+ }
455
+ export function defaultMainDeps() {
456
+ return {
457
+ env: process.env,
458
+ stdin: process.stdin,
459
+ stdout: (line) => void process.stdout.write(line),
460
+ stderr: (line) => void process.stderr.write(line),
461
+ exit: (code) => process.exit(code),
462
+ home: os.homedir(),
463
+ isDir: (p) => {
464
+ try {
465
+ return fs.statSync(p).isDirectory();
466
+ }
467
+ catch {
468
+ return false;
469
+ }
470
+ },
471
+ lock: (p) => tryLockFile(p),
472
+ relayFor: (p) => unixLineRelay(p),
473
+ };
474
+ }
475
+ function makeLog(env, stderr) {
476
+ const file = env.MNEMOM_DURABLE_PROXY_LOG || "";
477
+ return (line) => {
478
+ const s = `${new Date().toISOString()} seat-proxy ${String(line).replace(/[\r\n]+/g, " ")}\n`;
479
+ if (file) {
480
+ try {
481
+ fs.appendFileSync(file, s, { mode: 0o600 });
482
+ return;
483
+ }
484
+ catch {
485
+ /* fall through to stderr */
486
+ }
487
+ }
488
+ stderr(s);
489
+ };
490
+ }
491
+ /**
492
+ * The 'connect' listener for a seat proxy's loopback servers: a CONNECT (a tunnel request, never
493
+ * origin-form) is refused with a 400 and the socket closed. Without a listener Node would drop the
494
+ * socket silently; this makes the refusal explicit and logged.
495
+ */
496
+ export function refuseConnect(log) {
497
+ return (_req, socket) => {
498
+ log("CONNECT refused (not origin-form)");
499
+ socket.on("error", () => socket.destroy());
500
+ socket.end("HTTP/1.1 400 Bad Request\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
501
+ };
502
+ }
503
+ function listen(server, port, host) {
504
+ return new Promise((resolve, reject) => {
505
+ server.once("error", reject);
506
+ server.listen(port, host, () => {
507
+ server.removeListener("error", reject);
508
+ resolve(server.address().port);
509
+ });
510
+ });
511
+ }
512
+ /**
513
+ * `mnemom agent host seat-proxy …`: validate, read the key, take the conversation lock, listen,
514
+ * print READY. Resolves with the running proxy, or with an exit code it already passed to `exit`.
515
+ */
516
+ export async function seatProxyMain(argv, deps = defaultMainDeps()) {
517
+ const fail = (code, msg) => {
518
+ deps.stderr(`mnemom agent host seat-proxy: ${msg}\n`);
519
+ deps.exit(code);
520
+ return code;
521
+ };
522
+ const parsed = parseSeatProxyArgs(argv, deps.env, deps.isDir);
523
+ if (!parsed.ok)
524
+ return fail(EXIT_CONFIG, parsed.error);
525
+ const cfg = parsed.args;
526
+ const log = makeLog(deps.env, deps.stderr);
527
+ const key = await readKeyLine(deps.stdin, deps.keyTimeoutMs);
528
+ if (!isWellFormedLeaseKey(key))
529
+ return fail(EXIT_CONFIG, "no lease key on stdin (expected one mnm_ds_ line)");
530
+ const lock = deps.lock(proxyLockPath(deps.home, cfg.conversation));
531
+ if (!lock.ok) {
532
+ if (lock.reason === "held") {
533
+ log("lock held by another proxy for this conversation");
534
+ return fail(EXIT_LOCK_HELD, "the conversation lock is held by another proxy");
535
+ }
536
+ return fail(EXIT_CONFIG, "could not take the conversation lock");
537
+ }
538
+ const turns = new TurnOrigins({ configDir: claudeConfigDir(deps.env), cwd: cfg.cwd });
539
+ const state = { dead: null };
540
+ const handler = createSeatProxyHandler(cfg, key, { log, exit: deps.exit, turns }, state);
541
+ const onConnect = refuseConnect(log);
542
+ const v4 = http.createServer(handler).on("connect", onConnect);
543
+ let port;
544
+ try {
545
+ port = await listen(v4, 0, deps.hostV4 ?? "127.0.0.1");
546
+ }
547
+ catch {
548
+ return fail(EXIT_CONFIG, "could not listen on 127.0.0.1");
549
+ }
550
+ const v6 = http.createServer(handler).on("connect", onConnect);
551
+ let v6up = true;
552
+ await listen(v6, port, deps.hostV6 ?? "::1").catch(() => {
553
+ v6up = false; // no IPv6 loopback on this host: 127.0.0.1 alone
554
+ });
555
+ const socketPath = seatSocketPath(deps.home, cfg.conversation);
556
+ let sock;
557
+ try {
558
+ sock = await startSeatSocket({
559
+ socketPath,
560
+ seat: cfg.seat,
561
+ relay: cfg.helloRelay ? deps.relayFor(cfg.helloRelay) : null,
562
+ log,
563
+ });
564
+ }
565
+ catch (err) {
566
+ v4.close();
567
+ if (v6up)
568
+ v6.close();
569
+ try {
570
+ fs.closeSync(lock.fd); // release the conversation lock: nothing is listening for it now
571
+ }
572
+ catch {
573
+ /* already closed */
574
+ }
575
+ log(`seat socket failed (${err.message}); closed 127.0.0.1:${port}`);
576
+ return fail(EXIT_CONFIG, "could not open the seat socket");
577
+ }
578
+ log(`listening 127.0.0.1:${port}${v6up ? " [::1]" : ""} door=${cfg.door.origin}`);
579
+ deps.stdout(`READY ${port} ${socketPath}\n`);
580
+ return {
581
+ port,
582
+ socketPath,
583
+ close: async () => {
584
+ await Promise.all([
585
+ new Promise((r) => v4.close(() => r())),
586
+ v6up ? new Promise((r) => v6.close(() => r())) : Promise.resolve(),
587
+ new Promise((r) => sock.close(() => r())),
588
+ ]);
589
+ if (lock.ok) {
590
+ try {
591
+ fs.closeSync(lock.fd);
592
+ }
593
+ catch {
594
+ /* already closed */
595
+ }
596
+ }
597
+ },
598
+ };
599
+ }
@@ -0,0 +1,46 @@
1
+ import net from "node:net";
2
+ /** The one fixed refusal line (bytes before the newline). A local literal: S1 freezes no seat-socket refusal line yet (gap c3-seat-proxy-enums-not-in-s1). */
3
+ export declare const SEAT_SOCKET_REFUSAL = "{\"ok\":false,\"error\":\"seat_op_refused\"}";
4
+ /** Our own hello cannot reach the daemon (no relay configured, or the relay failed). */
5
+ export declare const SEAT_SOCKET_UNAVAILABLE = "{\"ok\":false,\"error\":\"seat_hello_unavailable\"}";
6
+ /** A frame (the bytes before the newline) longer than this many bytes is refused without parsing. */
7
+ export declare const SEAT_SOCKET_MAX_FRAME: number;
8
+ /** macOS sun_path is 104 bytes including the NUL. */
9
+ export declare const SOCKET_PATH_MAX = 103;
10
+ /** The seat this proxy serves: registered by the daemon at spawn (all three are non-secret ids). */
11
+ export interface SeatIdentity {
12
+ sessionId: string;
13
+ epoch: number;
14
+ spawnId: string;
15
+ }
16
+ export type HelloRelay = (frame: {
17
+ op: "hello";
18
+ session_id: string;
19
+ epoch: number;
20
+ spawn_id: string;
21
+ }) => Promise<string>;
22
+ /** Whether a parsed frame is the seat's own hello. Everything else is refused. */
23
+ export declare function isOwnHello(frame: unknown, seat: SeatIdentity | null): boolean;
24
+ /** The reply line (without newline) for one request line. Never throws. */
25
+ export declare function answerFrame(line: string, seat: SeatIdentity | null, relay: HelloRelay | null): Promise<{
26
+ reply: string;
27
+ outcome: "refused" | "relayed" | "unavailable";
28
+ }>;
29
+ /**
30
+ * The socket path for a conversation: under ~/.mnemom/durable/seats when it fits sun_path, else a
31
+ * short path under /tmp/mnm-seat-<uid>/ (a 0700 directory the seat uid owns), named by a hash of
32
+ * HOME and the conversation id.
33
+ */
34
+ export declare function seatSocketPath(home: string, conversationId: string, uid?: number): string;
35
+ /** Create (or check) the socket's directory: 0700 and owned by this uid. Throws when it is not. */
36
+ export declare function ensureSocketDir(dir: string, uid?: number): void;
37
+ /** Relay a hello to the daemon's control socket: one JSON line out, one line back. */
38
+ export declare function unixLineRelay(socketPath: string, timeoutMs?: number): HelloRelay;
39
+ export interface SeatSocketOptions {
40
+ socketPath: string;
41
+ seat: SeatIdentity | null;
42
+ relay: HelloRelay | null;
43
+ log: (line: string) => void;
44
+ }
45
+ /** Listen on the seat socket (file mode 0600). Resolves once listening. */
46
+ export declare function startSeatSocket(opts: SeatSocketOptions): Promise<net.Server>;