agentbox-app 0.1.13 → 0.1.16
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/README.md +24 -9
- package/bin/agentbox.mjs +23 -1
- package/briefs/message-rules.md +36 -8
- package/briefs/worker.md +46 -23
- package/main/account-discovery.mjs +122 -30
- package/main/account-tooling.mjs +29 -1
- package/main/account-usage.mjs +46 -0
- package/main/agent-sessions.mjs +282 -268
- package/main/agents.mjs +179 -466
- package/main/analytics.mjs +12 -2
- package/main/answer-modes.mjs +103 -37
- package/main/approval-prompt-core.mjs +197 -0
- package/main/approval-prompt-server.mjs +4 -138
- package/main/approvals.mjs +101 -297
- package/main/artifact-path.mjs +47 -18
- package/main/claude-input.mjs +48 -4
- package/main/claude-plan.mjs +48 -0
- package/main/claude-session-open.mjs +28 -0
- package/main/claude-usage-process.mjs +40 -0
- package/main/claude-usage.mjs +18 -40
- package/main/cloud/account.mjs +179 -0
- package/main/cloud/aws.mjs +127 -0
- package/main/cloud/browser-return.mjs +58 -0
- package/main/cloud/clients.mjs +15 -0
- package/main/cloud/codex-cloud.mjs +53 -0
- package/main/cloud/config.mjs +107 -0
- package/main/cloud/docs.mjs +219 -0
- package/main/cloud/exec.mjs +27 -0
- package/main/cloud/host.mjs +165 -0
- package/main/cloud/kit.mjs +80 -0
- package/main/cloud/logins.mjs +543 -0
- package/main/cloud/managed.mjs +115 -0
- package/main/cloud/marker.mjs +104 -0
- package/main/cloud/org.mjs +53 -0
- package/main/cloud/reuse.mjs +208 -0
- package/main/cloud/run-cli.mjs +177 -0
- package/main/cloud/run.mjs +1934 -0
- package/main/cloud/usage.mjs +76 -0
- package/main/cloud-permission.mjs +51 -0
- package/main/codex-account.mjs +78 -0
- package/main/codex-app-server.mjs +276 -409
- package/main/codex-approvals.mjs +148 -433
- package/main/codex-models.mjs +20 -2
- package/main/codex-session.mjs +212 -507
- package/main/codex-threads-worker.mjs +12 -0
- package/main/codex-threads.mjs +71 -3
- package/main/codex-usage-file.mjs +5 -3
- package/main/codex-usage.mjs +56 -7
- package/main/codex-watch.mjs +17 -3
- package/main/codex.mjs +95 -363
- package/main/config.mjs +11 -1
- package/main/corner-tag.mjs +340 -19
- package/main/dev-window.mjs +1 -1
- package/main/engine-bin.mjs +232 -282
- package/main/engine-setup.mjs +21 -9
- package/main/feedback.mjs +3 -25
- package/main/folder-pictures.mjs +2 -2
- package/main/freeze-recorder-thread.mjs +86 -0
- package/main/freeze-recorder.mjs +146 -0
- package/main/fresh-user.mjs +20 -9
- package/main/git-change-offthread.mjs +17 -3
- package/main/git-change-thread.mjs +5 -2
- package/main/harnesses/claude.mjs +32 -11
- package/main/harnesses/codex.mjs +9 -2
- package/main/importable-threads.mjs +64 -6
- package/main/ipc.mjs +362 -111
- package/main/leftovers.mjs +117 -25
- package/main/liquid-glass.mjs +26 -0
- package/main/live-replies.mjs +10 -4
- package/main/main-went-red.mjs +330 -0
- package/main/main.mjs +199 -84
- package/main/memory-gate-history.mjs +36 -10
- package/main/memory-gate-server.mjs +62 -0
- package/main/memory-gate.mjs +14 -1
- package/main/menu.mjs +4 -2
- package/main/message-priority.mjs +2 -2
- package/main/mic.mjs +38 -0
- package/main/native/liquid-glass/build/liquid-glass.node +0 -0
- package/main/new-chats-watch.mjs +110 -0
- package/main/new-user-window.mjs +25 -0
- package/main/notify.mjs +24 -8
- package/main/phone-alerts.mjs +117 -0
- package/main/phone-inbox.mjs +625 -0
- package/main/phone-link.mjs +29 -9
- package/main/phone-uploads.mjs +476 -0
- package/main/picture-shape.mjs +114 -0
- package/main/quit-sequence.mjs +143 -0
- package/main/refresh-agent-models.mjs +15 -2
- package/main/relay-client.mjs +69 -1
- package/main/repeats.mjs +10 -4
- package/main/row-label.mjs +12 -41
- package/main/run-change.mjs +13 -0
- package/main/run-ledger.mjs +135 -0
- package/main/save-attachments.mjs +41 -0
- package/main/serve.mjs +45 -3
- package/main/settings.mjs +220 -44
- package/main/shell-path.mjs +185 -37
- package/main/ship-conflict-brief.mjs +119 -0
- package/main/ship-queue.mjs +115 -19
- package/main/source-updater.mjs +18 -10
- package/main/stage-attachment.mjs +68 -0
- package/main/stall-watch.mjs +50 -0
- package/main/store/home.mjs +87 -232
- package/main/store/paths.mjs +11 -0
- package/main/store/teammates-files.mjs +99 -0
- package/main/store/work-items.mjs +231 -13
- package/main/store.mjs +226 -52
- package/main/supervisor.mjs +3103 -341
- package/main/task-folders.mjs +430 -30
- package/main/task-terminals.mjs +110 -34
- package/main/team/index.mjs +114 -10
- package/main/team/memory-cloud.mjs +183 -1
- package/main/team/room-hours.mjs +48 -0
- package/main/team/room-setup.mjs +31 -0
- package/main/team/rooms-memory.mjs +454 -0
- package/main/team/rooms-shape.mjs +42 -0
- package/main/team/rooms-supabase.mjs +324 -0
- package/main/team/rooms.mjs +575 -0
- package/main/team/session.mjs +96 -26
- package/main/team/supabase-backend.mjs +180 -0
- package/main/team/sync.mjs +109 -1
- package/main/teammates.mjs +580 -0
- package/main/terminal-host.mjs +143 -0
- package/main/terminal-hosts.mjs +261 -0
- package/main/transcript-search.mjs +80 -0
- package/main/webview-guard.mjs +45 -0
- package/main/worktree-include.mjs +90 -219
- package/mcp/core/documents.mjs +3 -1
- package/mcp/core/repeats.mjs +88 -0
- package/mcp/core/teammates.mjs +222 -0
- package/mcp/core/work.mjs +76 -4
- package/mcp/stdio.mjs +24 -11
- package/mcp/tools.mjs +116 -8
- package/package.json +21 -5
- package/renderer/dist/assets/{abnfDiagram-YUKMZFIV-DPPuMWt-.js → abnfDiagram-YUKMZFIV-CoWB6YUK.js} +1 -1
- package/renderer/dist/assets/{arc-HmFfdseH.js → arc-Ch0NrCul.js} +1 -1
- package/renderer/dist/assets/{architectureDiagram-47P4ROYG-DxJ3zHrH.js → architectureDiagram-47P4ROYG-CXz9M5tQ.js} +1 -1
- package/renderer/dist/assets/{blockDiagram-E7TT5TSB-CbTXzOqQ.js → blockDiagram-E7TT5TSB-DchGUuHH.js} +1 -1
- package/renderer/dist/assets/{c4Diagram-CRA5TL53-X5ItFyLn.js → c4Diagram-CRA5TL53-Cip-zNyr.js} +1 -1
- package/renderer/dist/assets/channel-BQuQSL--.js +1 -0
- package/renderer/dist/assets/{chunk-2Q5K7J3B-DC12Ho_L.js → chunk-2Q5K7J3B-BoXid_2r.js} +1 -1
- package/renderer/dist/assets/{chunk-5VM5RSS4-ClVtfPd0.js → chunk-5VM5RSS4-Cpez1rRz.js} +1 -1
- package/renderer/dist/assets/{chunk-7TKQ45FW-DgcOm0HT.js → chunk-7TKQ45FW-DKA84YkE.js} +1 -1
- package/renderer/dist/assets/{chunk-AW2ZBBNX-C01s4yoF.js → chunk-AW2ZBBNX-Dg-SumPG.js} +1 -1
- package/renderer/dist/assets/{chunk-FVRAUYC3-Bj0zxhR4.js → chunk-FVRAUYC3-06TCvPm1.js} +2 -2
- package/renderer/dist/assets/{chunk-HTAEGDNF-DWDeEhLU.js → chunk-HTAEGDNF-CMEBy1Td.js} +1 -1
- package/renderer/dist/assets/{chunk-JWPE2WC7-Dsqwv3eW.js → chunk-JWPE2WC7-BJ2ObmV-.js} +1 -1
- package/renderer/dist/assets/{chunk-KQW6MTUR-CE86ANL2.js → chunk-KQW6MTUR-CGWOYXUE.js} +1 -1
- package/renderer/dist/assets/{chunk-XXDRQBXY-CoNTASfs.js → chunk-XXDRQBXY-CiZp07XC.js} +1 -1
- package/renderer/dist/assets/{classDiagram-v2-K4WV4PDN-2wprLqtw.js → classDiagram-v2-K4WV4PDN-DJNIX719.js} +1 -1
- package/renderer/dist/assets/cornerTag-CPgvu-q3.js +1 -0
- package/renderer/dist/assets/cornerTag-oMfEjzCO.css +1 -0
- package/renderer/dist/assets/{cose-bilkent-JH36ORCC-enqMiYbn.js → cose-bilkent-JH36ORCC-CbkyZOUH.js} +1 -1
- package/renderer/dist/assets/{cynefin-EF2NZ3EQ-DHngLxvQ.js → cynefin-EF2NZ3EQ-78iOroZO.js} +1 -1
- package/renderer/dist/assets/{cynefinDiagram-3GCD6N5R-C0rZZIl3.js → cynefinDiagram-3GCD6N5R-GcCopMs7.js} +1 -1
- package/renderer/dist/assets/{dagre-W4DXFKR2-DQnaEWO7.js → dagre-W4DXFKR2-DHLlXLkz.js} +1 -1
- package/renderer/dist/assets/{diagram-2UJZ2QOL-CS8AlGPx.js → diagram-2UJZ2QOL-DuZSUHcH.js} +1 -1
- package/renderer/dist/assets/{diagram-OVF4WLC6-yiDMHIkY.js → diagram-OVF4WLC6-Ckxu3cqH.js} +1 -1
- package/renderer/dist/assets/{diagram-PFPMY2P6-XbQKzbBA.js → diagram-PFPMY2P6-CsTw9Ycn.js} +1 -1
- package/renderer/dist/assets/{diagram-UMYRVEAY-dhVvlfl_.js → diagram-UMYRVEAY-CTXd8qVg.js} +1 -1
- package/renderer/dist/assets/{diagram-YEKJPTXX-DZHe8SCf.js → diagram-YEKJPTXX-CJs1T_9m.js} +1 -1
- package/renderer/dist/assets/{diagram-ZIFT7M5P-B5h6aXVR.js → diagram-ZIFT7M5P-CvjI23mf.js} +1 -1
- package/renderer/dist/assets/{ebnfDiagram-VR2GEFS7-PR9ScDco.js → ebnfDiagram-VR2GEFS7-d_IeAzOJ.js} +1 -1
- package/renderer/dist/assets/{elk-IJKZMXRS-Cg-yQXPd.js → elk-IJKZMXRS-zo9KFP3N.js} +1 -1
- package/renderer/dist/assets/{erDiagram-O2IAPWRE-omOrJMxu.js → erDiagram-O2IAPWRE-DqfYf5Dt.js} +1 -1
- package/renderer/dist/assets/{flowDiagram-OXPTDLAJ-aOX2hzvT.js → flowDiagram-OXPTDLAJ-DAWsMLmo.js} +1 -1
- package/renderer/dist/assets/{ganttDiagram-R7TSEDQI-B1H3pWyI.js → ganttDiagram-R7TSEDQI-4ml_bhwR.js} +1 -1
- package/renderer/dist/assets/{gitGraphDiagram-XJZIOB7I-Cvw304HC.js → gitGraphDiagram-XJZIOB7I-B_WcqSId.js} +1 -1
- package/renderer/dist/assets/{infoDiagram-5W2HQ5XZ-_ZXcNViH.js → infoDiagram-5W2HQ5XZ-CI-jYqzc.js} +1 -1
- package/renderer/dist/assets/{ishikawaDiagram-K3B6WC7H-C7Mx_qQk.js → ishikawaDiagram-K3B6WC7H-B56VWRlV.js} +1 -1
- package/renderer/dist/assets/{journeyDiagram-COXZFDF6-fr62QoAt.js → journeyDiagram-COXZFDF6-D1-p09Lr.js} +1 -1
- package/renderer/dist/assets/{kanban-definition-P3RFRI5V-J8zsabCe.js → kanban-definition-P3RFRI5V-Cv3w7JoN.js} +1 -1
- package/renderer/dist/assets/{linear-BmNdEtIi.js → linear-C35V2Spi.js} +1 -1
- package/renderer/dist/assets/main-BcoBiGwv.css +32 -0
- package/renderer/dist/assets/main-CLnIOulD.js +1191 -0
- package/renderer/dist/assets/{mermaid.core-CnYzvFkz.js → mermaid.core-BzGT5Vo9.js} +4 -4
- package/renderer/dist/assets/{mindmap-definition-LZFPQGKD-Bfcxc7PM.js → mindmap-definition-LZFPQGKD-BZUfuymC.js} +1 -1
- package/renderer/dist/assets/{pegDiagram-BGZESJAR-CwUJOq6X.js → pegDiagram-BGZESJAR-DFh9XNGe.js} +1 -1
- package/renderer/dist/assets/{pieDiagram-CAPJLFHJ-BrwMmqfC.js → pieDiagram-CAPJLFHJ-C65tPVsy.js} +1 -1
- package/renderer/dist/assets/{product-name-CTKLYNWY.js → product-name-CSNW6d7s.js} +1 -1
- package/renderer/dist/assets/{quadrantDiagram-GDMTTRAM-BN4Stoj-.js → quadrantDiagram-GDMTTRAM-Cushj_nk.js} +1 -1
- package/renderer/dist/assets/{railroadDiagram-SM67HX2B-CPZ5bbeR.js → railroadDiagram-SM67HX2B-DZGebGx5.js} +1 -1
- package/renderer/dist/assets/{requirementDiagram-X7JNWC4A-CgRnimVe.js → requirementDiagram-X7JNWC4A-DpAB0MNo.js} +1 -1
- package/renderer/dist/assets/{sankeyDiagram-UM26HJRW-Bt2rej__.js → sankeyDiagram-UM26HJRW-D-29rHra.js} +1 -1
- package/renderer/dist/assets/{sequenceDiagram-ZO4K6R2Y-Dd5nKe9G.js → sequenceDiagram-ZO4K6R2Y-FfOQAZol.js} +1 -1
- package/renderer/dist/assets/{sizeCapture-INFHLROL-usyNyXyh.js → sizeCapture-INFHLROL-C_n6fDBY.js} +1 -1
- package/renderer/dist/assets/{stateDiagram-v2-TBUQTH76-CRzUBb0u.js → stateDiagram-v2-TBUQTH76-BZjenzJG.js} +1 -1
- package/renderer/dist/assets/{swimlanes-N4OXWK64-C1GJvMS0.js → swimlanes-N4OXWK64-DxaMU9vX.js} +1 -1
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cb2kM6rr.js +8 -0
- package/renderer/dist/assets/{timeline-definition-YOQAKGHF-caeyPlcc.js → timeline-definition-YOQAKGHF-DTg5HJKz.js} +1 -1
- package/renderer/dist/assets/{usecaseDiagram-VIAY4XPW-BN_SV6PC.js → usecaseDiagram-VIAY4XPW-Bc3MZDsl.js} +1 -1
- package/renderer/dist/assets/{vennDiagram-BLWOH2XV-BLGuaUX-.js → vennDiagram-BLWOH2XV-GIVGk9Id.js} +1 -1
- package/renderer/dist/assets/{wardleyDiagram-YMQ3BMBF-Da0cJeNc.js → wardleyDiagram-YMQ3BMBF-NmJnbQOg.js} +1 -1
- package/renderer/dist/assets/{xychartDiagram-TAQBALBS-cGfUffMk.js → xychartDiagram-TAQBALBS-p-kfGfnW.js} +1 -1
- package/renderer/dist/corner-tag.html +3 -3
- package/renderer/dist/index.html +3 -3
- package/scripts/after-install.mjs +30 -8
- package/scripts/cloud-shim.pl +57 -0
- package/shared/account-names.d.mts +6 -0
- package/shared/account-names.mjs +15 -0
- package/shared/agent-hours.mjs +136 -0
- package/shared/agent-import.mjs +60 -34
- package/shared/agents.mjs +126 -522
- package/shared/analytics-events.mjs +12 -0
- package/shared/approval-card.mjs +36 -0
- package/shared/bridge-map.mjs +27 -1
- package/shared/claude-code-shape.mjs +6 -0
- package/shared/claude-usage.d.mts +1 -0
- package/shared/claude-usage.mjs +42 -1
- package/shared/cloud-hand-back.d.mts +7 -0
- package/shared/cloud-hand-back.mjs +20 -0
- package/shared/cloud-kit.d.mts +219 -0
- package/shared/cloud-kit.mjs +341 -0
- package/shared/cloud-login-name.mjs +4 -0
- package/shared/cloud-start.mjs +17 -0
- package/shared/cloud-versions.mjs +23 -0
- package/shared/codex-import.mjs +2 -2
- package/shared/codex-modes.d.mts +22 -9
- package/shared/codex-modes.mjs +67 -11
- package/shared/codex-usage.mjs +108 -2
- package/shared/corner-tag.d.mts +23 -1
- package/shared/corner-tag.mjs +284 -29
- package/shared/dead-run-trace.mjs +19 -2
- package/shared/engines.mjs +70 -309
- package/shared/feedback.d.mts +7 -0
- package/shared/feedback.mjs +25 -0
- package/shared/first-run-practice.mjs +17 -24
- package/shared/harness-definitions.mjs +8 -0
- package/shared/instruction-defaults.json +2 -2
- package/shared/key-caps.d.mts +14 -0
- package/shared/key-caps.mjs +68 -0
- package/shared/message-edits.d.mts +7 -0
- package/shared/message-edits.mjs +35 -0
- package/shared/move-thread.d.mts +24 -0
- package/shared/move-thread.mjs +112 -0
- package/shared/named-threads.mjs +72 -0
- package/shared/not-shipped.d.mts +22 -0
- package/shared/not-shipped.mjs +89 -0
- package/shared/notify-rules.d.mts +6 -0
- package/shared/notify-rules.mjs +36 -8
- package/shared/phone-files.mjs +28 -0
- package/shared/phone-settings.mjs +118 -0
- package/shared/phone-snapshot.d.mts +16 -0
- package/shared/phone-snapshot.mjs +348 -0
- package/shared/picture-formats.d.mts +1 -0
- package/shared/picture-formats.mjs +13 -0
- package/shared/rank.d.mts +2 -1
- package/shared/rank.mjs +32 -19
- package/shared/repeats.d.mts +8 -0
- package/shared/repeats.mjs +33 -2
- package/shared/room-cards.mjs +167 -0
- package/shared/room-links.d.mts +4 -0
- package/shared/room-links.mjs +21 -0
- package/shared/runs-on.d.mts +3 -3
- package/shared/runs-on.mjs +8 -5
- package/shared/shipped-note.d.mts +4 -0
- package/shared/shipped-note.mjs +6 -0
- package/shared/shortcuts.mjs +14 -6
- package/shared/spawn-trouble.d.mts +4 -0
- package/shared/spawn-trouble.mjs +443 -40
- package/shared/team-rules.mjs +9 -2
- package/shared/teammates.d.mts +19 -0
- package/shared/teammates.mjs +756 -0
- package/shared/terminal-focus.d.mts +1 -0
- package/shared/terminal-focus.mjs +13 -0
- package/shared/thread-cards.mjs +4 -1
- package/shared/usage.d.mts +9 -0
- package/shared/usage.mjs +49 -3
- package/shared/websites.d.mts +15 -0
- package/shared/websites.mjs +61 -0
- package/shared/work-items.mjs +127 -30
- package/shared/work-lines.mjs +92 -244
- package/renderer/dist/assets/channel-BlT2g9Sz.js +0 -1
- package/renderer/dist/assets/cornerTag-BLrxiDE5.css +0 -1
- package/renderer/dist/assets/cornerTag-BOT6u3Tn.js +0 -1
- package/renderer/dist/assets/main-ChNdnUvt.js +0 -1132
- package/renderer/dist/assets/main-nj0QR5kg.css +0 -32
- package/renderer/dist/assets/swimlanesDiagram-K3J5GTZL-Cy4Av2_p.js +0 -8
package/main/analytics.mjs
CHANGED
|
@@ -144,9 +144,12 @@ export function createAnalytics({ config, version, dir, client, env = process.en
|
|
|
144
144
|
|
|
145
145
|
return {
|
|
146
146
|
get enabled() { return on(); },
|
|
147
|
+
// A whole phrase, verb and all: the line that prints it reads
|
|
148
|
+
// `diagnostics ${reason}`, and a reason with no verb of its own made that
|
|
149
|
+
// "diagnostics is has nowhere to send to".
|
|
147
150
|
get reason() {
|
|
148
151
|
if (!hasDestination) return 'has nowhere to send to';
|
|
149
|
-
return diagnosticsOn(config) ? 'on' : 'turned off';
|
|
152
|
+
return diagnosticsOn(config) ? 'is on' : 'is turned off';
|
|
150
153
|
},
|
|
151
154
|
hasDestination,
|
|
152
155
|
installId,
|
|
@@ -230,7 +233,14 @@ export function createAnalytics({ config, version, dir, client, env = process.en
|
|
|
230
233
|
return sent;
|
|
231
234
|
},
|
|
232
235
|
|
|
233
|
-
|
|
236
|
+
// AND THE SWITCH IS READ HERE TOO (w-60c8ddbab6). This is a send like any
|
|
237
|
+
// other, which is note 4 at the top of this file, and until the quit
|
|
238
|
+
// started waiting for it the point was moot: a fire-and-forget flush on the
|
|
239
|
+
// way out mostly never left the machine. Now that it lands, a flush asked
|
|
240
|
+
// for after the switch went off would carry events captured before it did,
|
|
241
|
+
// and section 10 of the privacy page says turning it off "stops sending from
|
|
242
|
+
// that moment".
|
|
243
|
+
async flush() { if (!on()) return; try { await posthog?.flush?.(); } catch {} },
|
|
234
244
|
async shutdown() { try { await posthog?.shutdown?.(); } catch {} },
|
|
235
245
|
};
|
|
236
246
|
}
|
package/main/answer-modes.mjs
CHANGED
|
@@ -12,23 +12,15 @@
|
|
|
12
12
|
// was not: one hand-written line saying `"source":"founder"` granted its own
|
|
13
13
|
// next run bypassPermissions. Proved on a scratch ledger, 2026-08-24.
|
|
14
14
|
//
|
|
15
|
-
// So the grant is not on the ledger any more. It lives
|
|
16
|
-
//
|
|
17
|
-
// reaches this file and no tool that names it.
|
|
15
|
+
// So the grant is not on the ledger any more. It lives in her own folder (the
|
|
16
|
+
// desktop's user data folder), written by main and by nothing else.
|
|
18
17
|
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
// on the folder.
|
|
26
|
-
//
|
|
27
|
-
// What it changes is the COST. Before: one more line appended to a file the
|
|
28
|
-
// worker was appending to anyway, in a format it writes every few seconds,
|
|
29
|
-
// in its own working directory. After: a deliberate reach into the app's own
|
|
30
|
-
// storage, in a file nothing briefs it about and no tool it holds mentions.
|
|
31
|
-
// That is a real difference and it is the whole claim. It is not a wall.
|
|
18
|
+
// NEVER UNDER THE STORE ROOT. The app makes its own home the store root, and
|
|
19
|
+
// every Claude worker is granted that folder, so a file edit there needs no
|
|
20
|
+
// card. Outside the granted folders a write meets the same card or auto-mode
|
|
21
|
+
// check as any other reach into her home. That is the whole claim and it is
|
|
22
|
+
// not a wall: anything that can run commands can write any file this user owns
|
|
23
|
+
// (tests/a-worker-cannot-set-its-own-permission-mode).
|
|
32
24
|
//
|
|
33
25
|
// GRANTING IS THE FOUNDER'S, SPENDING ONE IS THE MACHINERY'S, AGENTS GET
|
|
34
26
|
// NEITHER. That rule survives the move intact, and it is now structural rather
|
|
@@ -36,13 +28,73 @@
|
|
|
36
28
|
// there is no `source` to check and no line for anyone else to forge.
|
|
37
29
|
|
|
38
30
|
import fs from 'node:fs';
|
|
31
|
+
import os from 'node:os';
|
|
39
32
|
import path from 'node:path';
|
|
40
|
-
import
|
|
33
|
+
import crypto from 'node:crypto';
|
|
34
|
+
import { nameSlug } from '../shared/product-name.mjs';
|
|
41
35
|
import { CLAUDE_PERMISSION_MODES } from '../shared/work-items.mjs';
|
|
42
36
|
import { CODEX_MODE_ORDER } from '../shared/codex-modes.mjs';
|
|
43
37
|
|
|
44
38
|
const FILE = 'answer-modes.json';
|
|
45
39
|
|
|
40
|
+
// Set once at start by `keepAnswerModesIn`. Until then nothing is read or kept:
|
|
41
|
+
// a process that never said where must not fall back to the store root.
|
|
42
|
+
let where = null;
|
|
43
|
+
let saidNowhere = false;
|
|
44
|
+
|
|
45
|
+
const inside = (file, dir) => {
|
|
46
|
+
const f = path.resolve(file);
|
|
47
|
+
const d = path.resolve(dir);
|
|
48
|
+
return f === d || f.startsWith(d + path.sep);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function configHome(home, env) {
|
|
52
|
+
const given = typeof env?.XDG_CONFIG_HOME === 'string' ? env.XDG_CONFIG_HOME.trim() : '';
|
|
53
|
+
return given && path.isAbsolute(given) ? given : path.join(home, '.config');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Where the app keeps these, said once at start (main/main.mjs, main/serve.mjs).
|
|
58
|
+
*
|
|
59
|
+
* In `userDir`, unless that is inside the store root, as the headless copy's
|
|
60
|
+
* is: then in a per-store folder under the config home, the way the team
|
|
61
|
+
* sign-in moves (main/team/session.mjs). If that is inside the store root too,
|
|
62
|
+
* nowhere: every thread runs on its project's mode, which is the safe side.
|
|
63
|
+
* Returns the file, or null.
|
|
64
|
+
*/
|
|
65
|
+
export function keepAnswerModesIn({ userDir, storeRoot, home = os.homedir(), env = process.env }) {
|
|
66
|
+
let target = userDir ? path.join(userDir, FILE) : null;
|
|
67
|
+
if (target && storeRoot && inside(target, storeRoot)) {
|
|
68
|
+
const tag = crypto.createHash('sha256').update(path.resolve(storeRoot)).digest('hex').slice(0, 16);
|
|
69
|
+
target = path.join(configHome(home, env), nameSlug, 'grants', tag, FILE);
|
|
70
|
+
}
|
|
71
|
+
if (!target || (storeRoot && inside(target, storeRoot))) {
|
|
72
|
+
console.warn(`zero: nowhere outside the store root (${storeRoot}) to keep what a reply may do, so every thread runs on its project's mode`);
|
|
73
|
+
target = null;
|
|
74
|
+
}
|
|
75
|
+
where = target;
|
|
76
|
+
if (storeRoot) carryFromTheStore(path.join(storeRoot, FILE));
|
|
77
|
+
return where;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export const answerModesFile = () => where;
|
|
81
|
+
|
|
82
|
+
// AN OLDER VERSION KEPT THIS IN THE STORE ROOT. Its file is carried across the
|
|
83
|
+
// first time and removed every time. The new file is created even when there is
|
|
84
|
+
// nothing to carry, because that is what makes the carry happen once: without
|
|
85
|
+
// it, a file a worker wrote into the store later would be carried at the next
|
|
86
|
+
// start.
|
|
87
|
+
function carryFromTheStore(old) {
|
|
88
|
+
let found = null;
|
|
89
|
+
try { found = fs.lstatSync(old); } catch { /* nothing there */ }
|
|
90
|
+
if (where && !fs.existsSync(where)) {
|
|
91
|
+
writeAll(where, sweep(found?.isFile() ? readAll(old) : {}, Date.now()));
|
|
92
|
+
}
|
|
93
|
+
if (found) {
|
|
94
|
+
try { fs.rmSync(old, { force: true }); } catch (err) { console.warn(`zero: could not remove ${old}: ${err.message}`); }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
46
98
|
// HOW LONG A MODE LIVES, NOW THAT IT BELONGS TO A THREAD AND NOT TO ONE SEND.
|
|
47
99
|
//
|
|
48
100
|
// This was 24 hours, and 24 hours was right while the supervisor spent the
|
|
@@ -61,21 +113,35 @@ const FILE = 'answer-modes.json';
|
|
|
61
113
|
// the file does not grow without limit. Swept on every write.
|
|
62
114
|
const KEEP_MS = 30 * 24 * 60 * 60 * 1000;
|
|
63
115
|
|
|
64
|
-
const
|
|
116
|
+
const fileFor = (home) => (home === undefined ? where : path.join(home, FILE));
|
|
65
117
|
|
|
66
|
-
|
|
118
|
+
// Never through a link, and never anything but a regular file, so a link left
|
|
119
|
+
// in place of one cannot point the read at a file somebody else writes.
|
|
120
|
+
const READ_FLAGS = fs.constants.O_RDONLY | fs.constants.O_NONBLOCK | (fs.constants.O_NOFOLLOW ?? 0);
|
|
121
|
+
|
|
122
|
+
function readAll(target) {
|
|
123
|
+
if (!target) return {};
|
|
124
|
+
let fd = null;
|
|
67
125
|
try {
|
|
68
|
-
|
|
126
|
+
fd = fs.openSync(target, READ_FLAGS);
|
|
127
|
+
if (!fs.fstatSync(fd).isFile()) return {};
|
|
128
|
+
const raw = JSON.parse(fs.readFileSync(fd, 'utf8'));
|
|
69
129
|
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
70
130
|
} catch {
|
|
71
131
|
return {};
|
|
132
|
+
} finally {
|
|
133
|
+
if (fd !== null) { try { fs.closeSync(fd); } catch { /* already closed */ } }
|
|
72
134
|
}
|
|
73
135
|
}
|
|
74
136
|
|
|
75
137
|
// tmp + rename, so a reader never sees half a file and a crash mid-write leaves
|
|
76
138
|
// the previous one intact.
|
|
77
|
-
function writeAll(
|
|
78
|
-
|
|
139
|
+
function writeAll(target, all) {
|
|
140
|
+
if (!target) {
|
|
141
|
+
if (!saidNowhere) console.warn('zero: a mode was picked with nowhere to keep it, so the thread runs on its project\'s mode');
|
|
142
|
+
saidNowhere = true;
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
79
145
|
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
80
146
|
const tmp = `${target}.${process.pid}.tmp`;
|
|
81
147
|
fs.writeFileSync(tmp, `${JSON.stringify(all, null, 2)}\n`);
|
|
@@ -123,8 +189,8 @@ function sweep(all, now) {
|
|
|
123
189
|
*/
|
|
124
190
|
const ALLOWED_MODES = [...CLAUDE_PERMISSION_MODES, ...CODEX_MODE_ORDER];
|
|
125
191
|
|
|
126
|
-
export function answerMode(slug, id, { home
|
|
127
|
-
const entry = readAll(home)[keyOf(slug, id)];
|
|
192
|
+
export function answerMode(slug, id, { home, now = Date.now() } = {}) {
|
|
193
|
+
const entry = readAll(fileFor(home))[keyOf(slug, id)];
|
|
128
194
|
if (!entry || typeof entry !== 'object') return undefined;
|
|
129
195
|
if (Number.isFinite(entry.at) && now - entry.at > KEEP_MS) return undefined;
|
|
130
196
|
if (entry.mode === null) return null;
|
|
@@ -132,9 +198,9 @@ export function answerMode(slug, id, { home = appHome(), now = Date.now() } = {}
|
|
|
132
198
|
}
|
|
133
199
|
|
|
134
200
|
/** Every row that currently carries one, as `{ "<slug>/<id>": mode }`. */
|
|
135
|
-
export function answerModes({ home
|
|
201
|
+
export function answerModes({ home, now = Date.now() } = {}) {
|
|
136
202
|
const out = {};
|
|
137
|
-
for (const [key, entry] of Object.entries(readAll(home))) {
|
|
203
|
+
for (const [key, entry] of Object.entries(readAll(fileFor(home)))) {
|
|
138
204
|
if (!entry || typeof entry !== 'object') continue;
|
|
139
205
|
if (Number.isFinite(entry.at) && now - entry.at > KEEP_MS) continue;
|
|
140
206
|
if (entry.mode === null) { out[key] = null; continue; }
|
|
@@ -150,12 +216,12 @@ export function answerModes({ home = appHome(), now = Date.now() } = {}) {
|
|
|
150
216
|
* A mode that is not one of the six is refused outright rather than stored,
|
|
151
217
|
* which is what the ledger's own check used to do.
|
|
152
218
|
*/
|
|
153
|
-
export function setAnswerMode(slug, id, mode, { home
|
|
219
|
+
export function setAnswerMode(slug, id, mode, { home, now = Date.now() } = {}) {
|
|
154
220
|
if (mode !== null && !ALLOWED_MODES.includes(mode)) return answerMode(slug, id, { home, now });
|
|
155
|
-
const all = sweep(readAll(home), now);
|
|
221
|
+
const all = sweep(readAll(fileFor(home)), now);
|
|
156
222
|
all[keyOf(slug, id)] = { mode, at: now };
|
|
157
|
-
writeAll(home, all);
|
|
158
|
-
return mode;
|
|
223
|
+
writeAll(fileFor(home), all);
|
|
224
|
+
return fileFor(home) ? mode : undefined;
|
|
159
225
|
}
|
|
160
226
|
|
|
161
227
|
/**
|
|
@@ -170,14 +236,14 @@ export function setAnswerMode(slug, id, mode, { home = appHome(), now = Date.now
|
|
|
170
236
|
* the worst this call can do is keep a grant she already made alive one more
|
|
171
237
|
* month.
|
|
172
238
|
*/
|
|
173
|
-
export function touchAnswerMode(slug, id, used, { home
|
|
174
|
-
const all = sweep(readAll(home), now);
|
|
239
|
+
export function touchAnswerMode(slug, id, used, { home, now = Date.now() } = {}) {
|
|
240
|
+
const all = sweep(readAll(fileFor(home)), now);
|
|
175
241
|
const key = keyOf(slug, id);
|
|
176
242
|
const entry = all[key];
|
|
177
243
|
if (!entry || typeof entry !== 'object') return undefined;
|
|
178
244
|
if (used !== undefined && entry.mode !== used) return entry.mode;
|
|
179
245
|
all[key] = { mode: entry.mode, at: now };
|
|
180
|
-
writeAll(home, all);
|
|
246
|
+
writeAll(fileFor(home), all);
|
|
181
247
|
return entry.mode;
|
|
182
248
|
}
|
|
183
249
|
|
|
@@ -193,14 +259,14 @@ export function touchAnswerMode(slug, id, used, { home = appHome(), now = Date.n
|
|
|
193
259
|
* NOW, for the same reason as above. Clearing is not "less": it means back to
|
|
194
260
|
* the project's setting, which can be the wider one.
|
|
195
261
|
*/
|
|
196
|
-
export function clearAnswerMode(slug, id, spent, { home
|
|
197
|
-
const all = sweep(readAll(home), now);
|
|
262
|
+
export function clearAnswerMode(slug, id, spent, { home, now = Date.now() } = {}) {
|
|
263
|
+
const all = sweep(readAll(fileFor(home)), now);
|
|
198
264
|
const key = keyOf(slug, id);
|
|
199
265
|
const current = all[key]?.mode;
|
|
200
266
|
if (spent !== undefined && current !== spent) return current === undefined ? undefined : current;
|
|
201
267
|
all[key] = { mode: null, at: now };
|
|
202
|
-
writeAll(home, all);
|
|
268
|
+
writeAll(fileFor(home), all);
|
|
203
269
|
return null;
|
|
204
270
|
}
|
|
205
271
|
|
|
206
|
-
export const _internals = { file, KEEP_MS };
|
|
272
|
+
export const _internals = { file: fileFor, KEEP_MS, useFile: (f) => { where = f ?? null; } };
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// The approvals channel: a tiny MCP server that Claude Code calls (via
|
|
2
|
+
// --permission-prompt-tool) whenever a headless worker wants to do something
|
|
3
|
+
// its standing grants do not cover. The request lands as a file in the spool,
|
|
4
|
+
// the app shows the founder a card, her click writes the answer file, and the
|
|
5
|
+
// worker unblocks in place. Before this, a gated command ended the story:
|
|
6
|
+
// sessions wrote everything and bounced the actual run back to the founder
|
|
7
|
+
// as a paste (2026-08-05).
|
|
8
|
+
//
|
|
9
|
+
// Deny is the default in every failure mode: the run ending, a spool error, a
|
|
10
|
+
// malformed answer. An approval can only come from the founder's explicit file,
|
|
11
|
+
// and a silence is not one: the question waits for her rather than being
|
|
12
|
+
// answered by a clock.
|
|
13
|
+
//
|
|
14
|
+
// EVERY TOUCH OF THE SPOOL GOES THROUGH main/approvals.mjs, which is where the
|
|
15
|
+
// rule that this directory is worker-writable is written down and enforced --
|
|
16
|
+
// nothing opened through a link, nothing opened that is not a regular file,
|
|
17
|
+
// every write landed by rename. It used to be bare `fs` here, and the audit
|
|
18
|
+
// line was the worst of it: `log.jsonl` is a fixed name, so a worker that
|
|
19
|
+
// symlinked it at `briefs/founder.md` had a repeatable append of its own
|
|
20
|
+
// `command` string into the standing instructions every session is briefed
|
|
21
|
+
// with. This process runs as the worker's own child, so it is the near half of
|
|
22
|
+
// that attack (2026-09-04).
|
|
23
|
+
|
|
24
|
+
import fs from 'node:fs';
|
|
25
|
+
import crypto from 'node:crypto';
|
|
26
|
+
import readline from 'node:readline';
|
|
27
|
+
import { answerIsHers, appendSpoolLine, cardDigest, readSpoolJson, removeSpoolEntry, spoolEntryExists, writeSpoolFile } from './approvals.mjs';
|
|
28
|
+
|
|
29
|
+
const POLL_MS = 2_000;
|
|
30
|
+
|
|
31
|
+
// A QUESTION IS NOT AN APPROVAL. Workers launch without AskUserQuestion
|
|
32
|
+
// (Supervisor.prepareClaudePermissions); one that arrives here anyway is
|
|
33
|
+
// turned back to the thread before any card exists, because the card has
|
|
34
|
+
// nowhere to put an answer (w-ab1f2f1d82).
|
|
35
|
+
const ASK_IN_THE_THREAD = 'Questions are not asked here. Finish what you can without the answer, then end your turn with the question as your last message and its choices listed under Options. Their reply comes back to this session.';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One approvals channel, over `input` and `output` (a process's own stdio, or
|
|
39
|
+
* a cloud run's connection when many share one process: main/cloud/run.mjs).
|
|
40
|
+
* `env` is that worker's: the spool, the public key, and which row it is.
|
|
41
|
+
*/
|
|
42
|
+
export function serveApprovals({ env = process.env, input = process.stdin, output = process.stdout, warn = console.warn } = {}) {
|
|
43
|
+
const SPOOL = env.ZERO_APPROVALS_DIR;
|
|
44
|
+
// THE KEY THAT SAYS AN ANSWER IS THE USER'S, AND IT IS SAFE TO HAND US.
|
|
45
|
+
//
|
|
46
|
+
// This channel is the worker's own, so its environment is the worker's to
|
|
47
|
+
// read and anything secret given here is a secret the forger has. This is the
|
|
48
|
+
// PUBLIC half of a pair whose private half never leaves Agentbox's process: it
|
|
49
|
+
// verifies and it cannot sign, which is exactly why it can travel this way.
|
|
50
|
+
// main/approvals.mjs holds the whole argument, including what it does not
|
|
51
|
+
// close.
|
|
52
|
+
const PUBLIC_KEY = env.ZERO_APPROVALS_PUBKEY;
|
|
53
|
+
// Closed when its run has gone: a question still waiting stops at once.
|
|
54
|
+
let closed = false;
|
|
55
|
+
const sleepers = new Set();
|
|
56
|
+
const nap = () => new Promise((resolve) => {
|
|
57
|
+
const wake = () => { clearTimeout(timer); sleepers.delete(wake); resolve(); };
|
|
58
|
+
const timer = setTimeout(wake, POLL_MS);
|
|
59
|
+
sleepers.add(wake);
|
|
60
|
+
});
|
|
61
|
+
const send = (msg) => { if (!output.writableEnded && !output.destroyed) output.write(JSON.stringify(msg) + '\n'); };
|
|
62
|
+
async function decide(args) {
|
|
63
|
+
try {
|
|
64
|
+
if (args?.tool_name === 'AskUserQuestion') return { behavior: 'deny', message: ASK_IN_THE_THREAD };
|
|
65
|
+
if (!SPOOL) return { behavior: 'deny', message: 'approvals spool is not configured' };
|
|
66
|
+
// NO KEY IS A CHANNEL THAT CANNOT TELL HER ANSWER FROM A WORKER'S, and a
|
|
67
|
+
// channel that cannot tell must not guess. It refuses here rather than
|
|
68
|
+
// asking and then denying in fifteen minutes, because nothing that arrives
|
|
69
|
+
// in that quarter of an hour could become an approval anyway.
|
|
70
|
+
if (!PUBLIC_KEY) return { behavior: 'deny', message: 'the approvals channel was given no key to check the founder\'s answer with, so nothing here can be approved' };
|
|
71
|
+
fs.mkdirSync(SPOOL, { recursive: true });
|
|
72
|
+
const id = crypto.randomUUID();
|
|
73
|
+
const request = {
|
|
74
|
+
id,
|
|
75
|
+
at: Date.now(),
|
|
76
|
+
product: env.ZERO_PRODUCT ?? null,
|
|
77
|
+
item: env.ZERO_ITEM ?? null,
|
|
78
|
+
tool: args.tool_name ?? 'unknown',
|
|
79
|
+
input: args.input ?? {},
|
|
80
|
+
};
|
|
81
|
+
// A question that could not be asked has already been answered.
|
|
82
|
+
if (!writeSpoolFile(SPOOL, `${id}.request.json`, JSON.stringify(request))) {
|
|
83
|
+
return { behavior: 'deny', message: 'the approvals spool would not take this request, so nothing was put in front of the founder' };
|
|
84
|
+
}
|
|
85
|
+
const answerName = `${id}.answer.json`;
|
|
86
|
+
let saidItCouldNotGoBack = false;
|
|
87
|
+
// A QUESTION WAITS FOR ITS ANSWER (w-4b00022acb). This gave up after
|
|
88
|
+
// fifteen minutes and told the run it had been refused, which is not
|
|
89
|
+
// what a silence means. The thread reads Asking and sits in Needs you
|
|
90
|
+
// while this waits (w-fe48447cab), and the run that asked is the bound:
|
|
91
|
+
// its end closes this channel and the loop stops with it.
|
|
92
|
+
while (!closed) {
|
|
93
|
+
// `lstat` rather than `existsSync`, so this sees exactly what the app's
|
|
94
|
+
// own `listPending` sees: an entry by that name, whatever it is.
|
|
95
|
+
if (spoolEntryExists(SPOOL, answerName)) {
|
|
96
|
+
const answer = readSpoolJson(SPOOL, answerName);
|
|
97
|
+
// AN ANSWER THAT IS NOT HERS IS NOT A DECISION AT ALL, and this is the
|
|
98
|
+
// line the whole file turns on. It used to read `answer?.allow` off
|
|
99
|
+
// whatever was in the directory, so any process that could write here
|
|
100
|
+
// was approved -- another worker's card, or its own.
|
|
101
|
+
//
|
|
102
|
+
// AND IT IS NOT A DENY EITHER, which matters as much: denying on an
|
|
103
|
+
// unreadable answer would let one worker force a No onto another's
|
|
104
|
+
// card, losing her Allow in silence. The file goes and the question
|
|
105
|
+
// goes on waiting, exactly as main/codex-approvals.mjs's own sweep
|
|
106
|
+
// does, and her real answer still lands when she gives it.
|
|
107
|
+
if (!answerIsHers(id, answer, PUBLIC_KEY)) {
|
|
108
|
+
removeSpoolEntry(SPOOL, answerName);
|
|
109
|
+
warn(`zero: an answer for approval ${id} that the founder did not write has been deleted; the question is still waiting`);
|
|
110
|
+
await nap();
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
// AND HER DECISION IS ABOUT AN ACTION, NOT ABOUT AN ID. The request
|
|
114
|
+
// file is worker-writable and its name and id survive a rewrite of its
|
|
115
|
+
// contents, so a worker could raise one command, replace the body with
|
|
116
|
+
// a harmless one, and have her Allow on the harmless card honoured
|
|
117
|
+
// against the command THIS PROCESS is still holding. `request` here is
|
|
118
|
+
// that memory: it was built from the tool call and has never been read
|
|
119
|
+
// back off the disk, so comparing it against the digest she signed is
|
|
120
|
+
// the whole check. A mismatch is a deny -- the user said yes to something
|
|
121
|
+
// else -- and it is loud, because only a rewrite can produce it.
|
|
122
|
+
const mine = cardDigest(request);
|
|
123
|
+
if (!mine || answer.card !== mine) {
|
|
124
|
+
finish(id, request, { allow: false, swapped: true });
|
|
125
|
+
warn(`zero: approval ${id} was answered for a different card than the one this run asked about; the request file was rewritten under it and nothing has been allowed`);
|
|
126
|
+
return {
|
|
127
|
+
behavior: 'deny',
|
|
128
|
+
message: 'The approval request changed between being asked and being answered, so the founder\'s decision was for a different action and nothing was allowed. Do not retry the same request this session: finish what your grants allow, and file the rest as a review with the exact commands.',
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
finish(id, request, answer);
|
|
132
|
+
if (answer.allow) return { behavior: 'allow', updatedInput: args.input ?? {} };
|
|
133
|
+
return { behavior: 'deny', message: answer.note || 'The founder declined this action. Work with what you are already allowed to do, or park the remainder as a review.' };
|
|
134
|
+
}
|
|
135
|
+
// THE SPOOL REAPS WHAT NOBODY IS WAITING ON, AND THIS IS HOW IT KNOWS.
|
|
136
|
+
// The app's `listPending` cannot see this loop, and this loop outlives
|
|
137
|
+
// its REQUEST_TTL_MS, so whoever waits keeps their own request there.
|
|
138
|
+
// Written from the `request` this process has held all along, never
|
|
139
|
+
// read back off the disk, so what comes back is the card that went.
|
|
140
|
+
if (!spoolEntryExists(SPOOL, `${id}.request.json`)
|
|
141
|
+
&& !writeSpoolFile(SPOOL, `${id}.request.json`, JSON.stringify(request))
|
|
142
|
+
&& !saidItCouldNotGoBack) {
|
|
143
|
+
// Failing here leaves this run parked with nothing on screen, which
|
|
144
|
+
// is the one way the wait goes bad, so it is said out loud. Once:
|
|
145
|
+
// this loop comes round every two seconds.
|
|
146
|
+
saidItCouldNotGoBack = true;
|
|
147
|
+
warn(`zero: approval ${id} left the spool and could not be put back, so this run is waiting with no card on screen`);
|
|
148
|
+
}
|
|
149
|
+
await nap();
|
|
150
|
+
}
|
|
151
|
+
finish(id, request, { allow: false, gone: true });
|
|
152
|
+
return { behavior: 'deny', message: 'the run that asked has ended' };
|
|
153
|
+
} catch (err) {
|
|
154
|
+
return { behavior: 'deny', message: `approvals channel error: ${err.message}` };
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// One audit line per decision; the request/answer pair leaves the spool.
|
|
159
|
+
function finish(id, request, answer) {
|
|
160
|
+
appendSpoolLine(SPOOL, 'log.jsonl', `${JSON.stringify({ ...request, answer, decidedAt: Date.now() })}\n`);
|
|
161
|
+
for (const f of [`${id}.request.json`, `${id}.answer.json`]) removeSpoolEntry(SPOOL, f);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const rl = readline.createInterface({ input });
|
|
165
|
+
rl.on('line', async (line) => {
|
|
166
|
+
let msg;
|
|
167
|
+
try { msg = JSON.parse(line); } catch { return; }
|
|
168
|
+
const { id, method, params } = msg;
|
|
169
|
+
if (method === 'initialize') {
|
|
170
|
+
send({ jsonrpc: '2.0', id, result: {
|
|
171
|
+
protocolVersion: params?.protocolVersion ?? '2024-11-05',
|
|
172
|
+
capabilities: { tools: {} },
|
|
173
|
+
serverInfo: { name: 'zero-approvals', version: '1.0.0' },
|
|
174
|
+
} });
|
|
175
|
+
} else if (method === 'tools/list') {
|
|
176
|
+
send({ jsonrpc: '2.0', id, result: { tools: [{
|
|
177
|
+
name: 'approval_prompt',
|
|
178
|
+
description: 'Asks the founder, live in her inbox, to approve one gated action. The run stops here until she answers, however long that takes. Use only when the work genuinely cannot proceed without it.',
|
|
179
|
+
inputSchema: {
|
|
180
|
+
type: 'object',
|
|
181
|
+
properties: {
|
|
182
|
+
tool_name: { type: 'string' },
|
|
183
|
+
input: { type: 'object' },
|
|
184
|
+
tool_use_id: { type: 'string' },
|
|
185
|
+
},
|
|
186
|
+
required: ['tool_name', 'input'],
|
|
187
|
+
},
|
|
188
|
+
}] } });
|
|
189
|
+
} else if (method === 'tools/call' && params?.name === 'approval_prompt') {
|
|
190
|
+
const result = await decide(params.arguments ?? {});
|
|
191
|
+
send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(result) }] } });
|
|
192
|
+
} else if (id !== undefined) {
|
|
193
|
+
send({ jsonrpc: '2.0', id, result: {} });
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
return { close: () => { closed = true; for (const wake of [...sleepers]) wake(); rl.close(); } };
|
|
197
|
+
}
|
|
@@ -19,142 +19,8 @@
|
|
|
19
19
|
// with. This process runs as the worker's own child, so it is the near half of
|
|
20
20
|
// that attack (2026-09-04).
|
|
21
21
|
|
|
22
|
-
import
|
|
23
|
-
import crypto from 'node:crypto';
|
|
24
|
-
import readline from 'node:readline';
|
|
25
|
-
import { answerIsHers, appendSpoolLine, cardDigest, readSpoolJson, removeSpoolEntry, spoolEntryExists, writeSpoolFile } from './approvals.mjs';
|
|
22
|
+
import { serveApprovals } from './approval-prompt-core.mjs';
|
|
26
23
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
//
|
|
31
|
-
// This process is the worker's own child, so its environment is the worker's to
|
|
32
|
-
// read and anything secret given here is a secret the forger has. This is the
|
|
33
|
-
// PUBLIC half of a pair whose private half never leaves Agentbox's process: it
|
|
34
|
-
// verifies and it cannot sign, which is exactly why it can travel this way.
|
|
35
|
-
// main/approvals.mjs holds the whole argument, including what it does not
|
|
36
|
-
// close.
|
|
37
|
-
const PUBLIC_KEY = process.env.ZERO_APPROVALS_PUBKEY;
|
|
38
|
-
|
|
39
|
-
const TIMEOUT_MS = 15 * 60_000;
|
|
40
|
-
const POLL_MS = 2_000;
|
|
41
|
-
|
|
42
|
-
const send = (msg) => process.stdout.write(JSON.stringify(msg) + '\n');
|
|
43
|
-
|
|
44
|
-
async function decide(args) {
|
|
45
|
-
try {
|
|
46
|
-
if (!SPOOL) return { behavior: 'deny', message: 'approvals spool is not configured' };
|
|
47
|
-
// NO KEY IS A CHANNEL THAT CANNOT TELL HER ANSWER FROM A WORKER'S, and a
|
|
48
|
-
// channel that cannot tell must not guess. It refuses here rather than
|
|
49
|
-
// asking and then denying in fifteen minutes, because nothing that arrives
|
|
50
|
-
// in that quarter of an hour could become an approval anyway.
|
|
51
|
-
if (!PUBLIC_KEY) return { behavior: 'deny', message: 'the approvals channel was given no key to check the founder\'s answer with, so nothing here can be approved' };
|
|
52
|
-
fs.mkdirSync(SPOOL, { recursive: true });
|
|
53
|
-
const id = crypto.randomUUID();
|
|
54
|
-
const request = {
|
|
55
|
-
id,
|
|
56
|
-
at: Date.now(),
|
|
57
|
-
product: process.env.ZERO_PRODUCT ?? null,
|
|
58
|
-
item: process.env.ZERO_ITEM ?? null,
|
|
59
|
-
tool: args.tool_name ?? 'unknown',
|
|
60
|
-
input: args.input ?? {},
|
|
61
|
-
};
|
|
62
|
-
// A question that could not be asked has already been answered.
|
|
63
|
-
if (!writeSpoolFile(SPOOL, `${id}.request.json`, JSON.stringify(request))) {
|
|
64
|
-
return { behavior: 'deny', message: 'the approvals spool would not take this request, so nothing was put in front of the founder' };
|
|
65
|
-
}
|
|
66
|
-
const answerName = `${id}.answer.json`;
|
|
67
|
-
const deadline = Date.now() + TIMEOUT_MS;
|
|
68
|
-
while (Date.now() < deadline) {
|
|
69
|
-
// `lstat` rather than `existsSync`, so this sees exactly what the app's
|
|
70
|
-
// own `listPending` sees: an entry by that name, whatever it is.
|
|
71
|
-
if (spoolEntryExists(SPOOL, answerName)) {
|
|
72
|
-
const answer = readSpoolJson(SPOOL, answerName);
|
|
73
|
-
// AN ANSWER THAT IS NOT HERS IS NOT A DECISION AT ALL, and this is the
|
|
74
|
-
// line the whole file turns on. It used to read `answer?.allow` off
|
|
75
|
-
// whatever was in the directory, so any process that could write here
|
|
76
|
-
// was approved -- another worker's card, or its own.
|
|
77
|
-
//
|
|
78
|
-
// AND IT IS NOT A DENY EITHER, which matters as much: denying on an
|
|
79
|
-
// unreadable answer would let one worker force a No onto another's
|
|
80
|
-
// card, losing her Allow in silence. The file goes and the question
|
|
81
|
-
// goes on waiting, exactly as main/codex-approvals.mjs's own sweep
|
|
82
|
-
// does, and her real answer still lands when she gives it.
|
|
83
|
-
if (!answerIsHers(id, answer, PUBLIC_KEY)) {
|
|
84
|
-
removeSpoolEntry(SPOOL, answerName);
|
|
85
|
-
console.warn(`zero: an answer for approval ${id} that the founder did not write has been deleted; the question is still waiting`);
|
|
86
|
-
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
87
|
-
continue;
|
|
88
|
-
}
|
|
89
|
-
// AND HER DECISION IS ABOUT AN ACTION, NOT ABOUT AN ID. The request
|
|
90
|
-
// file is worker-writable and its name and id survive a rewrite of its
|
|
91
|
-
// contents, so a worker could raise one command, replace the body with
|
|
92
|
-
// a harmless one, and have her Allow on the harmless card honoured
|
|
93
|
-
// against the command THIS PROCESS is still holding. `request` here is
|
|
94
|
-
// that memory: it was built from the tool call and has never been read
|
|
95
|
-
// back off the disk, so comparing it against the digest she signed is
|
|
96
|
-
// the whole check. A mismatch is a deny -- the user said yes to something
|
|
97
|
-
// else -- and it is loud, because only a rewrite can produce it.
|
|
98
|
-
const mine = cardDigest(request);
|
|
99
|
-
if (!mine || answer.card !== mine) {
|
|
100
|
-
finish(id, request, { allow: false, swapped: true });
|
|
101
|
-
console.warn(`zero: approval ${id} was answered for a different card than the one this run asked about; the request file was rewritten under it and nothing has been allowed`);
|
|
102
|
-
return {
|
|
103
|
-
behavior: 'deny',
|
|
104
|
-
message: 'The approval request changed between being asked and being answered, so the founder\'s decision was for a different action and nothing was allowed. Do not retry the same request this session: finish what your grants allow, and file the rest as a review with the exact commands.',
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
finish(id, request, answer);
|
|
108
|
-
if (answer.allow) return { behavior: 'allow', updatedInput: args.input ?? {} };
|
|
109
|
-
return { behavior: 'deny', message: answer.note || 'The founder declined this action. Work with what you are already allowed to do, or park the remainder as a review.' };
|
|
110
|
-
}
|
|
111
|
-
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
112
|
-
}
|
|
113
|
-
finish(id, request, { allow: false, timeout: true });
|
|
114
|
-
return {
|
|
115
|
-
behavior: 'deny',
|
|
116
|
-
message: 'No answer from the founder within 15 minutes. Do not retry the same request this session: finish what your grants allow, and file the rest as a review with the exact commands.',
|
|
117
|
-
};
|
|
118
|
-
} catch (err) {
|
|
119
|
-
return { behavior: 'deny', message: `approvals channel error: ${err.message}` };
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// One audit line per decision; the request/answer pair leaves the spool.
|
|
124
|
-
function finish(id, request, answer) {
|
|
125
|
-
appendSpoolLine(SPOOL, 'log.jsonl', `${JSON.stringify({ ...request, answer, decidedAt: Date.now() })}\n`);
|
|
126
|
-
for (const f of [`${id}.request.json`, `${id}.answer.json`]) removeSpoolEntry(SPOOL, f);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
const rl = readline.createInterface({ input: process.stdin });
|
|
130
|
-
rl.on('line', async (line) => {
|
|
131
|
-
let msg;
|
|
132
|
-
try { msg = JSON.parse(line); } catch { return; }
|
|
133
|
-
const { id, method, params } = msg;
|
|
134
|
-
if (method === 'initialize') {
|
|
135
|
-
send({ jsonrpc: '2.0', id, result: {
|
|
136
|
-
protocolVersion: params?.protocolVersion ?? '2024-11-05',
|
|
137
|
-
capabilities: { tools: {} },
|
|
138
|
-
serverInfo: { name: 'zero-approvals', version: '1.0.0' },
|
|
139
|
-
} });
|
|
140
|
-
} else if (method === 'tools/list') {
|
|
141
|
-
send({ jsonrpc: '2.0', id, result: { tools: [{
|
|
142
|
-
name: 'approval_prompt',
|
|
143
|
-
description: 'Asks the founder, live in her inbox, to approve one gated action. She answers within minutes when present; a 15-minute silence is a deny. Use only when the work genuinely cannot proceed without it.',
|
|
144
|
-
inputSchema: {
|
|
145
|
-
type: 'object',
|
|
146
|
-
properties: {
|
|
147
|
-
tool_name: { type: 'string' },
|
|
148
|
-
input: { type: 'object' },
|
|
149
|
-
tool_use_id: { type: 'string' },
|
|
150
|
-
},
|
|
151
|
-
required: ['tool_name', 'input'],
|
|
152
|
-
},
|
|
153
|
-
}] } });
|
|
154
|
-
} else if (method === 'tools/call' && params?.name === 'approval_prompt') {
|
|
155
|
-
const result = await decide(params.arguments ?? {});
|
|
156
|
-
send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: JSON.stringify(result) }] } });
|
|
157
|
-
} else if (id !== undefined) {
|
|
158
|
-
send({ jsonrpc: '2.0', id, result: {} });
|
|
159
|
-
}
|
|
160
|
-
});
|
|
24
|
+
// Run as its own process by Claude Code for a run on this Mac; a cloud run's
|
|
25
|
+
// relay serves the same channel in-process (main/cloud/run.mjs).
|
|
26
|
+
serveApprovals();
|