memhtml 0.1.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.
package/src/mount.ts ADDED
@@ -0,0 +1,279 @@
1
+ import { execFile } from "node:child_process"
2
+ import { mkdtempSync, statSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join, normalize } from "node:path"
5
+ import { promisify } from "node:util"
6
+ import type { IFileSystem } from "just-bash"
7
+ import { InMemoryFs, MountableFs, OverlayFs } from "just-bash"
8
+
9
+ /**
10
+ * The one composition that puts host directories inside a just-bash sandbox read-only.
11
+ *
12
+ * Two consumers need the SAME shape and it is built once here: this app's consolidator, which
13
+ * mounts the transcript root so the agent reads transcripts off a filesystem, and `memhtml exec`, which
14
+ * mounts the memory corpus so a sandboxed script can traverse it. The module lives in
15
+ * `apps/consolidator` because that is where `just-bash` is a real dependency, pinned to 3.2.0,
16
+ * the version eve 0.33.0 loads through its own optional-package path
17
+ * (node_modules/eve/dist/src/execution/sandbox/bindings/just-bash-runtime.js). `memhtml exec` imports it
18
+ * from `@memhtml/consolidator`.
19
+ *
20
+ * Every fact below was measured against just-bash 3.2.0 on 2026-08-09, re-probing the 2026-08
21
+ * spike's findings rather than citing them.
22
+ */
23
+
24
+ /**
25
+ * One host directory and where it appears in the guest.
26
+ *
27
+ * There is deliberately NO way to set the overlay's own `mountPoint` from here. See
28
+ * {@link mountReadOnlyRoots} for the measurement that makes that a footgun rather than an option.
29
+ */
30
+ export interface ReadOnlyRoot {
31
+ /** Absolute guest path, e.g. `/mnt/memhtml`. Never `/`, never nested inside another root's path. */
32
+ readonly mountPath: string
33
+ /** An existing directory on the host. Mounted read-only; nothing under it is ever written. */
34
+ readonly hostPath: string
35
+ }
36
+
37
+ /** The composed filesystem, plus the roots that reached it in mount order. */
38
+ export interface MountedFilesystem {
39
+ /** Hand this to `just-bash`'s `Bash`/`Sandbox`, or return it from eve's `filesystem` factory. */
40
+ readonly filesystem: IFileSystem
41
+ readonly roots: ReadonlyArray<ReadOnlyRoot>
42
+ }
43
+
44
+ /** A root declaration this composition cannot honour. Carries the reason, never a file's content. */
45
+ export class SandboxMountInvalid extends Error {
46
+ override readonly name = "SandboxMountInvalid"
47
+ }
48
+
49
+ /**
50
+ * Why a set of roots cannot be mounted, or `null`.
51
+ *
52
+ * Pure except for `statSync` on each host path, and separate from {@link mountReadOnlyRoots} for one
53
+ * reason: **eve does NOT invoke the `filesystem` factory during template prewarming**
54
+ * (node_modules/eve/dist/src/public/sandbox/just-bash-sandbox.d.ts, `filesystem`), so a bad root
55
+ * would otherwise surface on the first live session, inside a spawned server, wrapped by eve as
56
+ * "Failed to create the custom just-bash filesystem", after a sleep run already committed earlier
57
+ * phases. A caller that can name its roots before spawning calls this first and fails there.
58
+ *
59
+ * The rules, in the order a caller trips them:
60
+ *
61
+ * - `mountPath` must be absolute, already normalized, and free of a trailing slash.
62
+ * `MountableFs.mount` rejects `.`/`..` segments itself, but it silently normalizes a relative path,
63
+ * a doubled separator, and a trailing slash. The declared path and the effective mount would then
64
+ * differ, so a typo would mount somewhere other than where it reads. Note `/mnt/memhtml/`
65
+ * survives `path.normalize` unchanged (probed), so the trailing slash needs its own check.
66
+ * - `mountPath` may not be `/` and may not nest inside another root's path. `MountableFs` throws on
67
+ * both ("Cannot mount at root '/'", "Cannot mount at 'X': inside existing mount 'Y'", probed),
68
+ * which this restates as one typed reason naming both paths.
69
+ * - `hostPath` must be an existing DIRECTORY. `OverlayFs`'s constructor does check this eagerly
70
+ * ("OverlayFs root does not exist" / "is not a directory", probed), which is the one gotcha that
71
+ * was already handled upstream; it is repeated here so one call answers for every root instead of
72
+ * throwing on the first bad one with no mention of the mount it belongs to.
73
+ */
74
+ export const readOnlyRootsProblem = (roots: ReadonlyArray<ReadOnlyRoot>): string | null => {
75
+ const claimed: string[] = []
76
+ for (const root of roots) {
77
+ const { mountPath, hostPath } = root
78
+ if (mountPath === "/") {
79
+ return 'mount path "/" is not mountable: the base filesystem owns the root'
80
+ }
81
+ if (
82
+ !mountPath.startsWith("/") ||
83
+ mountPath.endsWith("/") ||
84
+ normalize(mountPath) !== mountPath
85
+ ) {
86
+ return `mount path ${JSON.stringify(mountPath)} must be an absolute, normalized guest path`
87
+ }
88
+ for (const taken of claimed) {
89
+ if (taken === mountPath) return `mount path ${mountPath} is declared twice`
90
+ if (mountPath.startsWith(`${taken}/`) || taken.startsWith(`${mountPath}/`)) {
91
+ return `mount paths ${taken} and ${mountPath} nest, which MountableFs refuses`
92
+ }
93
+ }
94
+ claimed.push(mountPath)
95
+
96
+ let stats: ReturnType<typeof statSync>
97
+ try {
98
+ stats = statSync(hostPath)
99
+ } catch (cause) {
100
+ return `host path ${hostPath} for mount ${mountPath} is unreadable: ${String(cause)}`
101
+ }
102
+ if (!stats.isDirectory()) {
103
+ return `host path ${hostPath} for mount ${mountPath} is not a directory`
104
+ }
105
+ }
106
+ return null
107
+ }
108
+
109
+ /**
110
+ * Compose a filesystem with each host root mounted read-only at its guest path.
111
+ *
112
+ * ## `mountPoint: "/"` on the nested overlay decides which paths resolve, and a file count cannot say
113
+ *
114
+ * `MountableFs` routes a path to a mount by stripping the mount prefix and handing the REMAINDER to
115
+ * the mounted filesystem (`routePath` in just-bash's bundle), while `OverlayFs` applies its own
116
+ * `mountPoint`, default `/home/user/project`, to whatever it is handed. So the two prefixes
117
+ * compose, and all three spellings resolve a real file at a DIFFERENT path. Re-probed 2026-08-09
118
+ * against a two-file fixture, mounting at `/mnt/memhtml`:
119
+ *
120
+ * | overlay `mountPoint` | path that reads the file |
121
+ * | --- | --- |
122
+ * | `"/"` | `/mnt/memhtml/sub/a.txt` (intended) |
123
+ * | omitted | `/mnt/memhtml/home/user/project/sub/a.txt` |
124
+ * | `"/mnt/memhtml"` | `/mnt/memhtml/mnt/memhtml/sub/a.txt` |
125
+ *
126
+ * **Every variant reports the same file count**, so a census assertion cannot tell them apart; only
127
+ * reading a path does. That is why `mountPoint` is not on {@link ReadOnlyRoot} at all. The option
128
+ * has exactly one correct value under a `MountableFs`, and offering it would be offering two ways to
129
+ * get a filesystem that looks populated and answers no path a caller would write.
130
+ *
131
+ * ## What read-only means here, measured rather than assumed
132
+ *
133
+ * `readOnly: true` is enforced rather than advisory: a write through the composed filesystem throws
134
+ * `EROFS: read-only file system`, and through `Bash` the command throws the same. `..` traversal out
135
+ * of a mount and an absolute `/etc/hostname` both fail, because the overlay resolves a guest path
136
+ * against its own root and returns nothing outside it. And `allowSymlinks` defaults to FALSE, so a
137
+ * symlink under a mounted root is not followed: any real path traversing one is rejected. That is the
138
+ * safe direction, and it costs reachability. `~/.claude/skills/*` holds symlinks to directories
139
+ * outside the trace root, and those read as absent inside the sandbox.
140
+ *
141
+ * ## The base filesystem stays writable
142
+ *
143
+ * `base` is whatever the caller already owns; every unmounted path routes to it. For eve that is
144
+ * `defaultFilesystem` from the `filesystem` factory, which owns `/workspace`, `/tmp`, and the home
145
+ * directory. eve's contract requires those to survive
146
+ * (node_modules/eve/dist/src/public/sandbox/just-bash-sandbox.d.ts) and mounting only under `/mnt/*`
147
+ * is what preserves them. The default is an `InMemoryFs`, which is what a standalone caller wants
148
+ * and what `MountableFs` would have defaulted to anyway.
149
+ *
150
+ * @throws {SandboxMountInvalid} when {@link readOnlyRootsProblem} rejects the roots.
151
+ */
152
+ export const mountReadOnlyRoots = (input: {
153
+ readonly roots: ReadonlyArray<ReadOnlyRoot>
154
+ readonly base?: IFileSystem | undefined
155
+ }): MountedFilesystem => {
156
+ const problem = readOnlyRootsProblem(input.roots)
157
+ if (problem !== null) throw new SandboxMountInvalid(problem)
158
+
159
+ const filesystem = new MountableFs({ base: input.base ?? new InMemoryFs() })
160
+ for (const root of input.roots) {
161
+ filesystem.mount(
162
+ root.mountPath,
163
+ new OverlayFs({ root: root.hostPath, mountPoint: "/", readOnly: true })
164
+ )
165
+ }
166
+ return { filesystem, roots: [...input.roots] }
167
+ }
168
+
169
+ /**
170
+ * The variable a spawning process uses to tell a sandbox process what to mount.
171
+ *
172
+ * The `filesystem` factory runs inside the eve SERVER, and the roots are decided by the CLIENT that
173
+ * spawned it. Those are two processes, so the roots have to cross a process boundary, and the spawn
174
+ * environment is the only channel eve's CLI leaves open. One variable rather than one per root, so
175
+ * the order and the pairing survive: a `MEMHTML_SANDBOX_TRACE_ROOT`-style set of variables cannot express
176
+ * "these three, in this order" and would need a new variable per consumer.
177
+ */
178
+ export const SANDBOX_MOUNTS_ENV = "MEMHTML_SANDBOX_MOUNTS"
179
+
180
+ /** Render roots for {@link SANDBOX_MOUNTS_ENV}. Validated first, so a spawn cannot carry a bad root. */
181
+ export const encodeSandboxMounts = (roots: ReadonlyArray<ReadOnlyRoot>): string => {
182
+ const problem = readOnlyRootsProblem(roots)
183
+ if (problem !== null) throw new SandboxMountInvalid(problem)
184
+ return JSON.stringify(
185
+ roots.map((root) => ({ mountPath: root.mountPath, hostPath: root.hostPath }))
186
+ )
187
+ }
188
+
189
+ /**
190
+ * Read roots back out of an environment. An absent or empty variable means no mounts, not an error.
191
+ *
192
+ * A MALFORMED variable throws, and the two cases are split for a reason: absent is the normal case
193
+ * for a sandbox with nothing to mount, while a variable that is present and unparseable means the
194
+ * spawner meant to mount something and this process would silently run without it. A sandbox that
195
+ * quietly lost its corpus answers questions about an empty corpus, which reads as a finding.
196
+ *
197
+ * @throws {SandboxMountInvalid} when the value is present and not a valid root array.
198
+ */
199
+ export const decodeSandboxMounts = (
200
+ env: Record<string, string | undefined>
201
+ ): ReadonlyArray<ReadOnlyRoot> => {
202
+ const raw = env[SANDBOX_MOUNTS_ENV]
203
+ if (raw === undefined || raw.trim() === "") return []
204
+
205
+ let parsed: unknown
206
+ try {
207
+ parsed = JSON.parse(raw)
208
+ } catch (cause) {
209
+ throw new SandboxMountInvalid(`${SANDBOX_MOUNTS_ENV} is not valid JSON: ${String(cause)}`)
210
+ }
211
+ if (!Array.isArray(parsed)) {
212
+ throw new SandboxMountInvalid(`${SANDBOX_MOUNTS_ENV} must hold an array of roots`)
213
+ }
214
+
215
+ const roots: ReadOnlyRoot[] = []
216
+ for (const entry of parsed) {
217
+ if (typeof entry !== "object" || entry === null) {
218
+ throw new SandboxMountInvalid(`${SANDBOX_MOUNTS_ENV} holds a non-object entry`)
219
+ }
220
+ const { mountPath, hostPath } = entry as Record<string, unknown>
221
+ if (typeof mountPath !== "string" || typeof hostPath !== "string") {
222
+ throw new SandboxMountInvalid(
223
+ `${SANDBOX_MOUNTS_ENV} entries need string mountPath and hostPath`
224
+ )
225
+ }
226
+ roots.push({ mountPath, hostPath })
227
+ }
228
+
229
+ const problem = readOnlyRootsProblem(roots)
230
+ if (problem !== null) throw new SandboxMountInvalid(`${SANDBOX_MOUNTS_ENV}: ${problem}`)
231
+ return roots
232
+ }
233
+
234
+ /** A materialized commit, and how to remove it. */
235
+ export interface CorpusSnapshot {
236
+ /** The detached worktree's directory, suitable as a {@link ReadOnlyRoot} `hostPath`. */
237
+ readonly hostPath: string
238
+ /** Removes the worktree and its administrative entry. Safe to call twice. */
239
+ readonly release: () => Promise<void>
240
+ }
241
+
242
+ const run = promisify(execFile)
243
+
244
+ /**
245
+ * Materialize one commit of a repository as a directory, for mounting.
246
+ *
247
+ * **A sleep run's live working tree is not a snapshot of anything.** `packages/sleep/src/run.ts:96`
248
+ * checks out the run's own branch before any phase executes, and earlier phases commit onto it, so
249
+ * the directory a later phase would mount mutates underneath it. A consolidation that read the
250
+ * corpus "as it is" would be reading a corpus its own siblings edited seconds earlier, and would
251
+ * report a state no reviewer can reproduce. `git worktree add --detach` at the run's `baseSha` is
252
+ * the tree the reviewer diffs against, which makes "what the agent saw" and "what the review shows"
253
+ * the same tree by construction rather than by timing.
254
+ *
255
+ * `--detach` and not a branch: a named branch would be a second ref on a sha the run already tracks,
256
+ * and `git worktree remove` of a branch-carrying worktree leaves the branch behind.
257
+ */
258
+ export const pinCorpusSnapshot = async (input: {
259
+ readonly repoRoot: string
260
+ readonly sha: string
261
+ }): Promise<CorpusSnapshot> => {
262
+ const parent = mkdtempSync(join(tmpdir(), "memhtml-corpus-snapshot-"))
263
+ const hostPath = join(parent, "tree")
264
+ await run("git", ["-C", input.repoRoot, "worktree", "add", "--detach", hostPath, input.sha])
265
+
266
+ let released = false
267
+ return {
268
+ hostPath,
269
+ release: async () => {
270
+ if (released) return
271
+ released = true
272
+ // `--force` because the mount is read-only but the worktree is a real directory a reader may
273
+ // have left something in; a refusal here would leak a worktree entry into the repo's config.
274
+ await run("git", ["-C", input.repoRoot, "worktree", "remove", "--force", hostPath]).catch(
275
+ () => {}
276
+ )
277
+ }
278
+ }
279
+ }
@@ -0,0 +1,231 @@
1
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"
2
+
3
+ /**
4
+ * The per-run credential the agent server demands and the client presents.
5
+ *
6
+ * ## What this replaces, and why the composition was worse than either half
7
+ *
8
+ * `agent/channels/eve.ts` used to authenticate every caller anonymously with `none()`, and the only
9
+ * thing keeping the agent off the network was the bind address. Loopback is not an authorization
10
+ * boundary on a shared host: any local UID could drive the session endpoint for a run's duration,
11
+ * which is free Opus tokens plus a bash sandbox. That alone was rated MEDIUM (CWE-306).
12
+ *
13
+ * The sandbox half is what makes it more than that. The sandbox has FULL network egress and this app
14
+ * cannot turn it off: `network:{dangerouslyAllowFullInternetAccess:!0}` is a hardcoded literal in
15
+ * node_modules/eve/dist/src/execution/sandbox/bindings/just-bash-runtime.js, and
16
+ * `justBashSetNetworkPolicyUnsupported()` throws by design. Measured 2026-08-09
17
+ * (`node scripts/probe-sandbox-egress.mjs`): `curl` reaches example.com, an IMDSv2 token PUT returns
18
+ * 56 bytes, and the instance-role name comes back. So the unauthenticated endpoint was a handle on a
19
+ * sandbox that reaches IMDS. `agent/sandbox/sandbox.ts` records that egress cannot be closed here;
20
+ * this module closes the handle.
21
+ *
22
+ * ## The mechanism
23
+ *
24
+ * One HS256 bearer JWT over a secret this process mints per spawn from `randomBytes`, verified by
25
+ * eve's own `jwtHmac` strategy (node_modules/eve/dist/src/public/channels/auth.d.ts:451, config shape
26
+ * at :41-60). The secret crosses to the server on the SPAWN ENVIRONMENT, which is the same channel
27
+ * `mount.ts` uses for mount roots and for the same reason: the auth policy is evaluated in the eve
28
+ * SERVER process while the value is decided by the CLIENT that spawns it.
29
+ *
30
+ * **No eve import here.** `VerifyJwtHmacConfig` is a plain interface, so {@link RunVerifierConfig}
31
+ * restates it structurally, the same move `contract.ts` makes for `JsonObject`. That keeps
32
+ * eve out of `src/`'s import graph so the test tier stays server-free. TypeScript is structural, so
33
+ * the value {@link runVerifierConfig} returns is assignable to `jwtHmac`'s parameter with no cast.
34
+ *
35
+ * Every claim and bound below was verified against the installed eve 0.33.0 by driving
36
+ * `verifyJwtHmac` directly (2026-08-14): a token from {@link signRunToken} verifies as
37
+ * `principalType: "service"`, and `null`, a non-JWT string, a token signed with a different secret,
38
+ * an expired token, one with no `sub`, one with a foreign `sub`, and one with a foreign `aud` each
39
+ * return `{ ok: false }`. `tests/run-auth.test.ts` is that probe kept as a test.
40
+ */
41
+
42
+ /**
43
+ * The variable a spawning client uses to hand the server the run's secret.
44
+ *
45
+ * Named for its LIFETIME rather than its content, because the lifetime is the security property: one
46
+ * spawn, one secret. A value that survived a run, such as a fixed default, a config key, or anything
47
+ * a caller could supply, would reopen the window this closes, since the window is exactly "how long
48
+ * is a credential that reaches this endpoint good for".
49
+ */
50
+ export const RUN_SECRET_ENV = "MEMHTML_CONSOLIDATOR_RUN_SECRET"
51
+
52
+ /**
53
+ * How many random bytes a run secret carries. 32 = 256 bits, matching HS256's hash output.
54
+ *
55
+ * RFC 7518 §3.2 requires an HMAC key at least the size of the hash output, and eve keys the verifier
56
+ * with `createSecretKey(Buffer.from(secret, "utf8"))`
57
+ * (node_modules/eve/dist/src/runtime/governance/auth/jwt-hmac.js), so the KEY MATERIAL is the
58
+ * base64url text, 43 bytes, carrying these 32 bytes of entropy. Both the byte count and the encoded
59
+ * length clear the floor.
60
+ *
61
+ * The floor is not enforced anywhere else. Probed against the installed eve: a three-character secret
62
+ * verifies its own token happily, because jose does not check HS key width on verify. So
63
+ * {@link runSecretFrom} enforces it, or a hand-set variable would be a password.
64
+ */
65
+ const SECRET_BYTES = 32
66
+
67
+ /**
68
+ * The minimum length a secret read from the environment may have, in characters.
69
+ *
70
+ * `base64url(32 bytes)` is exactly 43 unpadded characters, so this is the length {@link mintRunSecret}
71
+ * produces rather than a number picked to be round. A shorter value is REFUSED rather than accepted
72
+ * with a warning: an under-width HMAC key is the one failure mode eve's verifier will not catch.
73
+ */
74
+ const MIN_SECRET_CHARS = 43
75
+
76
+ /** The signature algorithm, on both sides, as one constant so they cannot drift apart. */
77
+ const ALGORITHM = "HS256" as const
78
+
79
+ /** The `node:crypto` hash name `HS256` denotes. Paired with {@link ALGORITHM} and never separately. */
80
+ const HMAC_HASH = "sha256"
81
+
82
+ /**
83
+ * `iss`, `aud`, and `sub`, all three matched by the verifier.
84
+ *
85
+ * Redundant with the signature and deliberately so: a secret that leaked into some other eve app's
86
+ * environment still mints nothing this channel accepts, because `subjects` and `audiences` are
87
+ * checked after the signature (`areTokenClaimMatchersSatisfied` in
88
+ * node_modules/eve/dist/src/runtime/governance/auth/token-claims.js). They cost one string compare
89
+ * each and they make a misconfiguration fail closed instead of cross-authenticating.
90
+ *
91
+ * `sub` is REQUIRED by eve independently of `subjects`: the strategy rejects a token whose `sub` is
92
+ * absent or empty before it looks at any matcher (jwt-hmac.js, verified live).
93
+ */
94
+ const ISSUER = "memhtml-consolidator"
95
+ const AUDIENCE = "memhtml-consolidator/eve"
96
+ const SUBJECT = "memhtml-consolidator-client"
97
+
98
+ /**
99
+ * How long one token is good for. Seconds.
100
+ *
101
+ * Short because it does not have to cover the run: the client passes the FUNCTION form of eve's
102
+ * `TokenValue`, which resolves before every HTTP call
103
+ * (node_modules/eve/dist/src/client/types.d.ts:49-69), so a 10-minute turn presents a fresh token on
104
+ * every request rather than one token held open for the turn. That decouples the credential's
105
+ * lifetime from `TURN_TIMEOUT_MS` entirely: a stream reconnect ten minutes in signs a new token.
106
+ *
107
+ * 120s rather than something tighter because the bound that matters is the SERVER's lifetime (one
108
+ * run), and a token has to survive being minted before a request that then queues behind a model
109
+ * call's connection setup.
110
+ */
111
+ const TOKEN_TTL_SECONDS = 120
112
+
113
+ /**
114
+ * Clock skew the verifier tolerates, in seconds. eve defaults to 30.
115
+ *
116
+ * 5 because there is no skew to tolerate: the signer and the verifier are two processes on ONE host
117
+ * reading one clock, so the 30s default is budget for a distributed issuer this deployment does not
118
+ * have. It is the difference between a token being good for 125s and 150s.
119
+ */
120
+ const CLOCK_SKEW_SECONDS = 5
121
+
122
+ /**
123
+ * eve's `VerifyJwtHmacConfig`, restated structurally.
124
+ *
125
+ * Field for field with node_modules/eve/dist/src/public/channels/auth.d.ts:41-60, minus the two
126
+ * optional matchers this app does not use. Declared rather than imported to keep `src/` free of eve.
127
+ * See the note at the top of this module.
128
+ */
129
+ export interface RunVerifierConfig {
130
+ readonly algorithm: "HS256" | "HS384" | "HS512"
131
+ readonly audiences: readonly string[]
132
+ readonly issuer: string
133
+ readonly secret: string
134
+ readonly clockSkewSeconds: number
135
+ readonly subjects: readonly string[]
136
+ }
137
+
138
+ /**
139
+ * A fresh secret for one spawn.
140
+ *
141
+ * `randomBytes` and not `randomUUID`: a UUIDv4 carries 122 bits in a fixed 36-character shape, which
142
+ * is under the HS256 key floor {@link SECRET_BYTES} exists to clear. base64url so the value is safe
143
+ * in an environment variable with no quoting question, since `+`, `/`, and `=` are all avoided.
144
+ */
145
+ export const mintRunSecret = (): string => randomBytes(SECRET_BYTES).toString("base64url")
146
+
147
+ /**
148
+ * The run's secret as read from an environment, or `null` when there is no usable one.
149
+ *
150
+ * `null` is the FAIL-CLOSED signal and the callers on both sides treat it that way: the channel turns
151
+ * it into a 401 by handing `routeAuth` a walk with nothing that can accept. Absent, blank, and
152
+ * under-width all collapse to `null` on purpose, since the caller's move is the same for each
153
+ * (refuse), and distinguishing them in a return value would invite a caller to accept one of them.
154
+ *
155
+ * The value is the credential, so it is not logged and not returned in a message.
156
+ */
157
+ export const runSecretFrom = (env: Record<string, string | undefined>): string | null => {
158
+ const raw = env[RUN_SECRET_ENV]
159
+ if (raw === undefined) return null
160
+ const secret = raw.trim()
161
+ if (secret.length < MIN_SECRET_CHARS) return null
162
+ return secret
163
+ }
164
+
165
+ /**
166
+ * The verifier configuration for an environment, or `null` when it holds no usable secret.
167
+ *
168
+ * Both sides of the boundary read their claims from the constants above through this one function and
169
+ * {@link signRunToken}, so a mismatch between what is signed and what is accepted is not expressible.
170
+ * That matters because every claim mismatch fails the same silent way, as `{ ok: false }` with no
171
+ * detail (eve returns no reason so routes cannot leak which check failed, auth.d.ts:9-19).
172
+ */
173
+ export const runVerifierConfig = (
174
+ env: Record<string, string | undefined>
175
+ ): RunVerifierConfig | null => {
176
+ const secret = runSecretFrom(env)
177
+ if (secret === null) return null
178
+ return {
179
+ algorithm: ALGORITHM,
180
+ audiences: [AUDIENCE],
181
+ issuer: ISSUER,
182
+ secret,
183
+ clockSkewSeconds: CLOCK_SKEW_SECONDS,
184
+ subjects: [SUBJECT]
185
+ }
186
+ }
187
+
188
+ /** base64url of a JSON value, which is the encoding both JWT segments use. */
189
+ const segment = (value: unknown): string =>
190
+ Buffer.from(JSON.stringify(value), "utf8").toString("base64url")
191
+
192
+ /**
193
+ * Sign one short-lived bearer token for the run.
194
+ *
195
+ * Hand-rolled over `node:crypto` because eve exports NO signer: `jwtHmac`, `verifyJwtHmac`, and the
196
+ * jose bundle behind them are verify-only on the public surface (checked across every subpath export
197
+ * of eve 0.33.0, 46 of them), so the alternative to these six lines is a new dependency for one HMAC. The claims
198
+ * are the ones {@link runVerifierConfig} matches, which is the whole correctness condition and the
199
+ * reason both live in this module.
200
+ *
201
+ * `exp` is derived from the call, not from the spawn, so each call produces a token valid
202
+ * {@link TOKEN_TTL_SECONDS} from now, which is what makes the per-request function form work.
203
+ */
204
+ export const signRunToken = (input: { readonly secret: string }): string => {
205
+ const now = Math.floor(Date.now() / 1_000)
206
+ const head = segment({ alg: ALGORITHM, typ: "JWT" })
207
+ const body = segment({
208
+ iss: ISSUER,
209
+ aud: AUDIENCE,
210
+ sub: SUBJECT,
211
+ iat: now,
212
+ exp: now + TOKEN_TTL_SECONDS
213
+ })
214
+ const signature = createHmac(HMAC_HASH, Buffer.from(input.secret, "utf8"))
215
+ .update(`${head}.${body}`)
216
+ .digest("base64url")
217
+ return `${head}.${body}.${signature}`
218
+ }
219
+
220
+ /**
221
+ * Whether two secrets are the same value, compared in constant time.
222
+ *
223
+ * For a test that has to assert the secret the client minted is the secret the spawn carried without
224
+ * ever reading either one. `timingSafeEqual` throws on a length mismatch, so that case is answered
225
+ * before the compare rather than by catching.
226
+ */
227
+ export const sameRunSecret = (left: string, right: string): boolean => {
228
+ const a = Buffer.from(left, "utf8")
229
+ const b = Buffer.from(right, "utf8")
230
+ return a.length === b.length && timingSafeEqual(a, b)
231
+ }
@@ -0,0 +1,48 @@
1
+ -- The state plane, applied to the ATTACHed `state` database.
2
+ --
3
+ -- These are the only facts git cannot reproduce, and they are high-churn: bumping an access count
4
+ -- on every retrieval would produce a commit per query. So `state.db` is gitignored and its
5
+ -- durability story is the committed append-only sidecar `.memhtml/state/access.jsonl`, refreshed once
6
+ -- per night by the sleep cycle's state-export phase. A fresh clone plus `memhtml state import` plus
7
+ -- `memhtml index rebuild` therefore reproduces the whole system, access history included.
8
+ --
9
+ -- Rejected alternative: keeping these counters in the memory HTML files. It makes every retrieval a
10
+ -- git write, makes retrieval order a source of merge conflicts, and breaks the content-hash
11
+ -- invariance that lets a correction land on one entry alone.
12
+
13
+ CREATE TABLE state.access (
14
+ -- No FK to main.files: cross-database foreign keys do not exist. The store's move() issues the
15
+ -- matching `UPDATE state.access SET path = ?` in the same batch as an archive, and `memhtml doctor`
16
+ -- reports orphaned rows.
17
+ path TEXT PRIMARY KEY,
18
+ -- Times this memory was retrieved, gated by the 900-second cooldown. A count, monotonically
19
+ -- non-decreasing. The cooldown exists because this feeds the salience RRF arm: without it,
20
+ -- replaying one query ten times would inflate that memory's salience tenfold.
21
+ access_count INTEGER NOT NULL DEFAULT 0 CHECK (access_count >= 0),
22
+ reinforcement_count INTEGER NOT NULL DEFAULT 0 CHECK (reinforcement_count >= 0),
23
+ -- An EWMA over reinforcement signals, unitless in [-1, 1]. The salience arm clamps the negative
24
+ -- half to 0. A memory that led somewhere bad is not boosted, and it is not buried either.
25
+ outcome_score REAL NOT NULL DEFAULT 0.0 CHECK (outcome_score BETWEEN -1 AND 1),
26
+ last_accessed_at TEXT,
27
+ last_reinforced_at TEXT,
28
+ updated_at TEXT NOT NULL
29
+ );
30
+ -- The schema name goes on the INDEX, not on the table: `CREATE INDEX x ON state.access (...)` is a
31
+ -- syntax error on this driver (probed 2026-08-02), while `CREATE INDEX state.x ON access (...)` is
32
+ -- accepted and lands the index in the attached schema. Unqualified `access` resolves within it.
33
+ CREATE INDEX state.access_last ON access (last_accessed_at);
34
+
35
+ -- The corroboration counter on a machine-detected `contradicts`. The one derived fact that gates a
36
+ -- retention penalty, so it lives in the durable plane; once `detections >= 2` the sleep conflict
37
+ -- phase promotes the edge into both files as <link rel="memhtml-contradicts"> and commits it, after
38
+ -- which this row is decoration and the fact is file-borne.
39
+ CREATE TABLE state.edge_corroboration (
40
+ src_path TEXT NOT NULL,
41
+ rel TEXT NOT NULL,
42
+ dst_path TEXT NOT NULL,
43
+ detections INTEGER NOT NULL DEFAULT 1 CHECK (detections >= 1),
44
+ confirmed INTEGER NOT NULL DEFAULT 0 CHECK (confirmed IN (0,1)),
45
+ promoted INTEGER NOT NULL DEFAULT 0 CHECK (promoted IN (0,1)),
46
+ updated_at TEXT NOT NULL,
47
+ PRIMARY KEY (src_path, rel, dst_path)
48
+ );