create-walle 0.9.25 → 0.9.26
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 +8 -0
- package/bin/create-walle.js +815 -45
- package/package.json +2 -2
- package/template/bin/ctm-dev-cleanup.js +90 -4
- package/template/bin/ctm-launch.sh +49 -1
- package/template/bin/dev.sh +45 -1
- package/template/bin/ensure-stable-node.js +132 -0
- package/template/bin/install-service.sh +9 -0
- package/template/claude-task-manager/api-prompts.js +899 -119
- package/template/claude-task-manager/approval-agent.js +360 -40
- package/template/claude-task-manager/bin/ctm-disclaim.c +42 -0
- package/template/claude-task-manager/bin/ctm-hotkey.swift +67 -81
- package/template/claude-task-manager/bin/ctm-screen-auth.swift +37 -0
- package/template/claude-task-manager/bin/install-hotkey.sh +97 -49
- package/template/claude-task-manager/bin/restart-ctm.sh +14 -0
- package/template/claude-task-manager/db.js +399 -48
- package/template/claude-task-manager/docs/approval-hook-sandbox.md +84 -0
- package/template/claude-task-manager/docs/codex-app-server-approvals.md +72 -0
- package/template/claude-task-manager/docs/codex-native-sandbox.md +47 -0
- package/template/claude-task-manager/docs/prompt-editing-tree-design.md +18 -1
- package/template/claude-task-manager/lib/approval-hook.js +200 -0
- package/template/claude-task-manager/lib/approval-self-adapt.js +1 -0
- package/template/claude-task-manager/lib/auth-rules.js +11 -0
- package/template/claude-task-manager/lib/background-llm.js +32 -4
- package/template/claude-task-manager/lib/codesign-identity.js +140 -0
- package/template/claude-task-manager/lib/codex-app-server-client.js +119 -0
- package/template/claude-task-manager/lib/codex-approval-bridge.js +118 -0
- package/template/claude-task-manager/lib/codex-history-terminal-renderer.js +571 -0
- package/template/claude-task-manager/lib/codex-paths.js +73 -0
- package/template/claude-task-manager/lib/codex-rollout-snapshot.js +164 -0
- package/template/claude-task-manager/lib/codex-rollout-tail.js +72 -0
- package/template/claude-task-manager/lib/codex-sandbox-args.js +47 -0
- package/template/claude-task-manager/lib/coding-agent-models.js +118 -71
- package/template/claude-task-manager/lib/command-targets.js +163 -0
- package/template/claude-task-manager/lib/conversation-tail-merge.js +61 -19
- package/template/claude-task-manager/lib/db-owner-worker-client.js +29 -1
- package/template/claude-task-manager/lib/escalation-review.js +80 -3
- package/template/claude-task-manager/lib/flow-control.js +52 -0
- package/template/claude-task-manager/lib/fs-watcher.js +24 -15
- package/template/claude-task-manager/lib/ingest-cooldown.js +68 -0
- package/template/claude-task-manager/lib/jsonl-conversation-parser.js +8 -4
- package/template/claude-task-manager/lib/launchd-recovery.js +92 -0
- package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +207 -52
- package/template/claude-task-manager/lib/mobile-push-store.js +7 -0
- package/template/claude-task-manager/lib/model-overview-brain-fallback.js +102 -1
- package/template/claude-task-manager/lib/model-overview-cache.js +1 -0
- package/template/claude-task-manager/lib/oauth-proxy-supervisor.js +2 -1
- package/template/claude-task-manager/lib/perf-tracker.js +29 -2
- package/template/claude-task-manager/lib/permission-match.js +146 -16
- package/template/claude-task-manager/lib/project-slug.js +33 -0
- package/template/claude-task-manager/lib/prompt-intent.js +51 -4
- package/template/claude-task-manager/lib/read-pool-client.js +48 -3
- package/template/claude-task-manager/lib/real-node.js +73 -0
- package/template/claude-task-manager/lib/runtime-work-registry.js +131 -14
- package/template/claude-task-manager/lib/session-content-backfill.js +24 -5
- package/template/claude-task-manager/lib/session-diagnostics-batch.js +87 -0
- package/template/claude-task-manager/lib/session-history.js +5 -7
- package/template/claude-task-manager/lib/session-host-manager.js +19 -0
- package/template/claude-task-manager/lib/session-jobs.js +6 -0
- package/template/claude-task-manager/lib/session-message-response-cache.js +89 -0
- package/template/claude-task-manager/lib/session-messages-page.js +211 -0
- package/template/claude-task-manager/lib/session-messages-projection.js +170 -0
- package/template/claude-task-manager/lib/session-standup.js +8 -0
- package/template/claude-task-manager/lib/session-timeline-summary.js +16 -2
- package/template/claude-task-manager/lib/session-token-usage.js +30 -8
- package/template/claude-task-manager/lib/session-workspace-binding.js +29 -15
- package/template/claude-task-manager/lib/storage-migration.js +2 -1
- package/template/claude-task-manager/lib/transcript-store.js +179 -12
- package/template/claude-task-manager/lib/walle-ctm-history.js +298 -11
- package/template/claude-task-manager/lib/walle-permission-reply.js +49 -0
- package/template/claude-task-manager/lib/walle-session-cache.js +22 -1
- package/template/claude-task-manager/lib/walle-supervisor.js +42 -3
- package/template/claude-task-manager/package.json +5 -2
- package/template/claude-task-manager/prompt-harvest.js +31 -11
- package/template/claude-task-manager/providers/claude-code.js +29 -1
- package/template/claude-task-manager/providers/codex.js +13 -1
- package/template/claude-task-manager/public/css/setup.css +11 -0
- package/template/claude-task-manager/public/css/walle-session.css +132 -4
- package/template/claude-task-manager/public/css/walle.css +89 -0
- package/template/claude-task-manager/public/icon-16.png +0 -0
- package/template/claude-task-manager/public/icon-32.png +0 -0
- package/template/claude-task-manager/public/icon-512.png +0 -0
- package/template/claude-task-manager/public/index.html +2483 -165
- package/template/claude-task-manager/public/js/activation-render-check.js +55 -0
- package/template/claude-task-manager/public/js/flow-control-policy.js +52 -0
- package/template/claude-task-manager/public/js/message-renderer.js +60 -1
- package/template/claude-task-manager/public/js/prompts.js +13 -1
- package/template/claude-task-manager/public/js/session-status-precedence.js +9 -3
- package/template/claude-task-manager/public/js/setup.js +54 -10
- package/template/claude-task-manager/public/js/stream-resize-policy.js +80 -0
- package/template/claude-task-manager/public/js/stream-view.js +78 -0
- package/template/claude-task-manager/public/js/terminal-reconciler.js +52 -2
- package/template/claude-task-manager/public/js/tool-state.js +155 -0
- package/template/claude-task-manager/public/js/walle-session.js +887 -326
- package/template/claude-task-manager/public/js/walle.js +306 -195
- package/template/claude-task-manager/public/m/app.css +1 -0
- package/template/claude-task-manager/public/m/app.js +33 -3
- package/template/claude-task-manager/queue-engine.js +45 -1
- package/template/claude-task-manager/server.js +3367 -540
- package/template/claude-task-manager/workers/approval-blocklist.js +130 -17
- package/template/claude-task-manager/workers/db-owner-worker.js +31 -1
- package/template/claude-task-manager/workers/read-pool-worker.js +92 -5
- package/template/claude-task-manager/workers/session-host-process.js +10 -0
- package/template/claude-task-manager/workers/state-detectors/codex.js +58 -7
- package/template/package.json +2 -3
- package/template/shared/icons/AppIcon-ctm.icns +0 -0
- package/template/shared/icons/AppIcon-walle.icns +0 -0
- package/template/wall-e/agent.js +139 -18
- package/template/wall-e/api-walle.js +201 -22
- package/template/wall-e/bin/train-gemma-e4b-tooluse.js +1981 -0
- package/template/wall-e/brain.js +1049 -39
- package/template/wall-e/chat.js +427 -86
- package/template/wall-e/coding/acceptance-contract.js +26 -1
- package/template/wall-e/coding/action-memory-policy.js +353 -0
- package/template/wall-e/coding/action-memory-store.js +814 -0
- package/template/wall-e/coding/initial-messages.js +197 -0
- package/template/wall-e/coding/no-progress-guard.js +327 -0
- package/template/wall-e/coding/permission-service.js +88 -22
- package/template/wall-e/coding/session-workspaces.js +81 -0
- package/template/wall-e/coding/shell-sandbox.js +124 -0
- package/template/wall-e/coding/stream-processor.js +63 -2
- package/template/wall-e/coding/tool-execution-controller.js +14 -1
- package/template/wall-e/coding/tool-registry.js +1 -1
- package/template/wall-e/coding/transcript-writer.js +3 -0
- package/template/wall-e/coding-orchestrator.js +636 -35
- package/template/wall-e/coding-prompts.js +51 -2
- package/template/wall-e/docs/model-routing-policy.md +59 -0
- package/template/wall-e/docs/walle-shell-sandbox.md +61 -0
- package/template/wall-e/extraction/knowledge-extractor.js +76 -23
- package/template/wall-e/http/chat-api.js +30 -12
- package/template/wall-e/http/model-admin.js +93 -1
- package/template/wall-e/lib/background-lanes.js +133 -0
- package/template/wall-e/lib/boot-profile.js +11 -0
- package/template/wall-e/lib/brain-owner-worker-client.js +324 -0
- package/template/wall-e/lib/brain-read-pool-client.js +311 -0
- package/template/wall-e/lib/diagnostics-flags.js +87 -0
- package/template/wall-e/lib/event-loop-monitor.js +74 -3
- package/template/wall-e/lib/mcp-integration.js +7 -1
- package/template/wall-e/lib/real-node.js +98 -0
- package/template/wall-e/lib/runtime-health.js +206 -0
- package/template/wall-e/lib/runtime-worker-pool.js +101 -0
- package/template/wall-e/lib/scheduler-worker-jobs.js +231 -0
- package/template/wall-e/lib/scheduler.js +446 -17
- package/template/wall-e/lib/service-health.js +61 -2
- package/template/wall-e/lib/service-readiness.js +258 -0
- package/template/wall-e/lib/usage.js +152 -0
- package/template/wall-e/lib/worker-thread-pool.js +389 -0
- package/template/wall-e/llm/client.js +81 -4
- package/template/wall-e/llm/default-fallback.js +54 -8
- package/template/wall-e/llm/mlx.js +536 -73
- package/template/wall-e/llm/mlx.plugin.json +1 -1
- package/template/wall-e/llm/ollama.js +342 -43
- package/template/wall-e/llm/provider-error.js +18 -1
- package/template/wall-e/llm/provider-health-state.js +176 -0
- package/template/wall-e/llm/routing-policy.js +796 -0
- package/template/wall-e/llm/supported-models.js +5 -0
- package/template/wall-e/loops/tasks.js +60 -14
- package/template/wall-e/loops/think.js +89 -24
- package/template/wall-e/mcp-server.js +192 -28
- package/template/wall-e/server.js +32 -7
- package/template/wall-e/skills/script-skill-runner.js +8 -1
- package/template/wall-e/skills/skill-planner.js +64 -1
- package/template/wall-e/tools/builtin-middleware.js +67 -2
- package/template/wall-e/tools/local-tools.js +116 -26
- package/template/wall-e/tools/permission-checker.js +52 -4
- package/template/wall-e/tools/permission-rules.js +36 -0
- package/template/wall-e/tools/shell-analyzer.js +46 -1
- package/template/wall-e/training/gemma-e4b-qlora.js +314 -0
- package/template/wall-e/training/real-trajectory-miner.js +2617 -0
- package/template/wall-e/training/replay-eval-analysis.js +151 -0
- package/template/wall-e/training/run-shell-command-selector.js +277 -0
- package/template/wall-e/training/tool-sft-dataset.js +312 -0
- package/template/wall-e/training/tool-sft-renderers.js +144 -0
- package/template/wall-e/training/tool-trace-harvester.js +1440 -0
- package/template/wall-e/training/trajectory-action-selector.js +364 -0
- package/template/wall-e/weather-runtime.js +232 -0
- package/template/wall-e/workers/brain-owner-worker.js +162 -0
- package/template/wall-e/workers/brain-read-worker.js +148 -0
- package/template/wall-e/workers/runtime-worker.js +145 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Tier 3: containment via Claude's native Bash sandbox (Phase 4)
|
|
2
|
+
|
|
3
|
+
## Why this shape (not CTM-side sandboxing)
|
|
4
|
+
|
|
5
|
+
The research consensus is that approval prompts should be the *escalation path out of a
|
|
6
|
+
sandbox*, not the primary defense — Anthropic's own sandboxing cut permission prompts
|
|
7
|
+
~84%. But **CTM does not execute the agent's tool commands** — Claude does, in its own
|
|
8
|
+
process — so CTM cannot wrap individual command execution from outside.
|
|
9
|
+
|
|
10
|
+
The clean, low-risk lever is **Claude Code's built-in OS-level Bash sandbox** (macOS
|
|
11
|
+
Seatbelt; Linux bubblewrap+socat), configured through `settings.json` — which CTM
|
|
12
|
+
*already injects* for hook-governed sessions. So Phase 4 just turns that sandbox on for
|
|
13
|
+
CTM sessions. Containment is enforced by the OS on the running process, so it holds
|
|
14
|
+
even when an auto-approved command does more than its name suggests — exactly the
|
|
15
|
+
defense-in-depth the allowlist alone cannot give.
|
|
16
|
+
|
|
17
|
+
It even fits CTM's worktree workflow: Claude's sandbox special-cases linked git
|
|
18
|
+
worktrees (it allows writes to the main repo's shared `.git` so `git commit` works).
|
|
19
|
+
|
|
20
|
+
## What shipped
|
|
21
|
+
|
|
22
|
+
- `buildClaudeHookSettings(port, { sandbox })`: when `sandbox` is truthy, the injected
|
|
23
|
+
`--settings` also carries `sandbox` — `true` → `{ enabled: true }` (safe default:
|
|
24
|
+
keeps the unsandboxed-fallback escape hatch, warns-not-fails if the sandbox can't
|
|
25
|
+
start), or a full sandbox config object passed through verbatim.
|
|
26
|
+
- server.js: gated on a **separate** env flag `CTM_APPROVAL_HOOK_SANDBOX=1` (in addition
|
|
27
|
+
to `CTM_APPROVAL_HOOK=1`). **Default OFF.**
|
|
28
|
+
|
|
29
|
+
## Why a separate, default-off flag
|
|
30
|
+
|
|
31
|
+
Enabling the sandbox changes execution semantics and can add friction:
|
|
32
|
+
- **Network**: no domains are pre-allowed; the first time a command needs a new domain
|
|
33
|
+
it prompts (which, for us, routes back through the hook). A build that fetches from
|
|
34
|
+
many hosts will generate approvals until the domains are allow-listed via
|
|
35
|
+
`sandbox.network.allowedDomains`.
|
|
36
|
+
- **Writes outside cwd**: tools that write to `~/.kube`, `~/.cache`, etc. need
|
|
37
|
+
`sandbox.filesystem.allowWrite` entries or they fail (then fall back / retry
|
|
38
|
+
unsandboxed via the escape hatch).
|
|
39
|
+
- **Known-incompatible tools** (`docker`, `watchman`/`jest --watch`, some Go CLIs on
|
|
40
|
+
macOS TLS) need `excludedCommands`.
|
|
41
|
+
|
|
42
|
+
So this must be validated against the user's real projects before it goes on the live
|
|
43
|
+
workbench. The default-off flag makes it explicit opt-in, exactly like `CTM_APPROVAL_HOOK`.
|
|
44
|
+
|
|
45
|
+
## Validated (2026-06-12)
|
|
46
|
+
|
|
47
|
+
Confirmed on this machine that Claude's Seatbelt sandbox **contains writes**: a
|
|
48
|
+
`touch` to the home directory was blocked ("file writes restricted to the allowed
|
|
49
|
+
working directory"), while writes to the cwd succeed. Notably this held even *without*
|
|
50
|
+
CTM's flag — Claude Code 2.1.x already confines Bash writes to the cwd here (default /
|
|
51
|
+
org policy). So on this machine the `CTM_APPROVAL_HOOK_SANDBOX` flag is **redundant**
|
|
52
|
+
(the underlying sandbox is already active) and was intentionally **not** enabled on the
|
|
53
|
+
primary — flipping a redundant flag via a disruptive restart buys nothing. The flag's
|
|
54
|
+
value is for environments where Claude's sandbox is **not** already on; the capability
|
|
55
|
+
is built, tested, and proven to work.
|
|
56
|
+
|
|
57
|
+
## How to enable + validate
|
|
58
|
+
|
|
59
|
+
1. On `/ctm-dev` (or a throwaway session), set both flags:
|
|
60
|
+
`CTM_APPROVAL_HOOK=1 CTM_APPROVAL_HOOK_SANDBOX=1`.
|
|
61
|
+
2. Drive a real build / test / git workflow; confirm commands run (Seatbelt contains
|
|
62
|
+
them) and that legitimate network/write needs are either allowed by default (cwd +
|
|
63
|
+
temp + worktree `.git`) or surfaced as approvals you can allow-list.
|
|
64
|
+
3. Add project-specific `sandbox.filesystem.allowWrite` / `sandbox.network.allowedDomains`
|
|
65
|
+
/ `excludedCommands` as needed (a future increment can let CTM compose these per
|
|
66
|
+
project). Then flip the env flag on the primary (launchd plist) like CTM_APPROVAL_HOOK.
|
|
67
|
+
|
|
68
|
+
## Hardening options (not enabled by default — they can break legit use)
|
|
69
|
+
|
|
70
|
+
- `sandbox.filesystem.denyRead: ["~/.ssh", "~/.aws"]` — the default read policy still
|
|
71
|
+
allows reading credential files; deny them if the agent never needs to. (aws/gcloud
|
|
72
|
+
CLIs read `~/.aws`/`~/.config/gcloud`, so denying breaks them — scope carefully.)
|
|
73
|
+
- `sandbox.allowUnsandboxedCommands: false` (strict) — removes the escape hatch so a
|
|
74
|
+
command can't be retried outside the sandbox. Strong, but turns "incompatible tool"
|
|
75
|
+
into a hard failure; pair with `excludedCommands`.
|
|
76
|
+
- `sandbox.failIfUnavailable: true` — refuse to start unsandboxed (managed-deployment
|
|
77
|
+
gate). Default false (warn + run unsandboxed) is the safer interactive default.
|
|
78
|
+
|
|
79
|
+
## Reference
|
|
80
|
+
|
|
81
|
+
Claude Code sandbox docs: https://code.claude.com/docs/en/sandboxing and the
|
|
82
|
+
`sandbox.*` keys in https://code.claude.com/docs/en/settings#sandbox-settings.
|
|
83
|
+
The same primitives are the standalone `@anthropic-ai/sandbox-runtime` (used to wrap
|
|
84
|
+
the *whole* agent process — a heavier alternative we deliberately did not take).
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# Codex app-server approval bridge (Phase 3)
|
|
2
|
+
|
|
3
|
+
## Why this is a *bridge foundation*, not a drop-in like Claude hooks
|
|
4
|
+
|
|
5
|
+
Claude Code lets an **external** process (CTM) answer approvals via a PreToolUse HTTP
|
|
6
|
+
hook **while the user keeps using the TUI**. Codex has **no equivalent TUI-compatible
|
|
7
|
+
hook**. Its programmatic surface is `codex app-server` (JSON-RPC over stdio), and the
|
|
8
|
+
Codex TUI *is itself* an app-server client. So routing Codex approvals through CTM's
|
|
9
|
+
classifier requires CTM to **be** the app-server client — i.e. own the conversation —
|
|
10
|
+
which means a *CTM-driven Codex mode* (CTM renders the turn/items), not an interception
|
|
11
|
+
layer under the existing PTY TUI.
|
|
12
|
+
|
|
13
|
+
That is a larger product decision than the Claude work. This phase therefore delivers
|
|
14
|
+
the **correctness-critical protocol layer** — tested and validated against the real
|
|
15
|
+
binary — so the driven-mode wiring is a straightforward follow-on, not a research task.
|
|
16
|
+
|
|
17
|
+
## What shipped
|
|
18
|
+
|
|
19
|
+
- `lib/codex-approval-bridge.js` — pure mapping (unit-tested, 13 cases):
|
|
20
|
+
- `parseCodexApprovalRequest(method, params)` normalizes the **five** server→client
|
|
21
|
+
approval methods to `{ kind, protocol, context }` for the shared `decideApproval`
|
|
22
|
+
cascade. Handles both protocol generations the 0.132.0 binary emits:
|
|
23
|
+
- **v2:** `item/commandExecution/requestApproval`, `item/fileChange/requestApproval`,
|
|
24
|
+
`item/permissions/requestApproval`
|
|
25
|
+
- **legacy:** `execCommandApproval`, `applyPatchApproval`
|
|
26
|
+
- `buildCodexApprovalResponse(decision, protocol)` emits the right vocabulary:
|
|
27
|
+
v2 camelCase `accept|decline|cancel`; legacy snake_case `approved|denied|abort`.
|
|
28
|
+
- `decisionFromVerdict(verdict)` maps a `decideApproval` result → `allow|deny|abort`
|
|
29
|
+
(blocklist → `abort` = deny + interrupt the turn).
|
|
30
|
+
- Real-binary validation: the `codex app-server` handshake (`initialize` → `initialized`)
|
|
31
|
+
was driven against codex-cli **0.132.0** and returns the documented result
|
|
32
|
+
(`userAgent, codexHome, platformFamily, platformOs`). The protocol layer is correct
|
|
33
|
+
against the shipping binary.
|
|
34
|
+
|
|
35
|
+
## What also shipped: the driver engine
|
|
36
|
+
|
|
37
|
+
- `lib/codex-app-server-client.js` (unit-tested, 10 cases): the stdio JSON-RPC client.
|
|
38
|
+
- `spawnCodexTransport()` spawns `codex app-server` and frames newline-delimited JSON;
|
|
39
|
+
a `transport` injection makes the dispatch testable without a real process.
|
|
40
|
+
- `createCodexAppServerClient({ transport, decide, onItem, onNotification, onApproval })`
|
|
41
|
+
does the handshake (`initialize` → `initialized`), `startThread` (defaults route
|
|
42
|
+
approvals to us: `approvalPolicy:"on-request"`, `approvalsReviewer:"user"`,
|
|
43
|
+
`sandbox:"workspace-write"`), `startTurn`, id-keyed request/response correlation, and
|
|
44
|
+
dispatches server-initiated approval requests through the bridge to the async
|
|
45
|
+
`decide(context, kind)` callback — answering keyed by the request id. Fail-closed:
|
|
46
|
+
`decide` throwing → deny; unknown server requests get an empty result so the agent
|
|
47
|
+
never wedges.
|
|
48
|
+
- Validated **end-to-end against codex-cli 0.132.0**: the client drives a real
|
|
49
|
+
`initialize → thread/start → turn/start` and answers a live approval request via the
|
|
50
|
+
bridge (see the e2e probe). The protocol engine works against the shipping binary.
|
|
51
|
+
|
|
52
|
+
## Remaining (the product surface)
|
|
53
|
+
|
|
54
|
+
1. **CTM-driven-Codex session type.** Wire the client into a CTM session: render the
|
|
55
|
+
`item/*` / `turn/*` stream (CTM owns the UI — reuse the structured renderer used for
|
|
56
|
+
Wall-E chat), route `decide` through the shared `decideApproval` cascade, and **park**
|
|
57
|
+
human-needed approvals exactly like the Claude hook (hold the open JSON-RPC request,
|
|
58
|
+
raise the existing escalation card, resolve from the local/remote answer; dangerous →
|
|
59
|
+
cancel).
|
|
60
|
+
2. **Product decision:** opt-in CTM-driven Codex sessions vs. the current PTY TUI (which
|
|
61
|
+
keeps the scrape path). Until that ships, Codex TUI sessions keep the scrape approver.
|
|
62
|
+
|
|
63
|
+
The engine is also the right substrate for any **programmatic** Codex consumer (e.g. a
|
|
64
|
+
Wall-E coding backend), where there is no TUI and driven mode is the *only* mode — there
|
|
65
|
+
the remaining UI item (1) does not even apply.
|
|
66
|
+
|
|
67
|
+
## Reference
|
|
68
|
+
|
|
69
|
+
Exact wire contract (regenerate from the installed binary):
|
|
70
|
+
`codex app-server generate-json-schema --out <dir>` → `ServerRequest.json`,
|
|
71
|
+
`v2/ThreadStartParams.json`, `CommandExecutionRequestApprovalParams.json`, etc.
|
|
72
|
+
See memory `ctm-approver-protocol-hook-prototype-validated` for the full protocol notes.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Codex: native sandbox + on-request approvals (opt-in)
|
|
2
|
+
|
|
3
|
+
The cheap Codex win that does NOT require driven mode. When CTM spawns a Codex session
|
|
4
|
+
and `CTM_CODEX_SANDBOX=1`, CTM injects Codex's **own** sandbox + approval policy so it
|
|
5
|
+
auto-runs safe commands *inside its native OS sandbox* (Seatbelt on macOS, landlock on
|
|
6
|
+
Linux) and prompts far less — keeping the Codex TUI, no UI rebuild.
|
|
7
|
+
|
|
8
|
+
- Injected (prepended, before any subcommand): `--sandbox workspace-write
|
|
9
|
+
--ask-for-approval on-request`.
|
|
10
|
+
- **Never overrides an operator choice:** if the launch args already carry a sandbox /
|
|
11
|
+
approval / bypass / full-auto flag (or a `-c sandbox_mode=…` / `-c approval_policy=…`),
|
|
12
|
+
CTM leaves them untouched.
|
|
13
|
+
- Default **OFF** (`CTM_CODEX_SANDBOX` unset). Enable per-environment by setting the env
|
|
14
|
+
var (launchd plist on the primary, like `CTM_APPROVAL_HOOK`).
|
|
15
|
+
|
|
16
|
+
`lib/codex-sandbox-args.js` (`injectCodexSandboxArgs`) holds the pure logic (unit-tested);
|
|
17
|
+
server.js applies it at the Codex spawn in `handleCreate`. Flags validated against
|
|
18
|
+
codex-cli 0.132.0; the behavior was confirmed via the app-server e2e (workspace-write +
|
|
19
|
+
on-request auto-ran a shell command without prompting).
|
|
20
|
+
|
|
21
|
+
## What this is NOT (read before enabling)
|
|
22
|
+
|
|
23
|
+
This reduces the *number* of Codex prompts and contains execution. It does **not**:
|
|
24
|
+
- route the remaining Codex approvals through CTM's classifier (blocklist / learned
|
|
25
|
+
rules / verifier) — those still appear as Codex's own TUI widget (the scrape path);
|
|
26
|
+
- give the unified "Needs You" card, remote/phone approval, or park-and-resolve for
|
|
27
|
+
Codex. **Only driven mode** (`docs/codex-app-server-approvals.md`) does that.
|
|
28
|
+
|
|
29
|
+
## Cons / catches
|
|
30
|
+
|
|
31
|
+
- **More autonomous.** Codex auto-runs reads + in-workspace writes without asking
|
|
32
|
+
(contained by the sandbox). That's the point, but it's a behavior change — hence
|
|
33
|
+
opt-in, and we never override an operator who deliberately runs Codex cautiously.
|
|
34
|
+
- **Network is OFF** under `workspace-write`. Commands needing the network (`npm install`,
|
|
35
|
+
`git push`, `curl`) will fail-in-sandbox and escalate (→ prompt) unless you also enable
|
|
36
|
+
`[sandbox_workspace_write] network_access = true` in `~/.codex/config.toml` — which
|
|
37
|
+
weakens containment. No free lunch; default is the contained one.
|
|
38
|
+
- **Scrape path stays** for the remaining prompts (fewer of them, but not eliminated).
|
|
39
|
+
- **Codex-version-dependent** flags (`-s/--sandbox`, `-a/--ask-for-approval`) — stable in
|
|
40
|
+
0.132.0.
|
|
41
|
+
|
|
42
|
+
## To enable on the primary
|
|
43
|
+
|
|
44
|
+
Add `CTM_CODEX_SANDBOX=1` to the launchd plist `EnvironmentVariables` and bootout/bootstrap
|
|
45
|
+
(KeepAlive/restart won't re-read plist env). Validate against your real Codex workflows
|
|
46
|
+
first — a network-heavy project may want `network_access = true` or `--sandbox
|
|
47
|
+
danger-full-access` for specific sessions.
|
|
@@ -243,6 +243,15 @@ CREATE INDEX idx_chat_messages_edited_from ON chat_messages(edited_from_id);
|
|
|
243
243
|
`chat_branches` can remain temporarily as a compatibility payload, but new code
|
|
244
244
|
should prefer `chat_branch_heads` plus message parent links.
|
|
245
245
|
|
|
246
|
+
Current transition state: Wall-E sessions persist branch UI state through
|
|
247
|
+
`chat_branch_events` (append-only audit/event log) and `chat_branch_state`
|
|
248
|
+
(latest projection). The projection stores durable `chat_messages.id` references
|
|
249
|
+
for transcript messages and only stores inline message bodies for optimistic
|
|
250
|
+
client-only edits that do not have a durable row yet. Do not restore the old
|
|
251
|
+
behavior where CTM rewrites a full `{branches, active, history}` transcript blob
|
|
252
|
+
on every edit; that path hit request limits once images and long answers were in
|
|
253
|
+
the conversation.
|
|
254
|
+
|
|
246
255
|
### CTM Cache Tables
|
|
247
256
|
|
|
248
257
|
Add nullable tree fields to `session_messages`:
|
|
@@ -347,6 +356,11 @@ Existing APIs:
|
|
|
347
356
|
- `/api/wall-e/chat/history` should continue returning active-path messages.
|
|
348
357
|
- `/api/wall-e/chat/branches` can remain as legacy compatibility, but should
|
|
349
358
|
eventually be backed by first-class branch tables.
|
|
359
|
+
- `/api/wall-e/chat/branch-state` writes the compact ref projection and appends
|
|
360
|
+
an event. New CTM Wall-E session code should use this instead of posting a full
|
|
361
|
+
branch/history blob to `/api/wall-e/chat/branches`.
|
|
362
|
+
- `/api/wall-e/chat/branch-events` exposes the append-only event stream for
|
|
363
|
+
debugging and future incremental clients.
|
|
350
364
|
|
|
351
365
|
### CTM Session Messages
|
|
352
366
|
|
|
@@ -517,7 +531,10 @@ Backfill algorithm:
|
|
|
517
531
|
|
|
518
532
|
### Phase 5: Advanced Cleanup
|
|
519
533
|
|
|
520
|
-
- Retire `chat_branches` JSON blob or make it a derived cache.
|
|
534
|
+
- Retire `chat_branches` JSON blob or make it a derived cache. The current
|
|
535
|
+
Wall-E browser UIs write compact state/events, so the remaining work is
|
|
536
|
+
migration/backfill and removing legacy API compatibility once old clients are
|
|
537
|
+
gone.
|
|
521
538
|
- Add branch-aware memory ranking.
|
|
522
539
|
- Add branch-aware export/import.
|
|
523
540
|
- Add UI for branch rename/delete/hide.
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Claude Code PreToolUse hook integration for CTM's auto-approver.
|
|
4
|
+
//
|
|
5
|
+
// Instead of screen-scraping the rendered approval widget and injecting keystrokes,
|
|
6
|
+
// CTM injects a PreToolUse HTTP hook into the Claude sessions it spawns (via
|
|
7
|
+
// `claude --settings '<json>'`). Claude then POSTs every governed tool call to CTM's
|
|
8
|
+
// endpoint with STRUCTURED input ({tool_name, tool_input:{command}, session_id, cwd}),
|
|
9
|
+
// and CTM answers with a permissionDecision. This eliminates the parse/quoting/clip/
|
|
10
|
+
// TOCTOU failure class of the scrape path; the literal command is handed to us.
|
|
11
|
+
//
|
|
12
|
+
// Proven against claude 2.1.173 (see memory ctm-approver-protocol-hook-prototype-validated):
|
|
13
|
+
// - permissionDecision "allow" suppresses the interactive widget entirely;
|
|
14
|
+
// - "ask" falls through to Claude's native prompt (and shows our reason);
|
|
15
|
+
// - a hook ERROR or TIMEOUT degrades to the native prompt (FAIL-CLOSED / safe);
|
|
16
|
+
// - `--settings` MERGES with the user's config rather than replacing it.
|
|
17
|
+
//
|
|
18
|
+
// This module holds the PURE pieces (settings builder + decision→output mapper) so
|
|
19
|
+
// they unit-test without a server. The route wiring lives in server.js.
|
|
20
|
+
|
|
21
|
+
// Which Claude tools the hook governs (Claude matches tool_name against this regex).
|
|
22
|
+
// Bash is where the auto-approval pain is; Edit/Write/Read/Glob/Grep are low-risk and
|
|
23
|
+
// our classifier auto-allows them; `mcp__.*` brings MCP tool calls under the same
|
|
24
|
+
// cascade (read-only → allow, mutating → human), so their widgets are suppressed too.
|
|
25
|
+
const DEFAULT_MATCHER = 'Bash|Edit|Write|Read|Glob|Grep|NotebookEdit|WebFetch|mcp__.*';
|
|
26
|
+
|
|
27
|
+
// Build the `--settings` JSON (string) that wires a PreToolUse HTTP hook back to CTM.
|
|
28
|
+
// `port` is CTM's own HTTP port; the hook posts to /api/approval/hook on it.
|
|
29
|
+
// timeoutSec is the per-hook budget — kept generous so a parked (awaiting-human)
|
|
30
|
+
// request resolves on the structured channel before Claude falls back to its widget.
|
|
31
|
+
function buildClaudeHookSettings(port, { matcher = DEFAULT_MATCHER, timeoutSec = 600, path = '/api/approval/hook', token = '', sandbox = false } = {}) {
|
|
32
|
+
const p = Number(port);
|
|
33
|
+
if (!Number.isFinite(p) || p <= 0) throw new Error('buildClaudeHookSettings: invalid port');
|
|
34
|
+
const hook = { type: 'http', url: `http://127.0.0.1:${p}${path}`, timeout: Math.max(1, Math.floor(timeoutSec)) };
|
|
35
|
+
// Phase 5: a per-session bearer token so only THIS CTM-spawned session can drive a
|
|
36
|
+
// decision through the (localhost, otherwise-unauthenticated) endpoint. Validated
|
|
37
|
+
// server-side against the session's stored token; a mismatch fails closed.
|
|
38
|
+
if (token) hook.headers = { Authorization: `Bearer ${token}` };
|
|
39
|
+
const settings = { hooks: { PreToolUse: [{ matcher, hooks: [hook] }] } };
|
|
40
|
+
// Phase 4 (Tier 3): enable Claude Code's built-in OS-level Bash sandbox (macOS
|
|
41
|
+
// Seatbelt / Linux bubblewrap) so an auto-approved command is CONTAINED — writes
|
|
42
|
+
// limited to the cwd + temp, network gated. CTM cannot sandbox execution it does not
|
|
43
|
+
// own; this turns on Claude's native sandbox via the settings we already inject.
|
|
44
|
+
// `sandbox === true` → safe default (enabled; keeps the unsandboxed-fallback escape
|
|
45
|
+
// hatch and warns-not-fails if unavailable). Or pass a full sandbox config object.
|
|
46
|
+
if (sandbox) settings.sandbox = (sandbox === true) ? { enabled: true } : sandbox;
|
|
47
|
+
return JSON.stringify(settings);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Check that a request's Authorization header matches the session's expected hook
|
|
51
|
+
// token. A plain string compare (not constant-time) — the token only gates a
|
|
52
|
+
// same-machine, per-session approval decision over localhost, so a timing side channel
|
|
53
|
+
// is not in scope. Returns true when no token is required (backward-compatible:
|
|
54
|
+
// sessions launched before tokens, or non-hook sessions, are not rejected).
|
|
55
|
+
function hookTokenOk(expectedToken, authorizationHeader) {
|
|
56
|
+
if (!expectedToken) return true; // nothing to enforce
|
|
57
|
+
return String(authorizationHeader || '') === `Bearer ${expectedToken}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Map our shared decideApproval() result to a Claude PreToolUse hook output object.
|
|
61
|
+
// decideApproval returns { decision: 'allow' | 'ask', decidedBy, riskLevel, reason, ... }.
|
|
62
|
+
// - 'allow' -> permissionDecision 'allow' (widget suppressed)
|
|
63
|
+
// - 'ask' from the blocklist -> permissionDecision 'deny' (hard refuse + tell the model why)
|
|
64
|
+
// - 'ask' otherwise (escalation) -> permissionDecision 'ask' (Phase 1a: native widget;
|
|
65
|
+
// Phase 1b parks instead and returns allow/deny)
|
|
66
|
+
// Returns the object Claude expects under hookSpecificOutput, plus our internal
|
|
67
|
+
// `_route` tag ('allow'|'deny'|'ask') so the caller can decide whether to park.
|
|
68
|
+
function mapDecisionToHookOutput(d) {
|
|
69
|
+
const decision = d && d.decision;
|
|
70
|
+
const reason = (d && d.reason) || '';
|
|
71
|
+
if (decision === 'allow') {
|
|
72
|
+
return { _route: 'allow', permissionDecision: 'allow', permissionDecisionReason: reason || 'Auto-approved by CTM' };
|
|
73
|
+
}
|
|
74
|
+
// Everything else is "not auto-approved". A blocklist hit is an objective refusal.
|
|
75
|
+
if (d && d.decidedBy === 'blocklist') {
|
|
76
|
+
return { _route: 'deny', permissionDecision: 'deny', permissionDecisionReason: reason || 'Blocked by CTM dangerous-command blocklist' };
|
|
77
|
+
}
|
|
78
|
+
return { _route: 'ask', permissionDecision: 'ask', permissionDecisionReason: reason || 'CTM routed this to manual review' };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Wrap a hookSpecificOutput payload into the full hook response body.
|
|
82
|
+
function hookResponseBody(output) {
|
|
83
|
+
const { _route, ...rest } = output || {};
|
|
84
|
+
return { hookSpecificOutput: { hookEventName: 'PreToolUse', ...rest } };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Map a Claude PreToolUse payload's tool_name + tool_input to the context object the
|
|
88
|
+
// approver cascade expects. Claude tools: Bash -> command; Edit/Write/Read -> file path.
|
|
89
|
+
const CLAUDE_TOOL_TO_CONTEXT = {
|
|
90
|
+
Bash: (ti) => ({ toolName: 'Bash command', command: String((ti && ti.command) || '') }),
|
|
91
|
+
Edit: (ti) => ({ toolName: 'Edit', command: String((ti && ti.file_path) || '') }),
|
|
92
|
+
Write: (ti) => ({ toolName: 'Write', command: String((ti && ti.file_path) || '') }),
|
|
93
|
+
NotebookEdit: (ti) => ({ toolName: 'NotebookEdit', command: String((ti && ti.notebook_path) || '') }),
|
|
94
|
+
Read: (ti) => ({ toolName: 'Read', command: String((ti && ti.file_path) || '') }),
|
|
95
|
+
Glob: (ti) => ({ toolName: 'Glob', command: String((ti && ti.pattern) || '') }),
|
|
96
|
+
Grep: (ti) => ({ toolName: 'Grep', command: String((ti && ti.pattern) || '') }),
|
|
97
|
+
WebFetch: (ti) => ({ toolName: 'WebFetch', command: String((ti && ti.url) || '') }),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// MCP tool calls (tool_name `mcp__<server>__<tool>`) carry their args in tool_input.
|
|
101
|
+
// Serialize a compact descriptor so reviewWithHeuristics (which keys on the tool name
|
|
102
|
+
// for read-only-vs-mutating) and the verifier both have the full call to judge.
|
|
103
|
+
function _mcpContext(toolName, toolInput) {
|
|
104
|
+
let args = '';
|
|
105
|
+
try { args = JSON.stringify(toolInput || {}); } catch { args = ''; }
|
|
106
|
+
if (args === '{}') args = '';
|
|
107
|
+
const command = args ? `${toolName} ${args}`.slice(0, 2000) : toolName;
|
|
108
|
+
return { toolName, command };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function contextFromHookPayload(payload) {
|
|
112
|
+
const toolName = payload && payload.tool_name;
|
|
113
|
+
const toolInput = (payload && payload.tool_input) || {};
|
|
114
|
+
const mapper = CLAUDE_TOOL_TO_CONTEXT[toolName];
|
|
115
|
+
let base;
|
|
116
|
+
if (mapper) base = mapper(toolInput);
|
|
117
|
+
else if (/^(?:mcp__|plugin:)/i.test(String(toolName || ''))) base = _mcpContext(toolName, toolInput);
|
|
118
|
+
else base = { toolName: toolName || 'Unknown', command: '' };
|
|
119
|
+
return { ...base, warning: '', fullContext: base.command };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Park manager (Phase 1b): the Map + promise + timeout mechanics for holding a
|
|
123
|
+
// hook request open while a human decides. Pure of CTM internals — the server
|
|
124
|
+
// injects the card raise/clear as side effects (onTimeout) and decides the requestId.
|
|
125
|
+
// park() resolves to 'allow' | 'deny' (resolve() called) or 'ask' (deadline elapsed).
|
|
126
|
+
// One parked request per session; a second park() hands the prior one to 'ask' (the
|
|
127
|
+
// native-widget fallback) so a request is never silently dropped.
|
|
128
|
+
function createHookApprovalPark({ deadlineMs = 590000, onTimeout, now = Date.now, setTimer = setTimeout, clearTimer = clearTimeout } = {}) {
|
|
129
|
+
const map = new Map(); // sessionId -> { resolve, requestId, timer, createdAt }
|
|
130
|
+
function park(sessionId, requestId) {
|
|
131
|
+
const existing = map.get(sessionId);
|
|
132
|
+
if (existing) {
|
|
133
|
+
map.delete(sessionId);
|
|
134
|
+
if (existing.timer) clearTimer(existing.timer);
|
|
135
|
+
try { existing.resolve('ask'); } catch { /* prior awaiter gone */ }
|
|
136
|
+
}
|
|
137
|
+
return new Promise((resolve) => {
|
|
138
|
+
// Register the entry BEFORE arming the timer so the timeout callback always sees
|
|
139
|
+
// it — a synchronous timer (tests) would otherwise fire before the map.set and
|
|
140
|
+
// find nothing, never resolving. (Real setTimeout is async, but this is the
|
|
141
|
+
// correct invariant regardless.)
|
|
142
|
+
const entry = { resolve, requestId, timer: null, createdAt: now() };
|
|
143
|
+
map.set(sessionId, entry);
|
|
144
|
+
entry.timer = setTimer(() => {
|
|
145
|
+
const cur = map.get(sessionId);
|
|
146
|
+
if (!cur || cur.requestId !== requestId) return;
|
|
147
|
+
map.delete(sessionId);
|
|
148
|
+
try { if (typeof onTimeout === 'function') onTimeout(sessionId, requestId); } catch { /* never break the resolve */ }
|
|
149
|
+
resolve('ask');
|
|
150
|
+
}, deadlineMs);
|
|
151
|
+
if (entry.timer && typeof entry.timer.unref === 'function') entry.timer.unref();
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
function resolve(sessionId, decision) {
|
|
155
|
+
const p = map.get(sessionId);
|
|
156
|
+
if (!p) return null;
|
|
157
|
+
map.delete(sessionId);
|
|
158
|
+
if (p.timer) clearTimer(p.timer);
|
|
159
|
+
try { p.resolve(decision); } catch { /* awaiter gone */ }
|
|
160
|
+
return p;
|
|
161
|
+
}
|
|
162
|
+
return {
|
|
163
|
+
park,
|
|
164
|
+
resolve,
|
|
165
|
+
has: (sessionId) => map.has(sessionId),
|
|
166
|
+
size: () => map.size,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Shape the approval_decisions row for a parked hook escalation — parity with
|
|
171
|
+
// the scrape path's escalation records so the Permission "Pending" tab stays
|
|
172
|
+
// populated as sessions migrate to the hook. `parts` is the operative-command
|
|
173
|
+
// parse (approval-agent escalationCommandParts) so the row titles with the
|
|
174
|
+
// COMMAND, not a reason label. Pure: the caller owns the DB write.
|
|
175
|
+
function escalationRecordFromPark(sessionId, context, decision, label, parts) {
|
|
176
|
+
const p = parts || {};
|
|
177
|
+
return {
|
|
178
|
+
sessionId,
|
|
179
|
+
toolName: (context && context.toolName) || '',
|
|
180
|
+
commandSummary: p.title || String(label || '').slice(0, 160) || 'command',
|
|
181
|
+
fullContext: String((context && (context.fullContext || context.command)) || '').slice(0, 2000),
|
|
182
|
+
warning: (context && context.warning) || '',
|
|
183
|
+
decision: 'escalated',
|
|
184
|
+
reasoning: (decision && decision.reason) || 'Escalated by the approval hook',
|
|
185
|
+
decidedBy: (decision && decision.decidedBy) || 'hook',
|
|
186
|
+
riskLevel: (decision && decision.riskLevel) || 'medium',
|
|
187
|
+
commandSignature: p.signature || '',
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = {
|
|
192
|
+
DEFAULT_MATCHER,
|
|
193
|
+
buildClaudeHookSettings,
|
|
194
|
+
hookTokenOk,
|
|
195
|
+
mapDecisionToHookOutput,
|
|
196
|
+
hookResponseBody,
|
|
197
|
+
contextFromHookPayload,
|
|
198
|
+
createHookApprovalPark,
|
|
199
|
+
escalationRecordFromPark,
|
|
200
|
+
};
|
|
@@ -16,12 +16,23 @@ const WS_AUTH_RULES = Object.freeze({
|
|
|
16
16
|
reconcile: rule('read', false, 'never'),
|
|
17
17
|
resize: rule('read', false, 'never'),
|
|
18
18
|
reflow: rule('read', false, 'never'),
|
|
19
|
+
// Backpressure flow control: a viewer asks to pause/resume its own output
|
|
20
|
+
// stream (the server further gates an actual PTY pause to raw-eligible
|
|
21
|
+
// primaries). Same render-control class as reconcile/reflow — read scope.
|
|
22
|
+
flow: rule('read', false, 'never'),
|
|
23
|
+
// Client-reported render diagnostics (rows-flap attribution probe) — telemetry
|
|
24
|
+
// into the per-session diagnostics ring; rate-limited + scalar-whitelisted in
|
|
25
|
+
// handleClientDiag, so viewer (read) scope is sufficient.
|
|
26
|
+
'client-diag': rule('read', false, 'never'),
|
|
19
27
|
'walle-history-request': rule('read', false, 'never'),
|
|
20
28
|
|
|
21
29
|
input: rule('respond', true, 'remote'),
|
|
22
30
|
'walle-message': rule('respond', true, 'remote'),
|
|
23
31
|
'walle-cancel': rule('respond', true, 'remote'),
|
|
24
32
|
'walle-permission-response': rule('respond', true, 'remote'),
|
|
33
|
+
// Resolve a parked protocol-hook approval (allow/deny) — same class as
|
|
34
|
+
// answering any other approval prompt.
|
|
35
|
+
'approval-resolve': rule('respond', true, 'remote'),
|
|
25
36
|
|
|
26
37
|
create: rule('create', true, 'remote'),
|
|
27
38
|
|
|
@@ -23,16 +23,28 @@ const MODEL_TIER_ENV = {
|
|
|
23
23
|
// so a Wall-E restart doesn't pin us to env for long.
|
|
24
24
|
const WALLE_DEFAULTS_TTL_MS = 30000;
|
|
25
25
|
const WALLE_DEFAULTS_NEGATIVE_TTL_MS = 5000;
|
|
26
|
-
let _walleDefaultsCache = null; // { at, value: {provider, model} | null }
|
|
26
|
+
let _walleDefaultsCache = null; // { at, value: {provider, model} | null, authoritative }
|
|
27
|
+
// Stale-while-revalidate: the last starred default a status fetch ever returned. When
|
|
28
|
+
// Wall-E is slow (>timeout) or briefly down — exactly when the box is busiest — a stale
|
|
29
|
+
// starred default is far better than falling through to env.WALLE_PROVIDER, which launchd
|
|
30
|
+
// pins to whatever the plist said at bootstrap (observed: a stale deepseek pin surviving
|
|
31
|
+
// KeepAlive respawns → 402 churn). The starred default changes rarely and only via a
|
|
32
|
+
// Wall-E that is up, so serving the last-seen value while retrying is always safe.
|
|
33
|
+
let _lastGoodWalleDefaults = null;
|
|
27
34
|
|
|
28
35
|
function _resetWalleDefaultsCache() {
|
|
29
36
|
_walleDefaultsCache = null;
|
|
37
|
+
_lastGoodWalleDefaults = null;
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
async function fetchWalleStatusDefaults(env) {
|
|
33
41
|
const port = Number(env?.WALLE_PORT) || 3457;
|
|
42
|
+
// 2500ms matches the setup handler's Wall-E status budget: a loaded Wall-E routinely
|
|
43
|
+
// answers in 0.6–1.2s, and a timeout here means falling back to the (possibly stale)
|
|
44
|
+
// env pin or last-good value — be generous before giving up.
|
|
45
|
+
const timeoutMs = Number(env?.CTM_WALLE_DEFAULTS_FETCH_TIMEOUT_MS) || 2500;
|
|
34
46
|
const response = await fetch(`http://127.0.0.1:${port}/api/wall-e/status`, {
|
|
35
|
-
signal: AbortSignal.timeout(
|
|
47
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
36
48
|
});
|
|
37
49
|
if (!response.ok) throw new Error(`Wall-E status returned ${response.status}`);
|
|
38
50
|
const json = await response.json();
|
|
@@ -44,21 +56,37 @@ async function getWalleDefaultSelection(options = {}) {
|
|
|
44
56
|
if (String(env.CTM_BACKGROUND_LLM_WALLE_DEFAULTS || '').trim() === '0') return null;
|
|
45
57
|
const now = Date.now();
|
|
46
58
|
if (_walleDefaultsCache) {
|
|
47
|
-
|
|
59
|
+
// Stale-served results keep the short negative TTL so a recovered Wall-E is
|
|
60
|
+
// consulted again promptly; authoritative results get the full TTL. Both are
|
|
61
|
+
// env-tunable (and 0 disables caching — used by tests to force refetches).
|
|
62
|
+
const _ttlOf = (key, dflt) => {
|
|
63
|
+
const n = Number(env[key]);
|
|
64
|
+
return Number.isFinite(n) && n >= 0 ? n : dflt;
|
|
65
|
+
};
|
|
66
|
+
const ttl = _walleDefaultsCache.authoritative
|
|
67
|
+
? _ttlOf('CTM_WALLE_DEFAULTS_TTL_MS', WALLE_DEFAULTS_TTL_MS)
|
|
68
|
+
: _ttlOf('CTM_WALLE_DEFAULTS_NEGATIVE_TTL_MS', WALLE_DEFAULTS_NEGATIVE_TTL_MS);
|
|
48
69
|
if (now - _walleDefaultsCache.at < ttl) return _walleDefaultsCache.value;
|
|
49
70
|
}
|
|
50
71
|
let value = null;
|
|
72
|
+
let authoritative = false;
|
|
51
73
|
try {
|
|
52
74
|
const fetchStatus = options.fetchWalleStatus || fetchWalleStatusDefaults;
|
|
53
75
|
const status = await fetchStatus(env);
|
|
54
76
|
const provider = sanitizeSetupProviderType(status?.walle_provider || '');
|
|
55
77
|
if (provider) {
|
|
56
78
|
value = { provider, model: typeof status?.walle_model === 'string' ? status.walle_model : '' };
|
|
79
|
+
authoritative = true;
|
|
57
80
|
}
|
|
58
81
|
} catch {
|
|
59
82
|
value = null;
|
|
60
83
|
}
|
|
61
|
-
|
|
84
|
+
if (authoritative) {
|
|
85
|
+
_lastGoodWalleDefaults = value;
|
|
86
|
+
} else {
|
|
87
|
+
value = _lastGoodWalleDefaults;
|
|
88
|
+
}
|
|
89
|
+
_walleDefaultsCache = { at: now, value, authoritative };
|
|
62
90
|
return value;
|
|
63
91
|
}
|
|
64
92
|
|