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/LICENSE +201 -0
- package/README.md +531 -0
- package/agent/agent.ts +68 -0
- package/agent/channels/eve.ts +73 -0
- package/agent/instructions.md +142 -0
- package/agent/sandbox/sandbox.ts +102 -0
- package/dist/dist-Bubu4ZZa.mjs +3 -0
- package/dist/dist-CrYVXFO2.mjs +12846 -0
- package/dist/dist-CrYVXFO2.mjs.map +1 -0
- package/dist/dist-DUuomISL.mjs +2221 -0
- package/dist/dist-DUuomISL.mjs.map +1 -0
- package/dist/memhtml-mcp.mjs +4077 -0
- package/dist/memhtml-mcp.mjs.map +1 -0
- package/dist/memhtml.mjs +5009 -0
- package/dist/memhtml.mjs.map +1 -0
- package/guest/corpus.mjs +193 -0
- package/migrations/.gitkeep +0 -0
- package/migrations/0001_files.sql +111 -0
- package/migrations/0002_chunks.sql +31 -0
- package/migrations/0003_fts.sql +40 -0
- package/migrations/0004_edges.sql +40 -0
- package/migrations/0005_traces.sql +92 -0
- package/migrations/0006_sleep.sql +33 -0
- package/migrations/0007_watermark.sql +32 -0
- package/migrations/0008_tasks.sql +214 -0
- package/migrations/0009_frame_key.sql +54 -0
- package/migrations/0010_trace_consolidations.sql +45 -0
- package/package.json +59 -0
- package/src/agent-build.ts +280 -0
- package/src/client.ts +1155 -0
- package/src/contract.ts +443 -0
- package/src/index.ts +23 -0
- package/src/mount.ts +279 -0
- package/src/run-auth.ts +231 -0
- package/state-migrations/S0001_access.sql +48 -0
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
|
+
}
|
package/src/run-auth.ts
ADDED
|
@@ -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
|
+
);
|