agentbox-app 0.1.6
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 +674 -0
- package/README.md +176 -0
- package/bin/agentbox.mjs +103 -0
- package/briefs/adhd-mode.md +5 -0
- package/briefs/message-rules.md +95 -0
- package/briefs/worker.md +230 -0
- package/briefs/writing-rules.md +336 -0
- package/main/account-discovery.mjs +145 -0
- package/main/account-tooling.mjs +261 -0
- package/main/agent-activity.mjs +56 -0
- package/main/agent-files.mjs +422 -0
- package/main/agent-schedule.mjs +254 -0
- package/main/agent-sessions.mjs +435 -0
- package/main/agents.mjs +1095 -0
- package/main/analytics.mjs +206 -0
- package/main/answer-modes.mjs +206 -0
- package/main/approval-prompt-server.mjs +160 -0
- package/main/approvals.mjs +487 -0
- package/main/artifact-path.mjs +144 -0
- package/main/claude-bin.mjs +311 -0
- package/main/claude-input.mjs +117 -0
- package/main/claude-models.mjs +244 -0
- package/main/claude-plan.mjs +148 -0
- package/main/claude-usage.mjs +149 -0
- package/main/code-change.mjs +383 -0
- package/main/code-file.mjs +116 -0
- package/main/codex-account.mjs +146 -0
- package/main/codex-app-server.mjs +796 -0
- package/main/codex-approvals.mjs +712 -0
- package/main/codex-bin.mjs +261 -0
- package/main/codex-compaction.mjs +49 -0
- package/main/codex-models.mjs +194 -0
- package/main/codex-session.mjs +797 -0
- package/main/codex-threads.mjs +268 -0
- package/main/codex-usage-file.mjs +192 -0
- package/main/codex-usage.mjs +149 -0
- package/main/codex-watch.mjs +35 -0
- package/main/codex.mjs +757 -0
- package/main/config.mjs +345 -0
- package/main/copy-selection.mjs +55 -0
- package/main/crash-report.mjs +158 -0
- package/main/demo.mjs +254 -0
- package/main/dev-window.mjs +103 -0
- package/main/doc-file.mjs +99 -0
- package/main/doc-scheme.mjs +126 -0
- package/main/first-run.mjs +190 -0
- package/main/folders.mjs +91 -0
- package/main/fresh-user.mjs +120 -0
- package/main/git-change.mjs +323 -0
- package/main/img-scheme.mjs +90 -0
- package/main/instruction-settings.mjs +205 -0
- package/main/ipc.mjs +1558 -0
- package/main/live-replies.mjs +77 -0
- package/main/machine.mjs +113 -0
- package/main/main.mjs +972 -0
- package/main/menu.mjs +177 -0
- package/main/notify.mjs +155 -0
- package/main/open-source-file.mjs +8 -0
- package/main/project-identity.mjs +149 -0
- package/main/provider-commands.mjs +32 -0
- package/main/rail-note.mjs +74 -0
- package/main/repeats.mjs +261 -0
- package/main/row-label.mjs +185 -0
- package/main/screen-detail.mjs +44 -0
- package/main/serve.mjs +262 -0
- package/main/settings.mjs +912 -0
- package/main/staleness.mjs +76 -0
- package/main/store/chats.mjs +188 -0
- package/main/store/home.mjs +383 -0
- package/main/store/migrate.mjs +40 -0
- package/main/store/paths.mjs +82 -0
- package/main/store/pending-writes.mjs +122 -0
- package/main/store/project-lock.mjs +200 -0
- package/main/store/project.mjs +523 -0
- package/main/store/queues.mjs +31 -0
- package/main/store/work-items.mjs +299 -0
- package/main/store/write-queue.mjs +14 -0
- package/main/store-modules.mjs +23 -0
- package/main/store.mjs +1269 -0
- package/main/supervisor.mjs +6875 -0
- package/main/task-commands.mjs +161 -0
- package/main/task-folders-offthread.mjs +84 -0
- package/main/task-folders-thread.mjs +13 -0
- package/main/task-folders.mjs +409 -0
- package/main/task-remote-control.mjs +65 -0
- package/main/task-terminals.mjs +169 -0
- package/main/terminal-access.mjs +6 -0
- package/main/updater.mjs +240 -0
- package/main/zoom-keys.mjs +54 -0
- package/main/zoom-readout.mjs +20 -0
- package/mcp/agentbox-mcp.sh +34 -0
- package/mcp/core/account.mjs +77 -0
- package/mcp/core/chat.mjs +61 -0
- package/mcp/core/documents.mjs +238 -0
- package/mcp/core/page.mjs +152 -0
- package/mcp/core/products.mjs +110 -0
- package/mcp/core/skills.mjs +126 -0
- package/mcp/core/work.mjs +377 -0
- package/mcp/stdio.mjs +70 -0
- package/mcp/tools.mjs +257 -0
- package/package.json +186 -0
- package/renderer/dist/assets/Geist-Variable-Bj2R_7yk.woff2 +0 -0
- package/renderer/dist/assets/GeistMono-Variable-Dispecij.woff2 +0 -0
- package/renderer/dist/assets/addon-fit-YJmn1quW.js +12 -0
- package/renderer/dist/assets/addon-serialize-CsFQTvP_.js +13 -0
- package/renderer/dist/assets/cel-dusk-CS5I7kJS.webp +0 -0
- package/renderer/dist/assets/cel-dusk-DUslqS6B.webp +0 -0
- package/renderer/dist/assets/cel-dusk-Jt_xVGxp.webp +0 -0
- package/renderer/dist/assets/cel-dusk-sm-BBkV-tWT.webp +0 -0
- package/renderer/dist/assets/frost-haze-CxPSOBEg.webp +0 -0
- package/renderer/dist/assets/frost-haze-sm-C_w21qgd.webp +0 -0
- package/renderer/dist/assets/gouache-valley-ChSu14-t.webp +0 -0
- package/renderer/dist/assets/gouache-valley-d9qasI6k.webp +0 -0
- package/renderer/dist/assets/gouache-valley-sm-CdLjT5Ec.webp +0 -0
- package/renderer/dist/assets/ice-BC8luINc.webp +0 -0
- package/renderer/dist/assets/ice-CP9PrKHo.webp +0 -0
- package/renderer/dist/assets/ice-river-D6ziOhLY.webp +0 -0
- package/renderer/dist/assets/ice-river-UVM2-JY_.webp +0 -0
- package/renderer/dist/assets/ice-river-bErJwYWW.webp +0 -0
- package/renderer/dist/assets/ice-river-sm-Bg1U-SIj.webp +0 -0
- package/renderer/dist/assets/ice-sm-CbVcg3_D.webp +0 -0
- package/renderer/dist/assets/index-CwiDv5bL.css +32 -0
- package/renderer/dist/assets/index-D9hCAVPl.js +684 -0
- package/renderer/dist/assets/lake-C0WaW-6E.webp +0 -0
- package/renderer/dist/assets/lake-CSKjLC5K.webp +0 -0
- package/renderer/dist/assets/lake-ETGcOb25.webp +0 -0
- package/renderer/dist/assets/lake-harbour-BCQRbrHb.webp +0 -0
- package/renderer/dist/assets/lake-harbour-B_wfXTmE.webp +0 -0
- package/renderer/dist/assets/lake-harbour-Te1pEvBh.webp +0 -0
- package/renderer/dist/assets/lake-harbour-sm-Cme_ShEg.webp +0 -0
- package/renderer/dist/assets/lake-sm-CbG72Hbv.webp +0 -0
- package/renderer/dist/assets/lino-coast-CUZHcucI.webp +0 -0
- package/renderer/dist/assets/lino-coast-DP4J7cgd.webp +0 -0
- package/renderer/dist/assets/lino-coast-kOIAffr_.webp +0 -0
- package/renderer/dist/assets/lino-coast-sm-C1Mdbgit.webp +0 -0
- package/renderer/dist/assets/moon-pines-BskgnnXR.webp +0 -0
- package/renderer/dist/assets/moon-pines-CFSIIwt0.webp +0 -0
- package/renderer/dist/assets/moon-pines-PNhdOYww.webp +0 -0
- package/renderer/dist/assets/moon-pines-sm-C6K1NomK.webp +0 -0
- package/renderer/dist/assets/orbit-rings-2XV36Ch3.webp +0 -0
- package/renderer/dist/assets/orbit-rings-C4NrcYGQ.webp +0 -0
- package/renderer/dist/assets/orbit-rings-DGLk07dT.webp +0 -0
- package/renderer/dist/assets/orbit-rings-sm-Dky3YdvL.webp +0 -0
- package/renderer/dist/assets/peach-haze-2-C6f7xnWw.webp +0 -0
- package/renderer/dist/assets/peach-haze-2-sm-Dj_Ez_Sb.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-DQjqMROg.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-Dw71H_bc.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-kOZfm-GM.webp +0 -0
- package/renderer/dist/assets/pixel-harbour-sm-Dturjuky.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-C296JVMO.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-C4oMRDSx.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-CqVmQ4eh.webp +0 -0
- package/renderer/dist/assets/pixel-orbit-sm-CHagC_nO.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-C6LYu6uE.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-CIUCEESs.webp +0 -0
- package/renderer/dist/assets/pixel-ridge-sm-BnE0F_w-.webp +0 -0
- package/renderer/dist/assets/riso-hills-7heKVtry.webp +0 -0
- package/renderer/dist/assets/riso-hills-CIjAL3J6.webp +0 -0
- package/renderer/dist/assets/riso-hills-sm-DHsnbCfP.webp +0 -0
- package/renderer/dist/assets/slate-haze-BPWdkNsw.webp +0 -0
- package/renderer/dist/assets/slate-haze-sm-B0Cb5a2W.webp +0 -0
- package/renderer/dist/assets/source-sans-3-cyrillic-ext-wght-normal-DzyfIafT.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-cyrillic-wght-normal-BMDVbyM7.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-greek-ext-wght-normal-BWSLJLk6.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-greek-wght-normal-C9H9m1vD.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-latin-ext-wght-normal-C8iNium2.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-latin-wght-normal-BqRLTx4X.woff2 +0 -0
- package/renderer/dist/assets/source-sans-3-vietnamese-wght-normal-C1uRvKPU.woff2 +0 -0
- package/renderer/dist/assets/valley-haze-BDHPqoJy.webp +0 -0
- package/renderer/dist/assets/valley-haze-sm-CTyjmPAG.webp +0 -0
- package/renderer/dist/assets/vector-dunes-DzzWLdKo.webp +0 -0
- package/renderer/dist/assets/vector-dunes-Yr9NBQUy.webp +0 -0
- package/renderer/dist/assets/vector-dunes-sm-D8MYY6ms.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-BCGKN4u4.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-BF-jk5MC.webp +0 -0
- package/renderer/dist/assets/watercolour-mist-sm-Ds1xzTw9.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-B1um67Z9.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-DKQdYs32.webp +0 -0
- package/renderer/dist/assets/woodblock-sea-sm-BJzNq8xh.webp +0 -0
- package/renderer/dist/assets/xterm-BT7tBf8A.js +27 -0
- package/renderer/dist/emulator/audio-rules.mjs +74 -0
- package/renderer/dist/emulator/data/compression/README.md +11 -0
- package/renderer/dist/emulator/data/compression/extract7z.js +1 -0
- package/renderer/dist/emulator/data/compression/extractzip.js +1 -0
- package/renderer/dist/emulator/data/compression/libunrar.js +519 -0
- package/renderer/dist/emulator/data/compression/libunrar.wasm +0 -0
- package/renderer/dist/emulator/data/cores/README.md +22 -0
- package/renderer/dist/emulator/data/cores/mgba-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-legacy-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-thread-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/mgba-wasm.data +0 -0
- package/renderer/dist/emulator/data/cores/package.json +65 -0
- package/renderer/dist/emulator/data/cores/reports/mgba.json +1 -0
- package/renderer/dist/emulator/data/emulator.css +1614 -0
- package/renderer/dist/emulator/data/loader.js +170 -0
- package/renderer/dist/emulator/data/localization/README.md +63 -0
- package/renderer/dist/emulator/data/localization/af-FR.json +339 -0
- package/renderer/dist/emulator/data/localization/ar-AR.json +301 -0
- package/renderer/dist/emulator/data/localization/ben-BEN.json +301 -0
- package/renderer/dist/emulator/data/localization/de-GER.json +301 -0
- package/renderer/dist/emulator/data/localization/el-GR.json +301 -0
- package/renderer/dist/emulator/data/localization/en-US.json +339 -0
- package/renderer/dist/emulator/data/localization/es-ES.json +310 -0
- package/renderer/dist/emulator/data/localization/fa-AF.json +310 -0
- package/renderer/dist/emulator/data/localization/hi-HI.json +301 -0
- package/renderer/dist/emulator/data/localization/it-IT.json +302 -0
- package/renderer/dist/emulator/data/localization/ja-JA.json +301 -0
- package/renderer/dist/emulator/data/localization/jv-JV.json +301 -0
- package/renderer/dist/emulator/data/localization/ko-KO.json +336 -0
- package/renderer/dist/emulator/data/localization/pt-BR.json +361 -0
- package/renderer/dist/emulator/data/localization/retroarch.json +617 -0
- package/renderer/dist/emulator/data/localization/ro-RO.json +310 -0
- package/renderer/dist/emulator/data/localization/ru-RU.json +301 -0
- package/renderer/dist/emulator/data/localization/tr-TR.json +310 -0
- package/renderer/dist/emulator/data/localization/vi-VN.json +302 -0
- package/renderer/dist/emulator/data/localization/zh-CN.json +351 -0
- package/renderer/dist/emulator/data/src/GameManager.js +464 -0
- package/renderer/dist/emulator/data/src/compression.js +142 -0
- package/renderer/dist/emulator/data/src/emulator.js +6158 -0
- package/renderer/dist/emulator/data/src/gamepad.js +167 -0
- package/renderer/dist/emulator/data/src/nipplejs.js +1 -0
- package/renderer/dist/emulator/data/src/shaders.js +236 -0
- package/renderer/dist/emulator/data/src/socket.io.min.js +7 -0
- package/renderer/dist/emulator/data/src/storage.js +125 -0
- package/renderer/dist/emulator/data/version.json +5 -0
- package/renderer/dist/emulator/host.js +299 -0
- package/renderer/dist/emulator/index.html +28 -0
- package/renderer/dist/emulator/provenance.json +6 -0
- package/renderer/dist/fixtures/onboard/designs/w-n13/the-first-row.html +1 -0
- package/renderer/dist/index.html +13 -0
- package/scripts/after-install.mjs +59 -0
- package/scripts/approval-server.sh +43 -0
- package/scripts/before-publish.mjs +133 -0
- package/shared/agent-import.d.mts +13 -0
- package/shared/agent-import.mjs +252 -0
- package/shared/agents.d.mts +190 -0
- package/shared/agents.mjs +937 -0
- package/shared/analytics-events.mjs +63 -0
- package/shared/answers.d.mts +12 -0
- package/shared/answers.mjs +80 -0
- package/shared/artifact-keys.d.mts +28 -0
- package/shared/artifact-keys.mjs +140 -0
- package/shared/bridge-map.mjs +128 -0
- package/shared/claude-commands.d.mts +20 -0
- package/shared/claude-commands.generated.mjs +149 -0
- package/shared/claude-commands.mjs +131 -0
- package/shared/claude-models.generated.d.mts +15 -0
- package/shared/claude-models.generated.mjs +26 -0
- package/shared/claude-usage.d.mts +16 -0
- package/shared/claude-usage.mjs +172 -0
- package/shared/codex-commands.d.mts +4 -0
- package/shared/codex-commands.mjs +17 -0
- package/shared/codex-import.d.mts +38 -0
- package/shared/codex-import.mjs +232 -0
- package/shared/codex-modes.mjs +159 -0
- package/shared/codex-usage.mjs +180 -0
- package/shared/contracts.mjs +412 -0
- package/shared/crash-scrub.mjs +182 -0
- package/shared/dashboard.mjs +481 -0
- package/shared/dead-run-trace.mjs +127 -0
- package/shared/demo-world.mjs +415 -0
- package/shared/effort-levels.d.mts +15 -0
- package/shared/effort-levels.mjs +76 -0
- package/shared/engines.d.mts +62 -0
- package/shared/engines.mjs +389 -0
- package/shared/first-run-examples.mjs +60 -0
- package/shared/first-run-line.mjs +149 -0
- package/shared/first-run-practice.d.mts +32 -0
- package/shared/first-run-practice.mjs +254 -0
- package/shared/first-run-shape.mjs +100 -0
- package/shared/fresh-user-home.mjs +189 -0
- package/shared/hook-failure.mjs +22 -0
- package/shared/install-id.mjs +39 -0
- package/shared/instruction-defaults.json +5 -0
- package/shared/library-folder.mjs +28 -0
- package/shared/notify-rules.mjs +120 -0
- package/shared/product-name.d.mts +6 -0
- package/shared/product-name.mjs +144 -0
- package/shared/project-folder-check.d.mts +22 -0
- package/shared/project-folder-check.mjs +152 -0
- package/shared/provider-commands.d.mts +8 -0
- package/shared/provider-commands.mjs +58 -0
- package/shared/rank.d.mts +19 -0
- package/shared/rank.mjs +146 -0
- package/shared/recovery.mjs +26 -0
- package/shared/referenced-files.d.mts +11 -0
- package/shared/referenced-files.mjs +122 -0
- package/shared/repeats.d.mts +45 -0
- package/shared/repeats.mjs +193 -0
- package/shared/schemes.mjs +33 -0
- package/shared/settings-terminal.d.mts +1 -0
- package/shared/settings-terminal.mjs +14 -0
- package/shared/side-build.mjs +57 -0
- package/shared/spawn-trouble.d.mts +8 -0
- package/shared/spawn-trouble.mjs +599 -0
- package/shared/terminal-state.d.mts +1 -0
- package/shared/terminal-state.mjs +58 -0
- package/shared/usage.d.mts +53 -0
- package/shared/usage.mjs +319 -0
- package/shared/work-items.mjs +561 -0
- package/shared/work-lines.mjs +360 -0
- package/worker-permissions.json +6 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
// The app side of the approvals channel: read what workers are waiting on,
|
|
2
|
+
// write the founder's decision. Pure file protocol against the spool dir the
|
|
3
|
+
// approval-prompt server polls, so a restart on either side loses nothing.
|
|
4
|
+
//
|
|
5
|
+
// ------------------------------------------------------------------------
|
|
6
|
+
// THE SPOOL IS A DIRECTORY THE ADVERSARY CAN WRITE IN, AND THAT IS THE WHOLE
|
|
7
|
+
// REASON THE FILE OPERATIONS BELOW ARE NOT THE OBVIOUS ONES (2026-09-04).
|
|
8
|
+
//
|
|
9
|
+
// It lives at `<storeRoot>/.approvals`. `writeWorkerSettings` puts the store
|
|
10
|
+
// root in `additionalDirectories` and `worker-permissions.json` allows Bash
|
|
11
|
+
// broadly, so a Claude Code worker can list this directory and create files in
|
|
12
|
+
// it. That is not a misconfiguration to be narrowed here -- what an agent may
|
|
13
|
+
// do on her machine is section 7 of the terms and hers to decide -- it is the
|
|
14
|
+
// threat model this file is written against. Measured on this Mac, node
|
|
15
|
+
// v22.19.0:
|
|
16
|
+
//
|
|
17
|
+
// fs.writeFileSync(<symlink>, x) -> writes THROUGH it, to the target
|
|
18
|
+
// fs.appendFileSync(<symlink>, x) -> appends THROUGH it
|
|
19
|
+
// fs.readFileSync(<fifo>) -> blocks the process, forever
|
|
20
|
+
// fs.openSync(<symlink>, 'wx') -> EEXIST
|
|
21
|
+
// open(..., O_APPEND|O_CREAT|O_NOFOLLOW) -> ELOOP
|
|
22
|
+
// open(..., O_RDONLY|O_NONBLOCK) + fstat -> returns, and says isFIFO
|
|
23
|
+
// fs.renameSync(tmp, <symlink>) -> replaces the LINK; target intact
|
|
24
|
+
// fs.rmSync(<symlink>, {recursive:true}) -> removes the LINK; target intact
|
|
25
|
+
//
|
|
26
|
+
// The first three lines are what the top three used to be. So: nothing in this
|
|
27
|
+
// directory is ever opened through a link, nothing is opened that is not a
|
|
28
|
+
// regular file, and every write lands by creating at a name nothing can guess
|
|
29
|
+
// and renaming onto the real one. The helpers are here rather than in each
|
|
30
|
+
// caller because two copies of that rule are one edit away from disagreeing,
|
|
31
|
+
// and the half that forgets is the half with the hole.
|
|
32
|
+
//
|
|
33
|
+
// AND NOTHING IN A REQUEST FILE IS BELIEVED ABOUT TIME. `at` is a worker's own
|
|
34
|
+
// account of itself; the order her cards stack in is Agentbox's, see
|
|
35
|
+
// `listPending`.
|
|
36
|
+
//
|
|
37
|
+
// WHAT THIS STILL DOES NOT CLAIM. A worker that can write here can still put A
|
|
38
|
+
// card in front of her: a file is the whole of the Claude Code protocol
|
|
39
|
+
// (main/approval-prompt-server.mjs waits in another process and has no other
|
|
40
|
+
// channel), so a request's provenance cannot be proved. What it can no longer
|
|
41
|
+
// do is aim her click at a file of hers, append to one, put its card in front
|
|
42
|
+
// of every card that came before it, or leave that card there.
|
|
43
|
+
|
|
44
|
+
import fs from 'node:fs';
|
|
45
|
+
import path from 'node:path';
|
|
46
|
+
import crypto from 'node:crypto';
|
|
47
|
+
|
|
48
|
+
export function spoolDir(storeRoot) {
|
|
49
|
+
return path.join(storeRoot, '.approvals');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/* ------------------- whose answer this is, provably --------------------- */
|
|
53
|
+
//
|
|
54
|
+
// THE FILE STOPS BEING A CHANNEL FOR APPROVING ANYTHING (2026-09-05).
|
|
55
|
+
//
|
|
56
|
+
// main/approval-prompt-server.mjs read `if (answer?.allow) return { behavior:
|
|
57
|
+
// 'allow' }` off this directory, and the paragraph at the top of this file is
|
|
58
|
+
// the whole reason that was an approval anybody could write: the store root is
|
|
59
|
+
// in `additionalDirectories` and Bash is allowed broadly, so a worker could
|
|
60
|
+
// approve another worker's card, or start a watcher and approve its own the
|
|
61
|
+
// instant it raised one.
|
|
62
|
+
//
|
|
63
|
+
// The Codex half was closed by moving the decision in process. This half cannot
|
|
64
|
+
// be: the thing waiting is a SEPARATE PROCESS, spawned by the CLI as the
|
|
65
|
+
// worker's own child, and a file is the whole of the protocol it has.
|
|
66
|
+
//
|
|
67
|
+
// AND NO SHARED SECRET CLOSES IT EITHER, which is worth writing down so nobody
|
|
68
|
+
// re-derives it: anything handed to that server is handed through the worker's
|
|
69
|
+
// own environment, and the worker can read it. A token is a token the forger
|
|
70
|
+
// has.
|
|
71
|
+
//
|
|
72
|
+
// SO THE SECRET IS ONE THAT ONLY HAS TO BE CHECKED THERE, NEVER HELD THERE.
|
|
73
|
+
// Agentbox keeps an ed25519 private key in its own process and hands the worker's
|
|
74
|
+
// MCP server the PUBLIC half, which verifies and cannot sign. The signature
|
|
75
|
+
// covers the request id, the verdict, the note and the moment TOGETHER, so a
|
|
76
|
+
// decision cannot be moved onto another card, flipped from a deny into an
|
|
77
|
+
// allow, or replayed onto a later one.
|
|
78
|
+
//
|
|
79
|
+
// THE PAIR IS PER RUN OF AGENTBOX, deliberately: a key file in her home is a key
|
|
80
|
+
// a worker can read, and there is nothing to persist for -- every card is
|
|
81
|
+
// answered by the process that raised it, and restarting Agentbox kills every
|
|
82
|
+
// worker anyway (CLAUDE.md, process etiquette). Made on first use rather than
|
|
83
|
+
// at import, so a machine that never raises a card never pays for it.
|
|
84
|
+
//
|
|
85
|
+
// WHAT IS STILL NOT CLOSED, and it is not closeable from this repository. That
|
|
86
|
+
// MCP server is the worker's own child at the same uid. It cannot read Agentbox's
|
|
87
|
+
// memory -- macOS wants root or an entitlement for that -- so no signature can
|
|
88
|
+
// be made. What a same-uid process CAN do is kill it, or attach to it and patch
|
|
89
|
+
// the check out. Stopping that means the worker being allowed less, which is
|
|
90
|
+
// `worker-permissions.json`, which is section 7 of the terms and hers.
|
|
91
|
+
|
|
92
|
+
let pair = null;
|
|
93
|
+
const keys = () => (pair ??= crypto.generateKeyPairSync('ed25519'));
|
|
94
|
+
|
|
95
|
+
/** The half it is safe to hand a worker: it verifies and it cannot sign. */
|
|
96
|
+
export function approvalPublicKey() {
|
|
97
|
+
return keys().publicKey.export({ type: 'spki', format: 'der' }).toString('base64');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* ------------------ WHAT the answer is an answer FOR --------------------- */
|
|
101
|
+
//
|
|
102
|
+
// A SIGNATURE OVER AN ID PROVES NOTHING ABOUT WHAT SHE AGREED TO (2026-09-05).
|
|
103
|
+
//
|
|
104
|
+
// `decisionBytes` covered `[id, allow, note, at]`, and the request file is one
|
|
105
|
+
// the adversary can write in: a file's NAME and the id inside it survive while
|
|
106
|
+
// its CONTENTS are replaced. So a worker raised `rm -rf ~/Zero`, rewrote its own
|
|
107
|
+
// request in place with `echo hi`, `listPending` drew `echo hi`, she pressed
|
|
108
|
+
// Allow -- and the process waiting on that id was STILL HOLDING the first
|
|
109
|
+
// command and verified her signature happily. The signature was real and it
|
|
110
|
+
// said only "yes to <uuid>". Codex has the same shape from the other end:
|
|
111
|
+
// `settle` takes an id and a verdict while `open` holds the original, and a
|
|
112
|
+
// Claude Code worker can write in the spool both engines draw their cards from.
|
|
113
|
+
//
|
|
114
|
+
// SO THE DECISION IS BOUND TO THE ACTION. `answer` signs a digest of the card
|
|
115
|
+
// Agentbox last put in front of her, alongside the verdict, and each engine
|
|
116
|
+
// compares that digest against the request IT is holding before allowing
|
|
117
|
+
// anything -- main/approval-prompt-server.mjs against its own in-memory
|
|
118
|
+
// `request`, main/codex-approvals.mjs against its own `open` entry. A mismatch
|
|
119
|
+
// is a deny, on both.
|
|
120
|
+
//
|
|
121
|
+
// THE DIGEST IS OF WHAT WAS DISPLAYED, NOT OF THE FILE AT ANSWER TIME, and that
|
|
122
|
+
// difference is the whole fix. Re-reading the request inside `answer` looks
|
|
123
|
+
// equivalent: a worker flipping the file between a harmless body and the real
|
|
124
|
+
// one then wins every time her press lands on a harmless draw and the re-read
|
|
125
|
+
// catches the real body. `listPending` is the only thing in the app that reads
|
|
126
|
+
// a request, and each of its three callers acts on or draws exactly what it
|
|
127
|
+
// returned -- the snapshot (main/ipc.mjs), the ⌘Y/⌘N chord and the Agents menu
|
|
128
|
+
// (main/main.mjs) -- so the digest taken there IS the card she is looking at.
|
|
129
|
+
//
|
|
130
|
+
// AND CANONICALISATION IS THE PART THAT FAILS QUIETLY. A digest that disagrees
|
|
131
|
+
// with itself across the round trip through the file denies every approval on
|
|
132
|
+
// the machine, with nothing on any screen saying why. Both sides hold JSON: the
|
|
133
|
+
// engine's own object, and Agentbox's parse of the file that object was
|
|
134
|
+
// stringified into. So the digest is taken over a canonical form after ONE JSON
|
|
135
|
+
// round trip -- which resolves `toJSON`, drops `undefined` from objects and
|
|
136
|
+
// turns it and NaN into null inside arrays, exactly as the write to disk did --
|
|
137
|
+
// and then sorts keys, because two objects carrying the same fields in a
|
|
138
|
+
// different order are the same card.
|
|
139
|
+
|
|
140
|
+
/** One JSON value as one string, with the key order taken out of it. */
|
|
141
|
+
function canonical(value) {
|
|
142
|
+
if (Array.isArray(value)) return `[${value.map((v) => canonical(v) ?? 'null').join(',')}]`;
|
|
143
|
+
if (value && typeof value === 'object') {
|
|
144
|
+
const fields = [];
|
|
145
|
+
for (const key of Object.keys(value).sort()) {
|
|
146
|
+
const v = canonical(value[key]);
|
|
147
|
+
if (v !== undefined) fields.push(`${JSON.stringify(key)}:${v}`);
|
|
148
|
+
}
|
|
149
|
+
return `{${fields.join(',')}}`;
|
|
150
|
+
}
|
|
151
|
+
return JSON.stringify(value);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* THE CARD, AS ONE STRING EITHER SIDE CAN ARRIVE AT INDEPENDENTLY.
|
|
156
|
+
*
|
|
157
|
+
* Null for anything that is not a request object and for anything JSON cannot
|
|
158
|
+
* express (a cycle, a BigInt). Null is never equal to a digest, so every caller
|
|
159
|
+
* reads it as a mismatch and denies, which is the safe direction in all three.
|
|
160
|
+
*
|
|
161
|
+
* NOTHING IS NORMALISED beyond what JSON itself did. Two byte strings that draw
|
|
162
|
+
* the same glyph are two different commands, and Agentbox deciding otherwise
|
|
163
|
+
* would be Agentbox deciding what her shell will do with them.
|
|
164
|
+
*/
|
|
165
|
+
export function cardDigest(request) {
|
|
166
|
+
if (!request || typeof request !== 'object' || Array.isArray(request)) return null;
|
|
167
|
+
let text;
|
|
168
|
+
try { text = canonical(JSON.parse(JSON.stringify(request))); } catch { return null; }
|
|
169
|
+
if (typeof text !== 'string') return null;
|
|
170
|
+
return crypto.createHash('sha256').update(text, 'utf8').digest('base64');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* THE EXACT BYTES A SIGNATURE COVERS, and every field of the decision is in
|
|
175
|
+
* them, including WHICH CARD it was a decision about. An array rather than an
|
|
176
|
+
* object, because two JSON objects carrying the same fields in a different
|
|
177
|
+
* order are different bytes, and the verifier rebuilds this from a PARSED file
|
|
178
|
+
* rather than from the text on disk.
|
|
179
|
+
*/
|
|
180
|
+
function decisionBytes(id, allow, note, at, card) {
|
|
181
|
+
return Buffer.from(JSON.stringify([String(id), !!allow, note ?? null, at ?? null, card ?? null]), 'utf8');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Whether this answer is one Agentbox wrote for THIS request. False for every
|
|
186
|
+
* shape that is not, including the one a worker with a shell writes, and
|
|
187
|
+
* including one whose `card` has been edited away from the digest she really
|
|
188
|
+
* signed -- so the binding cannot be stripped back off a real decision. */
|
|
189
|
+
export function answerIsHers(id, answer, publicKey = approvalPublicKey()) {
|
|
190
|
+
if (!answer || typeof answer !== 'object' || Array.isArray(answer)) return false;
|
|
191
|
+
if (typeof answer.sig !== 'string' || !answer.sig) return false;
|
|
192
|
+
try {
|
|
193
|
+
const key = crypto.createPublicKey({ key: Buffer.from(publicKey, 'base64'), format: 'der', type: 'spki' });
|
|
194
|
+
return crypto.verify(null, decisionBytes(id, answer.allow, answer.note, answer.at, answer.card), key, Buffer.from(answer.sig, 'base64'));
|
|
195
|
+
} catch {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* HOW LONG A REQUEST FILE MAY OUTLIVE ANYONE WHO COULD ANSWER IT.
|
|
202
|
+
*
|
|
203
|
+
* Both engines give up on a card after fifteen minutes and delete the pair when
|
|
204
|
+
* they do: main/approval-prompt-server.mjs's `TIMEOUT_MS`, and
|
|
205
|
+
* main/codex-approvals.mjs's `timeoutMs`, which defaults to the transport's
|
|
206
|
+
* `APPROVAL_TIMEOUT_MS`. A request still on disk past that is either a forgery
|
|
207
|
+
* or the leftovers of a worker that died before its own deadline, and in both
|
|
208
|
+
* cases it is a question nothing can answer.
|
|
209
|
+
*
|
|
210
|
+
* A minute over the longest of them, and never under: being early takes a live
|
|
211
|
+
* card off her screen, being late leaves a dead one there one more minute.
|
|
212
|
+
* tests/a-card-a-worker-forged-cannot-hold-the-front-of-her-stack.test.mjs
|
|
213
|
+
* checks that against both of those constants rather than trusting this
|
|
214
|
+
* comment.
|
|
215
|
+
*/
|
|
216
|
+
export const REQUEST_TTL_MS = 16 * 60_000;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The largest request file worth opening. main/codex-approvals.mjs already
|
|
220
|
+
* cuts a card's text at CARD_CAP (4,000 characters), and a Claude Code tool
|
|
221
|
+
* input that reached a megabyte could not be read on a card anyway -- so this
|
|
222
|
+
* is only ever a bound on what a worker can make Agentbox hold in memory on
|
|
223
|
+
* every poll of the inbox. Past it the file is skipped exactly the way an
|
|
224
|
+
* unparseable one always has been, and the TTL above takes it away. */
|
|
225
|
+
export const MAX_REQUEST_BYTES = 1024 * 1024;
|
|
226
|
+
|
|
227
|
+
const O_NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0; // undefined on Windows, which this app is not
|
|
228
|
+
const READ_FLAGS = fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | O_NOFOLLOW;
|
|
229
|
+
const APPEND_FLAGS = fs.constants.O_WRONLY | fs.constants.O_APPEND | fs.constants.O_CREAT | O_NOFOLLOW;
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Whether the spool holds an entry by this name, WHATEVER it is. `lstat`
|
|
233
|
+
* rather than `existsSync`, which resolves a link and so answers "no" for a
|
|
234
|
+
* dangling one that `readdir` can plainly see. */
|
|
235
|
+
export function spoolEntryExists(dir, name) {
|
|
236
|
+
try { fs.lstatSync(path.join(dir, name)); return true; } catch { return false; }
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Take an entry out of the spool, whatever a worker made it. `rm` unlinks a
|
|
241
|
+
* symlink rather than descending it, so this never reaches outside the spool,
|
|
242
|
+
* and it removes the directory that `unlink` refuses (EPERM, measured). */
|
|
243
|
+
export function removeSpoolEntry(dir, name) {
|
|
244
|
+
try { fs.rmSync(path.join(dir, name), { recursive: true, force: true }); } catch { /* already gone */ }
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* One spool file as the object it should hold, or null.
|
|
249
|
+
*
|
|
250
|
+
* O_NOFOLLOW so a request symlinked at one of her files is never opened,
|
|
251
|
+
* O_NONBLOCK so a named pipe wearing a request file's name cannot freeze the
|
|
252
|
+
* main process inside a synchronous read, and `fstat` on the descriptor we
|
|
253
|
+
* actually got rather than an `lstat` on the name, which would leave a window
|
|
254
|
+
* to swap underneath.
|
|
255
|
+
*/
|
|
256
|
+
export function readSpoolJson(dir, name) {
|
|
257
|
+
let fd = null;
|
|
258
|
+
try {
|
|
259
|
+
fd = fs.openSync(path.join(dir, name), READ_FLAGS);
|
|
260
|
+
const stat = fs.fstatSync(fd);
|
|
261
|
+
if (!stat.isFile() || stat.size > MAX_REQUEST_BYTES) return null;
|
|
262
|
+
return JSON.parse(fs.readFileSync(fd, 'utf8'));
|
|
263
|
+
} catch {
|
|
264
|
+
return null;
|
|
265
|
+
} finally {
|
|
266
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch { /* already closed */ } }
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Put a file in the spool, without ever writing through what is already there.
|
|
272
|
+
*
|
|
273
|
+
* The exclusive create is at a name nothing can guess, so it cannot be
|
|
274
|
+
* pre-created; the rename is atomic and replaces the destination's directory
|
|
275
|
+
* entry without resolving it, so a planted link is destroyed rather than
|
|
276
|
+
* followed AND her decision still lands on the first press. Plain `wx` on the
|
|
277
|
+
* final name would refuse to follow but would also throw her click away
|
|
278
|
+
* whenever anything squatted the name, and a lost Allow is the silence this
|
|
279
|
+
* app is built not to produce.
|
|
280
|
+
*
|
|
281
|
+
* The second attempt is for the one squatter `rename` cannot replace: a
|
|
282
|
+
* directory (EISDIR, measured), which is also what `unlink` refuses. Removing
|
|
283
|
+
* it is safe for the reason `removeSpoolEntry` gives.
|
|
284
|
+
*/
|
|
285
|
+
export function writeSpoolFile(dir, name, text) {
|
|
286
|
+
const dest = path.join(dir, name);
|
|
287
|
+
const tmp = path.join(dir, `.${crypto.randomUUID()}.tmp`);
|
|
288
|
+
let fd = null;
|
|
289
|
+
try {
|
|
290
|
+
fd = fs.openSync(tmp, 'wx', 0o600);
|
|
291
|
+
fs.writeFileSync(fd, text);
|
|
292
|
+
fs.closeSync(fd);
|
|
293
|
+
fd = null;
|
|
294
|
+
} catch {
|
|
295
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch { /* already closed */ } }
|
|
296
|
+
try { fs.unlinkSync(tmp); } catch { /* never got made */ }
|
|
297
|
+
return false;
|
|
298
|
+
}
|
|
299
|
+
try { fs.renameSync(tmp, dest); return true; } catch { /* something unreplaceable is there */ }
|
|
300
|
+
try {
|
|
301
|
+
fs.rmSync(dest, { recursive: true, force: true });
|
|
302
|
+
fs.renameSync(tmp, dest);
|
|
303
|
+
return true;
|
|
304
|
+
} catch { /* the spool itself will not take a write; the caller denies */ }
|
|
305
|
+
try { fs.unlinkSync(tmp); } catch { /* best effort */ }
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* ONE AUDIT LINE, AND NEVER INTO A FILE OF HERS.
|
|
311
|
+
*
|
|
312
|
+
* `log.jsonl` is a FIXED name in a directory a worker can write in, so unlike
|
|
313
|
+
* the answer file it needs no guessing at all, and the bytes appended carry the
|
|
314
|
+
* worker's own `command` string. Symlinked at `briefs/founder.md` that is
|
|
315
|
+
* agent-authored text inside the standing instructions every session is briefed
|
|
316
|
+
* with, appended once per decision, forever.
|
|
317
|
+
*
|
|
318
|
+
* O_NOFOLLOW closes it with no window between a check and the write. But
|
|
319
|
+
* refusing alone would hand the attacker the audit trail: both callers swallow
|
|
320
|
+
* a failure here on purpose (a decision is never held hostage to its log line),
|
|
321
|
+
* so a planted link would end auditing silently and for good. An entry that is
|
|
322
|
+
* not a regular file is therefore destroyed and a real one opened in its place,
|
|
323
|
+
* out loud -- this is the one warning in this area that always means a worker
|
|
324
|
+
* attacked the spool.
|
|
325
|
+
*/
|
|
326
|
+
export function appendSpoolLine(dir, name, line) {
|
|
327
|
+
if (appendOnce(dir, name, line)) return true;
|
|
328
|
+
let squatter = null;
|
|
329
|
+
try { squatter = fs.lstatSync(path.join(dir, name)); } catch { return false; }
|
|
330
|
+
if (squatter.isFile()) return false; // a real log we simply could not write to
|
|
331
|
+
console.warn(`zero: ${name} in the approvals spool was ${squatter.isSymbolicLink() ? 'a link' : 'not a file'}, which only a worker can have done; it has been removed and the audit line written to a real one`);
|
|
332
|
+
removeSpoolEntry(dir, name);
|
|
333
|
+
return appendOnce(dir, name, line);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function appendOnce(dir, name, line) {
|
|
337
|
+
let fd = null;
|
|
338
|
+
try {
|
|
339
|
+
fd = fs.openSync(path.join(dir, name), APPEND_FLAGS, 0o600);
|
|
340
|
+
fs.writeFileSync(fd, line);
|
|
341
|
+
return true;
|
|
342
|
+
} catch {
|
|
343
|
+
return false;
|
|
344
|
+
} finally {
|
|
345
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch { /* already closed */ } }
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/**
|
|
350
|
+
* WHEN THIS PROCESS FIRST SAW EACH REQUEST, which is the only clock in that
|
|
351
|
+
* directory a worker cannot write to.
|
|
352
|
+
*
|
|
353
|
+
* `listPending` used to sort by `a.at`, read straight out of the request file.
|
|
354
|
+
* A worker writes `<uuid>.request.json` with `at: 0` and owns the front of the
|
|
355
|
+
* stack -- and that is not one bad card, it is every card, because the renderer
|
|
356
|
+
* draws only the front one and ⌘Y/⌘N and the Agents menu all act on
|
|
357
|
+
* `listPending[0]`. Her keystroke answers the decoy while the question she is
|
|
358
|
+
* reading waits behind it.
|
|
359
|
+
*
|
|
360
|
+
* So `at` is not read for ordering at all any more. `seq` is taken at the
|
|
361
|
+
* sighting and only ever increases, which makes the order total as well as
|
|
362
|
+
* unforgeable; the timestamp beside it is what the TTL is measured from. A
|
|
363
|
+
* floor on the worker's own `at` was the other candidate and it is weaker: a
|
|
364
|
+
* legitimate `at` is written moments before Agentbox sees the file, so it needs a
|
|
365
|
+
* tolerance, and that tolerance is exactly the window a forgery is planted in.
|
|
366
|
+
*
|
|
367
|
+
* Keyed by directory as well as by id because the suite runs many spools, and
|
|
368
|
+
* pruned against what is really on disk on every pass, so it cannot grow.
|
|
369
|
+
*/
|
|
370
|
+
const seenAt = new Map();
|
|
371
|
+
let sightings = 0;
|
|
372
|
+
|
|
373
|
+
const REQUEST_SUFFIX = '.request.json';
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* WHICH OF TWO REQUESTS FIRST SEEN IN THE SAME PASS CAME FIRST.
|
|
377
|
+
*
|
|
378
|
+
* A sighting is only as fine as the poll that made it, so cards that appear
|
|
379
|
+
* inside one interval share a timestamp and something has to break the tie.
|
|
380
|
+
* `readdir` is the wrong answer: measured on this Mac it comes back in NAME
|
|
381
|
+
* order, and a forgery names itself, so a worker watching the spool could plant
|
|
382
|
+
* a decoy the instant a real card appeared and sort itself in front of it.
|
|
383
|
+
*
|
|
384
|
+
* `ctime` is the right one, and it is the only timestamp here that is any use:
|
|
385
|
+
* an unprivileged process can move a file's mtime and its BIRTHTIME backwards
|
|
386
|
+
* -- measured, `utimes(epoch)` set both to 0 -- and the same call pushed ctime
|
|
387
|
+
* FORWARD to now. A worker can therefore make its own request look younger than
|
|
388
|
+
* it is, which costs it the front of the stack, and can never make it look
|
|
389
|
+
* older. Nanoseconds because APFS keeps them and two files written back to back
|
|
390
|
+
* differ by about 170 microseconds; equal stamps keep `readdir` order, which is
|
|
391
|
+
* as good as anything when two things really did happen at once.
|
|
392
|
+
*/
|
|
393
|
+
function bornAt(dir, name) {
|
|
394
|
+
try { return fs.lstatSync(path.join(dir, name), { bigint: true }).ctimeNs; }
|
|
395
|
+
catch { return 2n ** 63n; } // gone or unreadable: never in front of something real
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export function listPending(storeRoot, { now = Date.now } = {}) {
|
|
399
|
+
const dir = spoolDir(storeRoot);
|
|
400
|
+
let files = [];
|
|
401
|
+
try { files = fs.readdirSync(dir); } catch { return []; }
|
|
402
|
+
const at = now();
|
|
403
|
+
// NUL cannot occur in a path or a filename, so two spools never collide.
|
|
404
|
+
const prefix = `${dir}\0`;
|
|
405
|
+
const names = files.filter((f) => f.endsWith(REQUEST_SUFFIX));
|
|
406
|
+
// Everything new in this pass takes its place in the queue before anything is
|
|
407
|
+
// read, oldest first, so the order does not depend on what `readdir` felt
|
|
408
|
+
// like returning.
|
|
409
|
+
for (const { f } of names
|
|
410
|
+
.filter((f) => !seenAt.has(prefix + f.slice(0, -REQUEST_SUFFIX.length)))
|
|
411
|
+
.map((f) => ({ f, born: bornAt(dir, f) }))
|
|
412
|
+
.sort((a, b) => (a.born < b.born ? -1 : a.born > b.born ? 1 : 0))) {
|
|
413
|
+
seenAt.set(prefix + f.slice(0, -REQUEST_SUFFIX.length), { at, seq: sightings += 1 });
|
|
414
|
+
}
|
|
415
|
+
const present = new Set();
|
|
416
|
+
const pending = [];
|
|
417
|
+
for (const f of names) {
|
|
418
|
+
const id = f.slice(0, -REQUEST_SUFFIX.length);
|
|
419
|
+
const key = prefix + id;
|
|
420
|
+
const seen = seenAt.get(key);
|
|
421
|
+
// A REQUEST NOBODY IS WAITING ON ANY MORE. Nothing ever removed one whose
|
|
422
|
+
// process was gone, which is what made a forged `at: 0` mean permanently.
|
|
423
|
+
if (at - seen.at > REQUEST_TTL_MS) {
|
|
424
|
+
console.warn(`zero: approval ${id} outlasted every deadline that could have answered it; it has been taken out of the spool`);
|
|
425
|
+
removeSpoolEntry(dir, f);
|
|
426
|
+
removeSpoolEntry(dir, `${id}.answer.json`);
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
present.add(key);
|
|
430
|
+
// A DECIDED CARD IS HIDDEN WHILE THE ENGINE REAPS IT -- BUT ONLY IF THE
|
|
431
|
+
// DECISION IS HERS. This was a bare `files.includes(...)`, and that made
|
|
432
|
+
// any answer file a way to take a card off her screen: a worker rewriting
|
|
433
|
+
// a forgery once a second keeps ANY card, its own or another worker's,
|
|
434
|
+
// hidden until the fifteen-minute deadline denies it in silence. So the
|
|
435
|
+
// file is checked, and one that is not hers is removed rather than
|
|
436
|
+
// believed -- the same sweep main/codex-approvals.mjs already runs, moved
|
|
437
|
+
// to where both engines pass through it.
|
|
438
|
+
const answerName = `${id}.answer.json`;
|
|
439
|
+
if (files.includes(answerName)) {
|
|
440
|
+
if (answerIsHers(id, readSpoolJson(dir, answerName))) continue;
|
|
441
|
+
console.warn(`zero: an answer file appeared for approval ${id} that the founder did not write; it has been deleted and the card is still waiting`);
|
|
442
|
+
removeSpoolEntry(dir, answerName);
|
|
443
|
+
}
|
|
444
|
+
const request = readSpoolJson(dir, f);
|
|
445
|
+
// AND WHAT WAS HANDED OUT IS REMEMBERED, because this is the only place a
|
|
446
|
+
// request is ever read and every caller of this function acts on or draws
|
|
447
|
+
// exactly what it returns. `answer` signs THIS, so her press authorises the
|
|
448
|
+
// card that was on the screen rather than whatever is filed under the id by
|
|
449
|
+
// the time somebody honours it. Kept from the last SUCCESSFUL read: a file
|
|
450
|
+
// that has gone unreadable takes its card off her screen too, so leaving
|
|
451
|
+
// the digest alone is leaving the last thing she was actually shown.
|
|
452
|
+
if (request) {
|
|
453
|
+
seen.card = cardDigest(request);
|
|
454
|
+
pending.push({ request, seq: seen.seq });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
for (const key of seenAt.keys()) if (key.startsWith(prefix) && !present.has(key)) seenAt.delete(key);
|
|
458
|
+
return pending.sort((a, b) => a.seq - b.seq).map((p) => p.request);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* The digest of the card last put in front of her for this request, or null
|
|
463
|
+
* when nothing ever was. Kept beside the sighting, so it lives exactly as long
|
|
464
|
+
* as the request file does. */
|
|
465
|
+
export function cardShown(storeRoot, id) {
|
|
466
|
+
return seenAt.get(`${spoolDir(storeRoot)}\0${id}`)?.card ?? null;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
export function answer(storeRoot, id, allow, note) {
|
|
470
|
+
if (!/^[\w-]+$/.test(String(id ?? ''))) return false;
|
|
471
|
+
const dir = spoolDir(storeRoot);
|
|
472
|
+
if (!spoolEntryExists(dir, `${id}.request.json`)) return false;
|
|
473
|
+
// A DECISION THAT NAMES NO ACTION IS NOT A DECISION. Every route that answers
|
|
474
|
+
// a card reads `listPending` immediately before it -- the snapshot the card is
|
|
475
|
+
// drawn from, the ⌘Y/⌘N chord, the Agents menu -- so in the app there is
|
|
476
|
+
// always a card here. Reading the file instead would be signing something she
|
|
477
|
+
// may never have seen, which is the hole this whole section is about.
|
|
478
|
+
const card = cardShown(storeRoot, id);
|
|
479
|
+
if (!card) return false;
|
|
480
|
+
const at = Date.now();
|
|
481
|
+
// SIGNED, so the process waiting on it can tell her decision from one a
|
|
482
|
+
// worker wrote, and bound to the card so it cannot be honoured against a
|
|
483
|
+
// different action. See `answerIsHers` above for why nothing weaker works.
|
|
484
|
+
const sig = crypto.sign(null, decisionBytes(id, !!allow, note ?? null, at, card), keys().privateKey).toString('base64');
|
|
485
|
+
return writeSpoolFile(dir, `${id}.answer.json`,
|
|
486
|
+
JSON.stringify({ allow: !!allow, note: note ?? null, at, card, sig }));
|
|
487
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// A file a worker named on a card, resolved to a real file ON THAT CARD'S OWN
|
|
2
|
+
// PRODUCT, and never anywhere else.
|
|
3
|
+
//
|
|
4
|
+
// She was right twice over. The finder used to try EVERY product's roots in
|
|
5
|
+
// turn, own product first, so a path an Agentbox worker wrote against a folder
|
|
6
|
+
// Agentbox does not have fell straight through to whichever product did have one.
|
|
7
|
+
// On the card she was reading, nine of eleven chips opened another product's
|
|
8
|
+
// files. A chip that opens the wrong file is worse than no chip at all, because
|
|
9
|
+
// nothing on screen tells her it is wrong.
|
|
10
|
+
//
|
|
11
|
+
// Her answer, the same day: "Fix the finder so a card never opens another
|
|
12
|
+
// product's file, and say so plainly when it cannot find one."
|
|
13
|
+
//
|
|
14
|
+
// So there are exactly two rules here:
|
|
15
|
+
//
|
|
16
|
+
// 1. Every GUESS is made inside one product. The roots below, the
|
|
17
|
+
// account-root retry and the by-name hunt all read one product's trees.
|
|
18
|
+
// 2. When the guessing runs out, this returns a sentence rather than a
|
|
19
|
+
// silence, and the caller shows it.
|
|
20
|
+
//
|
|
21
|
+
// An ABSOLUTE path a worker typed is not a guess: it says exactly which file it
|
|
22
|
+
// means, so it still opens as written even when it points outside the product.
|
|
23
|
+
// That is the difference this file draws. Deciding for her that an explicit
|
|
24
|
+
// path is wrong would break every link into the app's own repo, which is not a
|
|
25
|
+
// product at all.
|
|
26
|
+
|
|
27
|
+
import fsDefault from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { machineryDir } from './store/home.mjs';
|
|
30
|
+
import { NAME } from '../shared/product-name.mjs';
|
|
31
|
+
|
|
32
|
+
// The roots a worker might have been standing in when it wrote a relative path:
|
|
33
|
+
// the docs dir, its two well-known subfolders, the product's code repo, and the
|
|
34
|
+
// app's own home for this product.
|
|
35
|
+
//
|
|
36
|
+
// THE HOME IS IN THIS LIST BECAUSE `runs/` LEFT HER FOLDER. The change a run
|
|
37
|
+
// made is written to `runs/<item>/…` and the pane asks for it by exactly that
|
|
38
|
+
// relative path. Once the move carried `runs/` into
|
|
39
|
+
// `<home>/projects/<encoded>/`, that path resolved against her folder and found
|
|
40
|
+
// nothing there. MEASURED on her real store before this line existed: of the 37
|
|
41
|
+
// run records that had moved, the pane resolved 0.
|
|
42
|
+
//
|
|
43
|
+
// It goes LAST, after her own folder, so nothing of hers is ever shadowed by
|
|
44
|
+
// one of ours.
|
|
45
|
+
export function artifactRoots(prod) {
|
|
46
|
+
if (!prod) return [];
|
|
47
|
+
return [
|
|
48
|
+
prod.dir,
|
|
49
|
+
prod.dir && path.join(prod.dir, 'designs'),
|
|
50
|
+
prod.dir && path.join(prod.dir, 'attachments'),
|
|
51
|
+
prod.repoPath,
|
|
52
|
+
prod.repoPath && path.join(prod.repoPath, 'designs'),
|
|
53
|
+
prod.dir && machineryDir(prod.dir),
|
|
54
|
+
].filter(Boolean);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Machinery, not artifacts. Walking these is slow and never finds the file a
|
|
58
|
+
// worker meant.
|
|
59
|
+
//
|
|
60
|
+
// `runs` JOINED THIS LIST FOR A SHARPER REASON THAN SLOWNESS. Every card's
|
|
61
|
+
// change record is called the same thing, `the-change-it-made.change`, so a
|
|
62
|
+
// hunt by basename through `runs/` will always find SOME card's record and
|
|
63
|
+
// return it as if it were this one's. MEASURED on her store: asking for's
|
|
64
|
+
// change handed back's. This file's own header says a chip that opens the wrong
|
|
65
|
+
// file is worse than no chip at all, and that was it happening. A change record
|
|
66
|
+
// now resolves by exact path or not at all.
|
|
67
|
+
const SKIP = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'sessions', 'runs', 'pending-writes', 'checkpoints']);
|
|
68
|
+
|
|
69
|
+
// Hunt the file down by NAME inside one product's trees, newest match wins.
|
|
70
|
+
// Workers write links relative to whatever directory they were standing in, so
|
|
71
|
+
// a fixed root list will always miss some of them, and every miss cost her a
|
|
72
|
+
// 10-minute detour (2026-08-05). Bounded: six levels, own product only.
|
|
73
|
+
function huntByName(base, roots, fs) {
|
|
74
|
+
let best = null;
|
|
75
|
+
const walk = (dir, depth) => {
|
|
76
|
+
if (depth > 6) return;
|
|
77
|
+
let entries;
|
|
78
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
79
|
+
for (const e of entries) {
|
|
80
|
+
if (e.name.startsWith('.')) continue;
|
|
81
|
+
const full = path.join(dir, e.name);
|
|
82
|
+
if (e.isDirectory()) { if (!SKIP.has(e.name)) walk(full, depth + 1); }
|
|
83
|
+
else if (e.name === base) {
|
|
84
|
+
let m = 0;
|
|
85
|
+
try { m = fs.statSync(full).mtimeMs; } catch {}
|
|
86
|
+
if (!best || m > best.m) best = { full, m };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
for (const root of roots) walk(root, 0);
|
|
91
|
+
return best?.full ?? null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// What she reads when the file is not there. Plain sentences, her vocabulary:
|
|
95
|
+
// this ends up in a toast under her cursor, not in a log.
|
|
96
|
+
export function notFoundMessage(raw, own, product) {
|
|
97
|
+
const name = own?.name || product || 'this project';
|
|
98
|
+
if (!own) return `${raw} could not be opened. ${product ? `“${product}”` : 'That project'} is not set up in ${NAME}.`;
|
|
99
|
+
return `${raw} is not in ${name}. Nothing was opened.`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Resolve one worker-written path. Returns { ok: true, path } or
|
|
103
|
+
// { ok: false, error } where the error is a sentence for her, not a code.
|
|
104
|
+
//
|
|
105
|
+
// `products` is the whole list only so the card's own product can be found in
|
|
106
|
+
// it. Nothing in here reads another entry.
|
|
107
|
+
export function resolveArtifact({ src, product, products = [], accountRoot, storeRoot, fs = fsDefault }) {
|
|
108
|
+
if (typeof src !== 'string' || !src) return { ok: false, error: 'No file was named.' };
|
|
109
|
+
const raw = decodeURIComponent(src.replace(/^file:\/\//, '')).replace(/^\.\//, '');
|
|
110
|
+
const own = products.find((x) => x.slug === product) ?? null;
|
|
111
|
+
const roots = artifactRoots(own);
|
|
112
|
+
|
|
113
|
+
const candidates = [];
|
|
114
|
+
if (path.isAbsolute(raw)) {
|
|
115
|
+
// Said in full, so taken at its word.
|
|
116
|
+
candidates.push(raw);
|
|
117
|
+
// Workers also write an absolute path with a segment missing (the account
|
|
118
|
+
// root straight onto reports/, no product slug). The remainder is a guess,
|
|
119
|
+
// so it is retried under THIS product's roots and no other.
|
|
120
|
+
for (const base of [accountRoot, storeRoot].filter(Boolean)) {
|
|
121
|
+
if (raw.startsWith(base + path.sep)) {
|
|
122
|
+
const rest = raw.slice(base.length + 1);
|
|
123
|
+
for (const root of roots) candidates.push(path.join(root, rest));
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
for (const root of roots) candidates.push(path.join(root, raw));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const full of candidates) {
|
|
131
|
+
if (fs.existsSync(full)) return { ok: true, path: full };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// The hunt takes the two TREES, not the root list: designs/ and attachments/
|
|
135
|
+
// sit inside the docs dir already, and walking them twice finds nothing new.
|
|
136
|
+
const base = path.basename(raw);
|
|
137
|
+
const trees = own ? [own.dir, own.repoPath].filter(Boolean) : [];
|
|
138
|
+
if (base && trees.length) {
|
|
139
|
+
const found = huntByName(base, trees, fs);
|
|
140
|
+
if (found) return { ok: true, path: found };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return { ok: false, error: notFoundMessage(raw, own, product) };
|
|
144
|
+
}
|