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,206 @@
|
|
|
1
|
+
// THE ONLY PLACE ANYTHING WE RECEIVE LEAVES THIS MACHINE. One file, the main
|
|
2
|
+
// process, and a list of seven counts it will refuse to grow past.
|
|
3
|
+
//
|
|
4
|
+
// IT IS NOT THE ONLY TRAFFIC ANY MORE, and the header said so until 2026-08-29.
|
|
5
|
+
// `main/updater.mjs` asks github.com a few times a day whether a newer Agentbox
|
|
6
|
+
// has been released, which reaches us not at all and is nothing to do with this
|
|
7
|
+
// file. `legal/privacy.html` 5.5 is where that is written down.
|
|
8
|
+
//
|
|
9
|
+
// Her yes: "yes to posthog, go ahead", on the eight counts she approved the
|
|
10
|
+
// same day and under her rule from 08-18: no code, no prompts, no keys, no
|
|
11
|
+
// paths. `legal/privacy.html` sections 4, 5 and 10 are the promise this file
|
|
12
|
+
// has to keep, so read them before changing anything here.
|
|
13
|
+
//
|
|
14
|
+
// FIVE THINGS ABOUT THE SHAPE, each of them load-bearing:
|
|
15
|
+
//
|
|
16
|
+
// 1. NEVER `posthog-js`, AND NEVER IN THE RENDERER. The browser SDK autocaptures
|
|
17
|
+
// the text of whatever was clicked, and in Agentbox that text is task titles
|
|
18
|
+
// and repo names. `posthog-node` takes only what it is handed. A test fails
|
|
19
|
+
// the build if `posthog-js` ever appears under `renderer/`.
|
|
20
|
+
// 2. The renderer cannot send. It hands a NAME over IPC and this file decides
|
|
21
|
+
// whether that name is one of hers (`shared/analytics-events.mjs`).
|
|
22
|
+
// 3. NOTHING IS SENT WITHOUT A DESTINATION AND THE SWITCH. No key configured
|
|
23
|
+
// means no client is ever constructed. The shipped app carries a key baked
|
|
24
|
+
// into its own package.json at build time; a copy run from source carries
|
|
25
|
+
// none and so sends nothing at all, which is still the honest default.
|
|
26
|
+
// 4. THE SWITCH IS READ AT EVERY SEND, never captured at boot. Section 10 of
|
|
27
|
+
// the privacy page says turning it off "stops sending from that moment", and
|
|
28
|
+
// an `enabled` flag read once at startup would keep sending until the app
|
|
29
|
+
// was restarted, which is that sentence being false for as long as she left
|
|
30
|
+
// the window open.
|
|
31
|
+
// 5. Crash reports come through the seam in `main/crash-report.mjs`, already
|
|
32
|
+
// scrubbed, and they are checked AGAIN here before they go. Two checks, on
|
|
33
|
+
// purpose: the first one runs while the process is dying.
|
|
34
|
+
|
|
35
|
+
import os from 'node:os';
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import { createRequire } from 'node:module';
|
|
38
|
+
import { sanitize } from '../shared/analytics-events.mjs';
|
|
39
|
+
import { findPrivate } from '../shared/crash-scrub.mjs';
|
|
40
|
+
import { readInstallId } from '../shared/install-id.mjs';
|
|
41
|
+
import { readEnv } from '../shared/product-name.mjs';
|
|
42
|
+
|
|
43
|
+
// PostHog's own default host. Kept here rather than left to the config so a
|
|
44
|
+
// typo cannot silently point a stranger's crashes somewhere else; `posthogHost`
|
|
45
|
+
// exists only for a self-hosted instance.
|
|
46
|
+
const DEFAULT_HOST = 'https://us.i.posthog.com';
|
|
47
|
+
|
|
48
|
+
// ONE SETTING, ON WHEN YOU INSTALL AGENTBOX (privacy page, section 10). Stored as
|
|
49
|
+
// an opt-OUT so that a config she has never touched means on, and so turning it
|
|
50
|
+
// off is a real, visible key in her own file rather than an absence.
|
|
51
|
+
export function diagnosticsOn(config) {
|
|
52
|
+
return config?.diagnostics !== false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// THE KEY THE DOWNLOAD CARRIES. Baked into the packaged `package.json` by
|
|
56
|
+
// `scripts/release.mjs` at build time (electron-builder's `extraMetadata`), so a
|
|
57
|
+
// stranger's install has somewhere to send and a copy run from source still does
|
|
58
|
+
// not. It is a PostHog project key, which is write-only by design: it can add an
|
|
59
|
+
// event and it cannot read one back, which is why it may sit inside a file
|
|
60
|
+
// anybody can unzip. The one that reads, `phx_`, never goes near a build.
|
|
61
|
+
//
|
|
62
|
+
// Measured 2026-08-21: before this, the key came only from the user's own
|
|
63
|
+
// `zero.config.json`, which no download contains, so every install but hers
|
|
64
|
+
// sent nothing and the north star could not be counted.
|
|
65
|
+
function bakedKey() {
|
|
66
|
+
try {
|
|
67
|
+
const pkg = createRequire(import.meta.url)('../package.json');
|
|
68
|
+
return pkg?.bakedPosthogKey ?? null;
|
|
69
|
+
} catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const BAKED = bakedKey();
|
|
75
|
+
|
|
76
|
+
// Where it would go, if it went anywhere. A missing key is not an error and not
|
|
77
|
+
// a warning: it is a copy of Agentbox with nowhere to send to, which is a build
|
|
78
|
+
// run from source.
|
|
79
|
+
//
|
|
80
|
+
// HER OWN FILE STILL WINS. A `posthogKey` in `zero.config.json` outranks the
|
|
81
|
+
// baked one, so pointing a copy at a different project stays a one-line edit,
|
|
82
|
+
// and `diagnostics: false` still turns all of it off at every send.
|
|
83
|
+
export function analyticsKey(config, baked = BAKED) {
|
|
84
|
+
const key = config?.posthogKey ?? readEnv('POSTHOG_KEY') ?? baked ?? null;
|
|
85
|
+
return typeof key === 'string' && key.trim() ? key.trim() : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createAnalytics({ config, version, dir, client, env = process.env } = {}) {
|
|
89
|
+
const key = analyticsKey(config);
|
|
90
|
+
const hasDestination = !!key || !!client;
|
|
91
|
+
// BOTH HALVES, AT EVERY SEND. See note 4 above.
|
|
92
|
+
const on = () => hasDestination && diagnosticsOn(config);
|
|
93
|
+
|
|
94
|
+
// `env` is here for ONE reason: a copy launched into a throwaway home is a
|
|
95
|
+
// first-run test of hers and sends under a fixed id rather than a fresh
|
|
96
|
+
// random one. The decision lives in `shared/install-id.mjs` with the
|
|
97
|
+
// identifier itself, because two copies of it is how the counts and the
|
|
98
|
+
// crash reports start telling different stories.
|
|
99
|
+
const installId = dir ? readInstallId(dir, env) : null;
|
|
100
|
+
// The version pair from section 5.1, on every event, set here and never by a
|
|
101
|
+
// caller. `os.release` is the kernel release, which is what the page means by
|
|
102
|
+
// the version of macOS and carries nothing about the machine or its owner.
|
|
103
|
+
const base = {
|
|
104
|
+
app_version: version || '0.0.0',
|
|
105
|
+
os_platform: process.platform,
|
|
106
|
+
os_release: os.release(),
|
|
107
|
+
os_arch: process.arch,
|
|
108
|
+
};
|
|
109
|
+
// What the second scrub check is run against. Cheap, and it is the only thing
|
|
110
|
+
// standing between a bug in the first check and her promise.
|
|
111
|
+
const ctx = {
|
|
112
|
+
home: os.homedir(),
|
|
113
|
+
username: (() => { try { return os.userInfo().username; } catch { return null; } })(),
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
let posthog = client ?? null;
|
|
117
|
+
const load = () => {
|
|
118
|
+
if (posthog) return posthog;
|
|
119
|
+
try {
|
|
120
|
+
// Required lazily, so a machine with no key and every test in this repo
|
|
121
|
+
// never constructs a client and never opens a socket.
|
|
122
|
+
const { PostHog } = createRequire(import.meta.url)('posthog-node');
|
|
123
|
+
posthog = new PostHog(key, { host: config?.posthogHost || DEFAULT_HOST, flushAt: 20, flushInterval: 10000 });
|
|
124
|
+
} catch (err) {
|
|
125
|
+
console.warn('zero: analytics could not start:', err.message);
|
|
126
|
+
posthog = null;
|
|
127
|
+
}
|
|
128
|
+
return posthog;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
const send = (event, properties) => {
|
|
132
|
+
if (!on()) return false;
|
|
133
|
+
const ph = load();
|
|
134
|
+
if (!ph) return false;
|
|
135
|
+
try {
|
|
136
|
+
ph.capture({ distinctId: installId, event, properties: { ...base, ...properties } });
|
|
137
|
+
return true;
|
|
138
|
+
} catch (err) {
|
|
139
|
+
// A failed send is never allowed to be a failed app.
|
|
140
|
+
console.warn('zero: analytics send failed:', err.message);
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
get enabled() { return on(); },
|
|
147
|
+
get reason() {
|
|
148
|
+
if (!hasDestination) return 'has nowhere to send to';
|
|
149
|
+
return diagnosticsOn(config) ? 'on' : 'turned off';
|
|
150
|
+
},
|
|
151
|
+
hasDestination,
|
|
152
|
+
installId,
|
|
153
|
+
|
|
154
|
+
// ONE DOOR. `name` must be one of hers, and the properties are filtered to
|
|
155
|
+
// numbers, booleans and one closed enum. Anything else is dropped rather
|
|
156
|
+
// than sent, because the caller is often the renderer.
|
|
157
|
+
track(name, props) {
|
|
158
|
+
if (!on()) return false;
|
|
159
|
+
const clean = sanitize(name, props);
|
|
160
|
+
if (!clean) return false;
|
|
161
|
+
return send(name, clean);
|
|
162
|
+
},
|
|
163
|
+
|
|
164
|
+
// Handed to `setTransport` in `main/crash-report.mjs`. The payload arrives
|
|
165
|
+
// already scrubbed and already checked; it is checked once more here, and a
|
|
166
|
+
// report that fails is dropped whole rather than trimmed, which is the rule
|
|
167
|
+
// the scrub itself follows.
|
|
168
|
+
crashTransport(payload) {
|
|
169
|
+
if (!on()) return false;
|
|
170
|
+
if (!payload || typeof payload !== 'object') return false;
|
|
171
|
+
const leaks = findPrivate(payload, ctx);
|
|
172
|
+
if (leaks.length) {
|
|
173
|
+
return send('crash_report', { crash_kind: 'redacted', dropped_fields: leaks.length });
|
|
174
|
+
}
|
|
175
|
+
return send('crash_report', {
|
|
176
|
+
crash_kind: String(payload.kind ?? 'unknown'),
|
|
177
|
+
up_ms: Number(payload.upMs ?? 0),
|
|
178
|
+
report_version: Number(payload.v ?? 1),
|
|
179
|
+
error: payload.error ?? null,
|
|
180
|
+
happened_at: String(payload.ts ?? ''),
|
|
181
|
+
});
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
// A report is written while the process is dying, so delivery cannot be
|
|
185
|
+
// synchronous: the queue on disk is drained on the NEXT launch. A file is
|
|
186
|
+
// removed only once its send has been accepted, and a file that will not
|
|
187
|
+
// parse is removed rather than retried forever.
|
|
188
|
+
drainCrashes(files = []) {
|
|
189
|
+
if (!on()) return 0;
|
|
190
|
+
let sent = 0;
|
|
191
|
+
for (const file of files) {
|
|
192
|
+
let payload = null;
|
|
193
|
+
try { payload = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { payload = null; }
|
|
194
|
+
if (!payload) { try { fs.unlinkSync(file); } catch {} continue; }
|
|
195
|
+
if (this.crashTransport(payload)) {
|
|
196
|
+
sent += 1;
|
|
197
|
+
try { fs.unlinkSync(file); } catch {}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return sent;
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
async flush() { try { await posthog?.flush?.(); } catch {} },
|
|
204
|
+
async shutdown() { try { await posthog?.shutdown?.(); } catch {} },
|
|
205
|
+
};
|
|
206
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// WHAT ONE REPLY MAY DO, KEPT WHERE A WORKER DOES NOT WRITE.
|
|
2
|
+
//
|
|
3
|
+
// When the founder answers a card she can also say what that one run is allowed
|
|
4
|
+
// to do on her Mac. That value becomes `--permission-mode` on a real process,
|
|
5
|
+
// so it is a GRANT and not an opinion about the work.
|
|
6
|
+
//
|
|
7
|
+
// IT USED TO RIDE ON THE LEDGER. `work-items.jsonl` is append-only, every line
|
|
8
|
+
// names its own author as a plain string, and nothing checks that string.
|
|
9
|
+
// Workers append to that file all day through the MCP; on a product with no
|
|
10
|
+
// code repo registered the file also sat in the worker's own cwd. The structured
|
|
11
|
+
// path was closed (an agent line carrying the field was stripped), and the file
|
|
12
|
+
// was not: one hand-written line saying `"source":"founder"` granted its own
|
|
13
|
+
// next run bypassPermissions. Proved on a scratch ledger, 2026-08-24.
|
|
14
|
+
//
|
|
15
|
+
// So the grant is not on the ledger any more. It lives here, in the app's own
|
|
16
|
+
// home, written by main and by nothing else. The MCP server has no code that
|
|
17
|
+
// reaches this file and no tool that names it.
|
|
18
|
+
//
|
|
19
|
+
// WHAT THIS DOES AND WHAT IT DOES NOT DO, said plainly.
|
|
20
|
+
//
|
|
21
|
+
// It does NOT make forging impossible. Anything that can run commands can
|
|
22
|
+
// write any file this user owns, and `~/.astral` is no exception. Claude Code
|
|
23
|
+
// has the identical property: its settings file is as writable as anything
|
|
24
|
+
// else in your home, and it relies on the sandbox and the prompt rather than
|
|
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.
|
|
32
|
+
//
|
|
33
|
+
// GRANTING IS THE FOUNDER'S, SPENDING ONE IS THE MACHINERY'S, AGENTS GET
|
|
34
|
+
// NEITHER. That rule survives the move intact, and it is now structural rather
|
|
35
|
+
// than enforced: there is no writer here except the app's own main process, so
|
|
36
|
+
// there is no `source` to check and no line for anyone else to forge.
|
|
37
|
+
|
|
38
|
+
import fs from 'node:fs';
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import { appHome } from './store/home.mjs';
|
|
41
|
+
import { CLAUDE_PERMISSION_MODES } from '../shared/work-items.mjs';
|
|
42
|
+
import { CODEX_MODE_ORDER } from '../shared/codex-modes.mjs';
|
|
43
|
+
|
|
44
|
+
const FILE = 'answer-modes.json';
|
|
45
|
+
|
|
46
|
+
// HOW LONG A MODE LIVES, NOW THAT IT BELONGS TO A THREAD AND NOT TO ONE SEND.
|
|
47
|
+
//
|
|
48
|
+
// This was 24 hours, and 24 hours was right while the supervisor spent the
|
|
49
|
+
// grant on the first spawn: anything still here the next day had been spent or
|
|
50
|
+
// belonged to a row nobody ever ran. Neither is true any more. A mode she sets
|
|
51
|
+
// on a thread governs every run on that thread until she moves it, so a sweep
|
|
52
|
+
// measured from the moment she picked it would quietly hand a thread back to
|
|
53
|
+
// the fleet's mode overnight, which is the bug this round exists to fix,
|
|
54
|
+
// arriving a day late instead of a run late.
|
|
55
|
+
//
|
|
56
|
+
// So the clock is not "when she picked it" but "when this thread last used
|
|
57
|
+
// it": `touchAnswerMode` below restamps `at` on every run that reads one, and
|
|
58
|
+
// the window is a month. A thread she is working in keeps its mode for as long
|
|
59
|
+
// as she works in it. A row that has been silent for a month loses its grant
|
|
60
|
+
// rather than keeping a bypassPermissions she granted in another quarter, and
|
|
61
|
+
// the file does not grow without limit. Swept on every write.
|
|
62
|
+
const KEEP_MS = 30 * 24 * 60 * 60 * 1000;
|
|
63
|
+
|
|
64
|
+
const file = (home = appHome()) => path.join(home, FILE);
|
|
65
|
+
|
|
66
|
+
function readAll(home) {
|
|
67
|
+
try {
|
|
68
|
+
const raw = JSON.parse(fs.readFileSync(file(home), 'utf8'));
|
|
69
|
+
return raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
|
|
70
|
+
} catch {
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// tmp + rename, so a reader never sees half a file and a crash mid-write leaves
|
|
76
|
+
// the previous one intact.
|
|
77
|
+
function writeAll(home, all) {
|
|
78
|
+
const target = file(home);
|
|
79
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
80
|
+
const tmp = `${target}.${process.pid}.tmp`;
|
|
81
|
+
fs.writeFileSync(tmp, `${JSON.stringify(all, null, 2)}\n`);
|
|
82
|
+
fs.renameSync(tmp, target);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const keyOf = (slug, id) => `${slug}/${id}`;
|
|
86
|
+
|
|
87
|
+
function sweep(all, now) {
|
|
88
|
+
for (const [key, entry] of Object.entries(all)) {
|
|
89
|
+
if (!entry || typeof entry !== 'object' || !Number.isFinite(entry.at) || now - entry.at > KEEP_MS) {
|
|
90
|
+
delete all[key];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return all;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The mode set on one row's reply.
|
|
98
|
+
*
|
|
99
|
+
* `undefined` means she never touched the chip. `null` means she cleared it, or
|
|
100
|
+
* the run that spent it cleared it, and the project's own setting applies. The
|
|
101
|
+
* two are kept apart because the ledger kept them apart and every reader of the
|
|
102
|
+
* old field already knows the difference.
|
|
103
|
+
*
|
|
104
|
+
* A value that is not one of Claude Code's six is dropped rather than returned,
|
|
105
|
+
* so nothing that is not a mode can reach a command line even if this file were
|
|
106
|
+
* edited by hand.
|
|
107
|
+
*/
|
|
108
|
+
/**
|
|
109
|
+
* EVERY WORD THIS FILE WILL STORE: Claude Code's six and Codex's three.
|
|
110
|
+
*
|
|
111
|
+
* ONE FIELD FOR BOTH ENGINES, because a row has one engine and the reply box
|
|
112
|
+
* can only ever offer that engine's list. Each consumer then filters by its
|
|
113
|
+
* own vocabulary and ignores the other's: `spawnPlan` keeps only a Claude mode
|
|
114
|
+
* (`CLAUDE_MODES.includes`), `codexThreadParamsFor` keeps only a Codex one
|
|
115
|
+
* (`isCodexMode`). A word meant for the other engine is therefore invisible
|
|
116
|
+
* rather than wrong.
|
|
117
|
+
*
|
|
118
|
+
* `auto` IS ON BOTH LISTS AND THAT IS THE ONE OVERLAP. It means the sensible
|
|
119
|
+
* default on each engine, so the only way to be bitten is to set it on a Codex
|
|
120
|
+
* row and then have the task picked up by Claude Code, which would run Claude
|
|
121
|
+
* Code's auto. Both are the default that engine already wanted; the failure is
|
|
122
|
+
* benign and is written here so nobody spends an afternoon proving it.
|
|
123
|
+
*/
|
|
124
|
+
const ALLOWED_MODES = [...CLAUDE_PERMISSION_MODES, ...CODEX_MODE_ORDER];
|
|
125
|
+
|
|
126
|
+
export function answerMode(slug, id, { home = appHome(), now = Date.now() } = {}) {
|
|
127
|
+
const entry = readAll(home)[keyOf(slug, id)];
|
|
128
|
+
if (!entry || typeof entry !== 'object') return undefined;
|
|
129
|
+
if (Number.isFinite(entry.at) && now - entry.at > KEEP_MS) return undefined;
|
|
130
|
+
if (entry.mode === null) return null;
|
|
131
|
+
return ALLOWED_MODES.includes(entry.mode) ? entry.mode : undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Every row that currently carries one, as `{ "<slug>/<id>": mode }`. */
|
|
135
|
+
export function answerModes({ home = appHome(), now = Date.now() } = {}) {
|
|
136
|
+
const out = {};
|
|
137
|
+
for (const [key, entry] of Object.entries(readAll(home))) {
|
|
138
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
139
|
+
if (Number.isFinite(entry.at) && now - entry.at > KEEP_MS) continue;
|
|
140
|
+
if (entry.mode === null) { out[key] = null; continue; }
|
|
141
|
+
if (ALLOWED_MODES.includes(entry.mode)) out[key] = entry.mode;
|
|
142
|
+
}
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Set one, or clear it with null. Called only from the founder's own reply
|
|
148
|
+
* (main/store.mjs, answerItem), which is only reachable from the reply box.
|
|
149
|
+
*
|
|
150
|
+
* A mode that is not one of the six is refused outright rather than stored,
|
|
151
|
+
* which is what the ledger's own check used to do.
|
|
152
|
+
*/
|
|
153
|
+
export function setAnswerMode(slug, id, mode, { home = appHome(), now = Date.now() } = {}) {
|
|
154
|
+
if (mode !== null && !ALLOWED_MODES.includes(mode)) return answerMode(slug, id, { home, now });
|
|
155
|
+
const all = sweep(readAll(home), now);
|
|
156
|
+
all[keyOf(slug, id)] = { mode, at: now };
|
|
157
|
+
writeAll(home, all);
|
|
158
|
+
return mode;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Say that this thread just used its mode, so the sweep above measures from
|
|
163
|
+
* the last run rather than from the moment she picked it.
|
|
164
|
+
*
|
|
165
|
+
* IT RESTAMPS THE MODE THAT IS THERE, NEVER THE ONE THE RUN WAS BUILT WITH.
|
|
166
|
+
* The supervisor reads the mode when it builds the launch and touches on the
|
|
167
|
+
* child's own 'spawn' event, so there is a window in between, and a second
|
|
168
|
+
* answer landing inside it must survive untouched: `used` is compared and a
|
|
169
|
+
* mismatch is left exactly as it stands. Nothing here ever changes a mode, so
|
|
170
|
+
* the worst this call can do is keep a grant she already made alive one more
|
|
171
|
+
* month.
|
|
172
|
+
*/
|
|
173
|
+
export function touchAnswerMode(slug, id, used, { home = appHome(), now = Date.now() } = {}) {
|
|
174
|
+
const all = sweep(readAll(home), now);
|
|
175
|
+
const key = keyOf(slug, id);
|
|
176
|
+
const entry = all[key];
|
|
177
|
+
if (!entry || typeof entry !== 'object') return undefined;
|
|
178
|
+
if (used !== undefined && entry.mode !== used) return entry.mode;
|
|
179
|
+
all[key] = { mode: entry.mode, at: now };
|
|
180
|
+
writeAll(home, all);
|
|
181
|
+
return entry.mode;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Put a row back on the project's setting.
|
|
186
|
+
*
|
|
187
|
+
* THIS IS NO LONGER CALLED ON SPAWN. Until 2026-09-24 the supervisor called it
|
|
188
|
+
* the moment a run came up, which is what made a picked mode last exactly one
|
|
189
|
+
* send; `touchAnswerMode` stands in that place now. The only thing that clears
|
|
190
|
+
* a mode today is she does, through the reply box's own clear row.
|
|
191
|
+
*
|
|
192
|
+
* IT CLEARS THE MODE IT WAS TOLD ABOUT, NEVER WHATEVER HAPPENS TO BE THERE
|
|
193
|
+
* NOW, for the same reason as above. Clearing is not "less": it means back to
|
|
194
|
+
* the project's setting, which can be the wider one.
|
|
195
|
+
*/
|
|
196
|
+
export function clearAnswerMode(slug, id, spent, { home = appHome(), now = Date.now() } = {}) {
|
|
197
|
+
const all = sweep(readAll(home), now);
|
|
198
|
+
const key = keyOf(slug, id);
|
|
199
|
+
const current = all[key]?.mode;
|
|
200
|
+
if (spent !== undefined && current !== spent) return current === undefined ? undefined : current;
|
|
201
|
+
all[key] = { mode: null, at: now };
|
|
202
|
+
writeAll(home, all);
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export const _internals = { file, KEEP_MS };
|
|
@@ -0,0 +1,160 @@
|
|
|
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: timeout, spool error, malformed
|
|
10
|
+
// answer. An approval can only come from the founder's explicit file.
|
|
11
|
+
//
|
|
12
|
+
// EVERY TOUCH OF THE SPOOL GOES THROUGH main/approvals.mjs, which is where the
|
|
13
|
+
// rule that this directory is worker-writable is written down and enforced --
|
|
14
|
+
// nothing opened through a link, nothing opened that is not a regular file,
|
|
15
|
+
// every write landed by rename. It used to be bare `fs` here, and the audit
|
|
16
|
+
// line was the worst of it: `log.jsonl` is a fixed name, so a worker that
|
|
17
|
+
// symlinked it at `briefs/founder.md` had a repeatable append of its own
|
|
18
|
+
// `command` string into the standing instructions every session is briefed
|
|
19
|
+
// with. This process runs as the worker's own child, so it is the near half of
|
|
20
|
+
// that attack (2026-09-04).
|
|
21
|
+
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
import readline from 'node:readline';
|
|
25
|
+
import { answerIsHers, appendSpoolLine, cardDigest, readSpoolJson, removeSpoolEntry, spoolEntryExists, writeSpoolFile } from './approvals.mjs';
|
|
26
|
+
|
|
27
|
+
const SPOOL = process.env.ZERO_APPROVALS_DIR;
|
|
28
|
+
|
|
29
|
+
// THE KEY THAT SAYS AN ANSWER IS HERS, AND IT IS SAFE TO HAND US.
|
|
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 -- she 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
|
+
});
|