clawmini 0.0.7 → 0.0.9
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/.changeset/README.md +8 -0
- package/.changeset/config.json +14 -0
- package/.github/workflows/release.yml +49 -0
- package/CHANGELOG.md +36 -0
- package/README.md +5 -4
- package/dist/adapter-discord/index.d.mts.map +1 -1
- package/dist/adapter-discord/index.mjs +465 -282
- package/dist/adapter-discord/index.mjs.map +1 -1
- package/dist/adapter-google-chat/index.mjs +367 -243
- package/dist/adapter-google-chat/index.mjs.map +1 -1
- package/dist/cli/index.mjs +684 -24
- package/dist/cli/index.mjs.map +1 -1
- package/dist/cli/lite.mjs +43 -13
- package/dist/cli/lite.mjs.map +1 -1
- package/dist/cli/{propose-policy.mjs → manage-policies.mjs} +270 -47
- package/dist/cli/manage-policies.mjs.map +1 -0
- package/dist/cli/run-host.d.mts +1 -0
- package/dist/cli/run-host.mjs +3090 -0
- package/dist/cli/run-host.mjs.map +1 -0
- package/dist/config-CPFQIGdG.mjs +57 -0
- package/dist/config-CPFQIGdG.mjs.map +1 -0
- package/dist/config-Dvl-Pov4.mjs +76 -0
- package/dist/config-Dvl-Pov4.mjs.map +1 -0
- package/dist/daemon/index.d.mts.map +1 -1
- package/dist/daemon/index.mjs +970 -332
- package/dist/daemon/index.mjs.map +1 -1
- package/dist/supervisor-actions-CiW56eLi.mjs +843 -0
- package/dist/supervisor-actions-CiW56eLi.mjs.map +1 -0
- package/dist/turn-log-buffer-DRgW53gl.mjs +767 -0
- package/dist/turn-log-buffer-DRgW53gl.mjs.map +1 -0
- package/dist/web/_app/immutable/chunks/{Drm9vgeP.js → 3AZlWB6U.js} +1 -1
- package/dist/web/_app/immutable/chunks/BhRSsUCh.js +2 -0
- package/dist/web/_app/immutable/chunks/BiLeM2i1.js +1 -0
- package/{web/.svelte-kit/output/client/_app/immutable/chunks/CME08kGM.js → dist/web/_app/immutable/chunks/BmBj85Ll.js} +1 -1
- package/dist/web/_app/immutable/chunks/BrERcKAH.js +1 -0
- package/dist/web/_app/immutable/chunks/Bv9252RM.js +1 -0
- package/dist/web/_app/immutable/chunks/CIXNBPKi.js +1 -0
- package/dist/web/_app/immutable/chunks/DISKL3GN.js +2 -0
- package/dist/web/_app/immutable/chunks/{Zeh-C-mx.js → DcpaLzmX.js} +1 -1
- package/dist/web/_app/immutable/chunks/DnQ3vS13.js +1 -0
- package/dist/web/_app/immutable/chunks/KsloHTKS.js +1 -0
- package/{web/.svelte-kit/output/client/_app/immutable/chunks/Ck-be5J2.js → dist/web/_app/immutable/chunks/RsHsUj-8.js} +2 -2
- package/dist/web/_app/immutable/chunks/{G_zz-Gou.js → wpfV79dV.js} +1 -1
- package/dist/web/_app/immutable/entry/app.CIw1Qj0n.js +2 -0
- package/dist/web/_app/immutable/entry/start.Di0-Jhte.js +1 -0
- package/dist/web/_app/immutable/nodes/{0.CYS8iApT.js → 0.DYyUA1au.js} +1 -1
- package/dist/web/_app/immutable/nodes/1.D-3QEMMZ.js +1 -0
- package/dist/web/_app/immutable/nodes/{2.BnwnD1Ki.js → 2.4olHnH7U.js} +1 -1
- package/{web/.svelte-kit/output/client/_app/immutable/nodes/3.Dr0ot9sV.js → dist/web/_app/immutable/nodes/3.4w0bE-m2.js} +3 -3
- package/dist/web/_app/immutable/nodes/4.CZvjhVHt.js +60 -0
- package/dist/web/_app/immutable/nodes/{5.BBGQ_i84.js → 5.DLbPVJY2.js} +1 -1
- package/dist/web/_app/version.json +1 -1
- package/dist/web/index.html +12 -12
- package/dist/workspace-oWmVh5mi.mjs +1001 -0
- package/dist/workspace-oWmVh5mi.mjs.map +1 -0
- package/docs/23_adapter_slash_autocomplete/development_log.md +19 -0
- package/docs/23_adapter_slash_autocomplete/notes.md +18 -0
- package/docs/23_adapter_slash_autocomplete/prd.md +46 -0
- package/docs/23_adapter_slash_autocomplete/questions.md +6 -0
- package/docs/23_adapter_slash_autocomplete/tickets.md +21 -0
- package/docs/24_subagent_job_policy_fixes/development_log.md +22 -0
- package/docs/24_subagent_job_policy_fixes/notes.md +28 -0
- package/docs/24_subagent_job_policy_fixes/prd.md +59 -0
- package/docs/24_subagent_job_policy_fixes/questions.md +3 -0
- package/docs/24_subagent_job_policy_fixes/tickets.md +49 -0
- package/docs/25_e2e_test_improvements/development_log.md +30 -0
- package/docs/25_e2e_test_improvements/notes.md +29 -0
- package/docs/25_e2e_test_improvements/prd.md +43 -0
- package/docs/25_e2e_test_improvements/questions.md +12 -0
- package/docs/25_e2e_test_improvements/tickets-2.md +22 -0
- package/docs/25_e2e_test_improvements/tickets.md +22 -0
- package/docs/25_policy_cwd/development_log.md +30 -0
- package/docs/25_policy_cwd/notes.md +28 -0
- package/docs/25_policy_cwd/prd.md +77 -0
- package/docs/25_policy_cwd/questions.md +6 -0
- package/docs/25_policy_cwd/tickets.md +77 -0
- package/docs/CLI_REFERENCE.md +3 -1
- package/docs/PHILOSOPHY.md +35 -0
- package/docs/adapter-visibility/SPEC.md +461 -0
- package/docs/adapter-visibility/SPEC_v2.md +202 -0
- package/docs/auto-update/SPEC.md +344 -0
- package/docs/backups/SPEC.md +296 -0
- package/docs/backups/clawmini.gitignore +69 -0
- package/docs/guides/assets/clawmini-avatar.png +0 -0
- package/docs/guides/backups.md +332 -0
- package/docs/guides/discord_adapter_setup.md +1 -1
- package/docs/guides/google_chat_adapter_setup.md +81 -0
- package/docs/unified-startup/SPEC.md +203 -0
- package/e2e/_helpers/test-environment.test.ts +49 -0
- package/e2e/_helpers/test-environment.ts +548 -0
- package/e2e/adapters/_google-chat-fixtures.ts +340 -0
- package/{src/cli/e2e → e2e/adapters}/adapter-discord.test.ts +22 -23
- package/e2e/adapters/adapter-google-chat-downtime.test.ts +157 -0
- package/e2e/adapters/adapter-google-chat-inbound.test.ts +697 -0
- package/e2e/adapters/adapter-google-chat-outbound.test.ts +297 -0
- package/e2e/adapters/adapter-google-chat-roundtrip.test.ts +56 -0
- package/e2e/adapters/adapter-google-chat-threads.test.ts +1078 -0
- package/e2e/agents/custom-api-env.test.ts +80 -0
- package/e2e/agents/export-lite-func.test.ts +104 -0
- package/e2e/agents/fallbacks.test.ts +124 -0
- package/e2e/agents/interrupt.test.ts +50 -0
- package/e2e/agents/no-reply-necessary.test.ts +57 -0
- package/e2e/agents/session-timeout-subagents.test.ts +76 -0
- package/e2e/agents/subagent-authorization.test.ts +246 -0
- package/e2e/agents/subagent-env.test.ts +49 -0
- package/e2e/agents/subagent-lifecycle.test.ts +782 -0
- package/e2e/agents/subagents-depth.test.ts +47 -0
- package/e2e/cli/agents.test.ts +176 -0
- package/e2e/cli/auto-update.test.ts +741 -0
- package/e2e/cli/basic.test.ts +44 -0
- package/{src/cli/e2e → e2e/cli}/export-lite.test.ts +16 -12
- package/e2e/cli/init-gitignore.test.ts +86 -0
- package/e2e/cli/init.test.ts +76 -0
- package/e2e/cli/messages.test.ts +363 -0
- package/e2e/cli/serve.test.ts +76 -0
- package/{src/cli/e2e → e2e/cli}/skills.test.ts +11 -10
- package/{src/cli/e2e → e2e/daemon}/daemon.test.ts +57 -195
- package/e2e/jobs/agent-jobs.test.ts +216 -0
- package/e2e/jobs/cron.test.ts +64 -0
- package/e2e/jobs/restart.test.ts +108 -0
- package/e2e/policies/approval-session.test.ts +69 -0
- package/e2e/policies/auto-create-policies-file.test.ts +35 -0
- package/e2e/policies/builtin-manage-policies.test.ts +184 -0
- package/e2e/policies/builtin-run-host.test.ts +180 -0
- package/e2e/policies/environment-policies.test.ts +177 -0
- package/e2e/policies/manage-policies.test.ts +566 -0
- package/e2e/policies/output-size.test.ts +98 -0
- package/e2e/policies/policies-context-cwd.test.ts +160 -0
- package/e2e/policies/relative-script-path.test.ts +60 -0
- package/e2e/policies/requests-show.test.ts +135 -0
- package/e2e/policies/requests.test.ts +208 -0
- package/e2e/policies/slash-policies.test.ts +308 -0
- package/e2e/policies/startup-cleanup.test.ts +48 -0
- package/e2e/routers/session-timeout.test.ts +106 -0
- package/e2e/routers/slash-model.test.ts +152 -0
- package/e2e/routers/slash-new.test.ts +50 -0
- package/e2e/routers/slash-restart-adapter.test.ts +96 -0
- package/e2e/routers/slash-restart.test.ts +114 -0
- package/e2e/routers/slash-shutdown.test.ts +55 -0
- package/e2e/routers/slash-stop.test.ts +232 -0
- package/e2e/routers/slash-upgrade.test.ts +88 -0
- package/{src/cli/e2e → e2e/sandbox}/environments.test.ts +14 -13
- package/eslint.config.js +6 -0
- package/napkin.md +1 -1
- package/package.json +8 -3
- package/src/adapter-discord/commands.test.ts +42 -0
- package/src/adapter-discord/commands.ts +33 -0
- package/src/adapter-discord/config.ts +12 -0
- package/src/adapter-discord/forwarder.test.ts +499 -21
- package/src/adapter-discord/forwarder.ts +343 -124
- package/src/adapter-discord/inbound-cache.test.ts +47 -0
- package/src/adapter-discord/inbound-cache.ts +37 -0
- package/src/adapter-discord/index.test.ts +67 -2
- package/src/adapter-discord/index.ts +84 -216
- package/src/adapter-discord/interactions.test.ts +54 -3
- package/src/adapter-discord/interactions.ts +97 -53
- package/src/adapter-discord/processMessage.ts +239 -0
- package/src/adapter-discord/state.ts +1 -0
- package/src/adapter-google-chat/auth.test.ts +9 -5
- package/src/adapter-google-chat/auth.ts +29 -23
- package/src/adapter-google-chat/cards.ts +7 -2
- package/src/adapter-google-chat/client.test.ts +37 -2
- package/src/adapter-google-chat/client.ts +138 -38
- package/src/adapter-google-chat/config.ts +19 -0
- package/src/adapter-google-chat/forwarder.test.ts +81 -56
- package/src/adapter-google-chat/forwarder.ts +394 -185
- package/src/adapter-google-chat/inbound-cache.test.ts +61 -0
- package/src/adapter-google-chat/inbound-cache.ts +36 -0
- package/src/adapter-google-chat/state.test.ts +1 -0
- package/src/adapter-google-chat/state.ts +9 -1
- package/src/adapter-google-chat/subscriptions.ts +8 -6
- package/src/cli/builtin-policies.ts +44 -0
- package/src/cli/commands/agents.ts +59 -5
- package/src/cli/commands/down.ts +54 -2
- package/src/cli/commands/environments.ts +8 -2
- package/src/cli/commands/init.ts +31 -0
- package/src/cli/commands/logs.ts +116 -0
- package/src/cli/commands/policies.ts +6 -4
- package/src/cli/commands/serve.test.ts +67 -0
- package/src/cli/commands/serve.ts +284 -0
- package/src/cli/commands/up.ts +122 -2
- package/src/cli/commands/web-api/agents.ts +3 -2
- package/src/cli/index.ts +4 -0
- package/src/cli/install-detection.test.ts +72 -0
- package/src/cli/install-detection.ts +48 -0
- package/src/cli/lite.ts +54 -22
- package/src/cli/manage-policies-utils.ts +104 -0
- package/src/cli/manage-policies.ts +291 -0
- package/src/cli/run-host.ts +45 -0
- package/src/cli/supervisor-actions.ts +267 -0
- package/src/cli/supervisor-control.test.ts +129 -0
- package/src/cli/supervisor-control.ts +155 -0
- package/src/cli/supervisor-pid.ts +68 -0
- package/src/cli/supervisor.ts +277 -0
- package/src/daemon/agent/agent-context.ts +11 -11
- package/src/daemon/agent/agent-session.ts +8 -1
- package/src/daemon/agent/chat-logger.test.ts +78 -9
- package/src/daemon/agent/chat-logger.ts +25 -5
- package/src/daemon/agent/turn-registry.test.ts +89 -0
- package/src/daemon/agent/turn-registry.ts +94 -0
- package/src/daemon/agent/types.ts +2 -0
- package/src/daemon/api/agent-policy-endpoints.ts +263 -0
- package/src/daemon/api/agent-router.ts +47 -126
- package/src/daemon/api/index.test.ts +1 -0
- package/src/daemon/api/policy-request.test.ts +7 -5
- package/src/daemon/api/router-utils.ts +6 -5
- package/src/daemon/api/subagent-router.ts +110 -74
- package/src/daemon/api/subagent-utils.test.ts +60 -0
- package/src/daemon/api/subagent-utils.ts +113 -87
- package/src/daemon/api/user-router.ts +34 -8
- package/src/daemon/auth.ts +1 -0
- package/src/daemon/cron.test.ts +62 -4
- package/src/daemon/cron.ts +42 -16
- package/src/daemon/events.ts +65 -0
- package/src/daemon/index.ts +24 -1
- package/src/daemon/message-interruption.test.ts +1 -0
- package/src/daemon/message-jobs.test.ts +1 -0
- package/src/daemon/message.ts +78 -14
- package/src/daemon/observation.test.ts +26 -18
- package/src/daemon/pending-replies.test.ts +112 -0
- package/src/daemon/pending-replies.ts +162 -0
- package/src/daemon/policy-request-service.ts +3 -1
- package/src/daemon/policy-utils.test.ts +66 -1
- package/src/daemon/policy-utils.ts +126 -1
- package/src/daemon/request-store.ts +31 -0
- package/src/daemon/routers/session-timeout.ts +4 -0
- package/src/daemon/routers/slash-model.test.ts +344 -0
- package/src/daemon/routers/slash-model.ts +207 -0
- package/src/daemon/routers/slash-policies.test.ts +38 -32
- package/src/daemon/routers/slash-policies.ts +84 -33
- package/src/daemon/routers/slash-restart.test.ts +69 -0
- package/src/daemon/routers/slash-restart.ts +36 -0
- package/src/daemon/routers/slash-shutdown.test.ts +50 -0
- package/src/daemon/routers/slash-shutdown.ts +28 -0
- package/src/daemon/routers/slash-upgrade.test.ts +116 -0
- package/src/daemon/routers/slash-upgrade.ts +76 -0
- package/src/daemon/routers/types.ts +7 -0
- package/src/daemon/routers.ts +16 -0
- package/src/shared/adapters/blockquote.test.ts +28 -0
- package/src/shared/adapters/blockquote.ts +20 -0
- package/src/shared/adapters/filtering.test.ts +224 -10
- package/src/shared/adapters/filtering.ts +95 -7
- package/src/shared/adapters/inbound-cache.test.ts +48 -0
- package/src/shared/adapters/inbound-cache.ts +54 -0
- package/src/shared/adapters/turn-log-buffer.ts +266 -0
- package/src/shared/adapters/turn-log.test.ts +389 -0
- package/src/shared/adapters/turn-log.ts +357 -0
- package/src/shared/agent-utils.ts +12 -5
- package/src/shared/chats.test.ts +4 -0
- package/src/shared/chats.ts +9 -0
- package/src/shared/config.ts +16 -1
- package/src/shared/lite.ts +76 -2
- package/src/shared/policies.ts +26 -0
- package/src/shared/template-manifest.ts +267 -0
- package/src/shared/utils/shell.ts +61 -0
- package/src/shared/version.ts +34 -0
- package/src/shared/workspace.test.ts +217 -0
- package/src/shared/workspace.ts +626 -48
- package/templates/environments/cladding/allowlist-domain.mjs +125 -0
- package/templates/environments/cladding/env.json +21 -1
- package/templates/environments/cladding/run-with-network.mjs +54 -0
- package/templates/environments/macos-proxy/allowlist-domain.mjs +95 -0
- package/templates/environments/macos-proxy/env.json +8 -1
- package/templates/environments/macos-proxy/proxy.mjs +42 -13
- package/templates/gemini/template.json +5 -0
- package/templates/gemini-claw/template.json +13 -0
- package/templates/skills/clawmini-requests/SKILL.md +69 -10
- package/templates/skills/run-host/SKILL.md +51 -0
- package/templates/skills/skill-creator/SKILL.md +4 -3
- package/templates/skills/skill-creator/scripts/validate.sh +52 -0
- package/tsdown.config.ts +10 -1
- package/vitest.config.ts +2 -2
- package/web/.svelte-kit/ambient.d.ts +292 -176
- package/web/.svelte-kit/generated/server/internal.js +1 -1
- package/web/.svelte-kit/output/client/.vite/manifest.json +127 -137
- package/web/.svelte-kit/output/client/_app/immutable/chunks/{Drm9vgeP.js → 3AZlWB6U.js} +1 -1
- package/web/.svelte-kit/output/client/_app/immutable/chunks/BhRSsUCh.js +2 -0
- package/web/.svelte-kit/output/client/_app/immutable/chunks/BiLeM2i1.js +1 -0
- package/{dist/web/_app/immutable/chunks/CME08kGM.js → web/.svelte-kit/output/client/_app/immutable/chunks/BmBj85Ll.js} +1 -1
- package/web/.svelte-kit/output/client/_app/immutable/chunks/BrERcKAH.js +1 -0
- package/web/.svelte-kit/output/client/_app/immutable/chunks/Bv9252RM.js +1 -0
- package/web/.svelte-kit/output/client/_app/immutable/chunks/CIXNBPKi.js +1 -0
- package/web/.svelte-kit/output/client/_app/immutable/chunks/DISKL3GN.js +2 -0
- package/web/.svelte-kit/output/client/_app/immutable/chunks/{Zeh-C-mx.js → DcpaLzmX.js} +1 -1
- package/web/.svelte-kit/output/client/_app/immutable/chunks/DnQ3vS13.js +1 -0
- package/web/.svelte-kit/output/client/_app/immutable/chunks/KsloHTKS.js +1 -0
- package/{dist/web/_app/immutable/chunks/Ck-be5J2.js → web/.svelte-kit/output/client/_app/immutable/chunks/RsHsUj-8.js} +2 -2
- package/web/.svelte-kit/output/client/_app/immutable/chunks/{G_zz-Gou.js → wpfV79dV.js} +1 -1
- package/web/.svelte-kit/output/client/_app/immutable/entry/app.CIw1Qj0n.js +2 -0
- package/web/.svelte-kit/output/client/_app/immutable/entry/start.Di0-Jhte.js +1 -0
- package/web/.svelte-kit/output/client/_app/immutable/nodes/{0.CYS8iApT.js → 0.DYyUA1au.js} +1 -1
- package/web/.svelte-kit/output/client/_app/immutable/nodes/1.D-3QEMMZ.js +1 -0
- package/web/.svelte-kit/output/client/_app/immutable/nodes/{2.BnwnD1Ki.js → 2.4olHnH7U.js} +1 -1
- package/{dist/web/_app/immutable/nodes/3.Dr0ot9sV.js → web/.svelte-kit/output/client/_app/immutable/nodes/3.4w0bE-m2.js} +3 -3
- package/web/.svelte-kit/output/client/_app/immutable/nodes/4.CZvjhVHt.js +60 -0
- package/web/.svelte-kit/output/client/_app/immutable/nodes/{5.BBGQ_i84.js → 5.DLbPVJY2.js} +1 -1
- package/web/.svelte-kit/output/client/_app/version.json +1 -1
- package/web/.svelte-kit/output/server/.vite/manifest.json +12 -10
- package/web/.svelte-kit/output/server/chunks/Icon.js +1 -1
- package/web/.svelte-kit/output/server/chunks/client.js +1 -1
- package/web/.svelte-kit/output/server/chunks/exports.js +1 -1
- package/web/.svelte-kit/output/server/chunks/index-server.js +2 -1
- package/web/.svelte-kit/output/server/chunks/internal.js +1 -1
- package/web/.svelte-kit/output/server/chunks/render-context.js +77 -0
- package/web/.svelte-kit/output/server/chunks/root.js +739 -788
- package/web/.svelte-kit/output/server/chunks/shared.js +234 -21
- package/web/.svelte-kit/output/server/index.js +126 -90
- package/web/.svelte-kit/output/server/manifest-full.js +1 -1
- package/web/.svelte-kit/output/server/manifest.js +1 -1
- package/web/.svelte-kit/output/server/nodes/0.js +1 -1
- package/web/.svelte-kit/output/server/nodes/1.js +1 -1
- package/web/.svelte-kit/output/server/nodes/2.js +1 -1
- package/web/.svelte-kit/output/server/nodes/3.js +1 -1
- package/web/.svelte-kit/output/server/nodes/4.js +1 -1
- package/web/.svelte-kit/output/server/nodes/5.js +1 -1
- package/web/.svelte-kit/output/server/remote-entry.js +245 -81
- package/web/.svelte-kit/tsconfig.json +4 -1
- package/dist/cli/propose-policy.mjs.map +0 -1
- package/dist/lite-CBxOT1y5.mjs +0 -241
- package/dist/lite-CBxOT1y5.mjs.map +0 -1
- package/dist/routing-D8rTxtaV.mjs +0 -245
- package/dist/routing-D8rTxtaV.mjs.map +0 -1
- package/dist/web/_app/immutable/chunks/B6YN0Nuq.js +0 -1
- package/dist/web/_app/immutable/chunks/BmRlVmv6.js +0 -1
- package/dist/web/_app/immutable/chunks/CK9JZLaG.js +0 -2
- package/dist/web/_app/immutable/chunks/Ck3rYNON.js +0 -1
- package/dist/web/_app/immutable/chunks/DMtIqaiV.js +0 -2
- package/dist/web/_app/immutable/chunks/DhD271EB.js +0 -1
- package/dist/web/_app/immutable/chunks/DpuLqk8d.js +0 -1
- package/dist/web/_app/immutable/chunks/DsIToJCP.js +0 -1
- package/dist/web/_app/immutable/chunks/bBmtyQMj.js +0 -1
- package/dist/web/_app/immutable/entry/app.CJmSwntr.js +0 -2
- package/dist/web/_app/immutable/entry/start.ZpUrT2ak.js +0 -1
- package/dist/web/_app/immutable/nodes/1.Bli0Hqzn.js +0 -1
- package/dist/web/_app/immutable/nodes/4.oBhvQhcA.js +0 -60
- package/dist/workspace-BJmJBfKi.mjs +0 -456
- package/dist/workspace-BJmJBfKi.mjs.map +0 -1
- package/src/cli/e2e/agents.test.ts +0 -140
- package/src/cli/e2e/basic.test.ts +0 -43
- package/src/cli/e2e/cron.test.ts +0 -132
- package/src/cli/e2e/export-lite-func.test.ts +0 -206
- package/src/cli/e2e/fallbacks.test.ts +0 -175
- package/src/cli/e2e/init.test.ts +0 -77
- package/src/cli/e2e/messages.test.ts +0 -332
- package/src/cli/e2e/propose-policy.test.ts +0 -203
- package/src/cli/e2e/requests.test.ts +0 -180
- package/src/cli/e2e/session-timeout.test.ts +0 -192
- package/src/cli/e2e/slash-new.test.ts +0 -93
- package/src/cli/e2e/subagents.test.ts +0 -106
- package/src/cli/e2e/utils.ts +0 -66
- package/src/cli/propose-policy.ts +0 -91
- package/web/.svelte-kit/output/client/_app/immutable/chunks/B6YN0Nuq.js +0 -1
- package/web/.svelte-kit/output/client/_app/immutable/chunks/BmRlVmv6.js +0 -1
- package/web/.svelte-kit/output/client/_app/immutable/chunks/CK9JZLaG.js +0 -2
- package/web/.svelte-kit/output/client/_app/immutable/chunks/Ck3rYNON.js +0 -1
- package/web/.svelte-kit/output/client/_app/immutable/chunks/DMtIqaiV.js +0 -2
- package/web/.svelte-kit/output/client/_app/immutable/chunks/DhD271EB.js +0 -1
- package/web/.svelte-kit/output/client/_app/immutable/chunks/DpuLqk8d.js +0 -1
- package/web/.svelte-kit/output/client/_app/immutable/chunks/DsIToJCP.js +0 -1
- package/web/.svelte-kit/output/client/_app/immutable/chunks/bBmtyQMj.js +0 -1
- package/web/.svelte-kit/output/client/_app/immutable/entry/app.CJmSwntr.js +0 -2
- package/web/.svelte-kit/output/client/_app/immutable/entry/start.ZpUrT2ak.js +0 -1
- package/web/.svelte-kit/output/client/_app/immutable/nodes/1.Bli0Hqzn.js +0 -1
- package/web/.svelte-kit/output/client/_app/immutable/nodes/4.oBhvQhcA.js +0 -60
- package/web/.svelte-kit/output/server/chunks/false.js +0 -4
- /package/dist/cli/{propose-policy.d.mts → manage-policies.d.mts} +0 -0
- /package/{src/cli/e2e → e2e/_helpers}/global-setup.ts +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
2
|
import { userRouter as appRouter } from './api/index.js';
|
|
3
|
-
import { daemonEvents,
|
|
3
|
+
import { daemonEvents, emitMessageAppended } from './events.js';
|
|
4
4
|
import * as daemonChats from './chats.js';
|
|
5
5
|
|
|
6
6
|
vi.mock('./chats.js', async (importOriginal) => {
|
|
@@ -49,8 +49,8 @@ describe('Daemon Message Observation', () => {
|
|
|
49
49
|
const result = (await iterator.next()).value;
|
|
50
50
|
|
|
51
51
|
expect(result).toHaveLength(2);
|
|
52
|
-
expect(result![0]
|
|
53
|
-
expect(result![1]
|
|
52
|
+
expect(result![0]).toMatchObject({ kind: 'message', message: { id: '2' } });
|
|
53
|
+
expect(result![1]).toMatchObject({ kind: 'message', message: { id: '3' } });
|
|
54
54
|
});
|
|
55
55
|
|
|
56
56
|
it('waitForMessages should wait for a new message if none are available after lastMessageId', async () => {
|
|
@@ -67,16 +67,20 @@ describe('Daemon Message Observation', () => {
|
|
|
67
67
|
|
|
68
68
|
const waitPromise = iterator.next();
|
|
69
69
|
|
|
70
|
-
const newMessage = {
|
|
70
|
+
const newMessage = {
|
|
71
|
+
id: '2',
|
|
72
|
+
role: 'log' as const,
|
|
73
|
+
content: 'hi',
|
|
74
|
+
timestamp: '...',
|
|
75
|
+
} as unknown as import('./chats.js').ChatMessage;
|
|
71
76
|
|
|
72
|
-
// Simulate message arrival
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
}, 10);
|
|
77
|
+
// Simulate message arrival via the shared emit helper so the merged
|
|
78
|
+
// chat-stream channel receives it alongside the legacy one.
|
|
79
|
+
setTimeout(() => emitMessageAppended('chat-1', newMessage), 10);
|
|
76
80
|
|
|
77
81
|
const result = await waitPromise;
|
|
78
82
|
expect(result.value).toHaveLength(1);
|
|
79
|
-
expect(result.value![0]
|
|
83
|
+
expect(result.value![0]).toMatchObject({ kind: 'message', message: { id: '2' } });
|
|
80
84
|
});
|
|
81
85
|
|
|
82
86
|
it('waitForMessages should ignore messages for other chats while waiting', async () => {
|
|
@@ -95,10 +99,12 @@ describe('Daemon Message Observation', () => {
|
|
|
95
99
|
iterator.next().then((res: any) => (yieldedValue = res.value));
|
|
96
100
|
|
|
97
101
|
// Simulate message for another chat
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
+
emitMessageAppended('other-chat', {
|
|
103
|
+
id: 'x',
|
|
104
|
+
role: 'user',
|
|
105
|
+
content: 'wrong',
|
|
106
|
+
timestamp: '...',
|
|
107
|
+
} as unknown as import('./chats.js').ChatMessage);
|
|
102
108
|
|
|
103
109
|
// Wait a tick
|
|
104
110
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
@@ -106,13 +112,15 @@ describe('Daemon Message Observation', () => {
|
|
|
106
112
|
expect(yieldedValue).toBeNull(); // Should still be waiting
|
|
107
113
|
|
|
108
114
|
// Now simulate the correct chat
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
115
|
+
emitMessageAppended('chat-1', {
|
|
116
|
+
id: 'y',
|
|
117
|
+
role: 'user',
|
|
118
|
+
content: 'right',
|
|
119
|
+
timestamp: '...',
|
|
120
|
+
} as unknown as import('./chats.js').ChatMessage);
|
|
113
121
|
|
|
114
122
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
115
123
|
expect(yieldedValue).toHaveLength(1);
|
|
116
|
-
expect(yieldedValue![0]
|
|
124
|
+
expect(yieldedValue![0]).toMatchObject({ kind: 'message', message: { id: 'y' } });
|
|
117
125
|
});
|
|
118
126
|
});
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
enqueuePendingReply,
|
|
8
|
+
dequeuePendingReply,
|
|
9
|
+
readPendingReplies,
|
|
10
|
+
getPendingRepliesPath,
|
|
11
|
+
drainPendingReplies,
|
|
12
|
+
} from './pending-replies.js';
|
|
13
|
+
|
|
14
|
+
describe('pending-replies queue', () => {
|
|
15
|
+
let tmp: string;
|
|
16
|
+
|
|
17
|
+
beforeEach(() => {
|
|
18
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'clawmini-pending-'));
|
|
19
|
+
fs.mkdirSync(path.join(tmp, '.clawmini'), { recursive: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('returns empty list when file does not exist', () => {
|
|
27
|
+
expect(readPendingReplies(tmp)).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('round-trips entries via enqueue + readPendingReplies', () => {
|
|
31
|
+
enqueuePendingReply({ chatId: 'chat-1', kind: 'restart-complete' }, tmp);
|
|
32
|
+
enqueuePendingReply({ chatId: 'chat-2', kind: 'upgrade-complete', messageId: 'msg-x' }, tmp);
|
|
33
|
+
|
|
34
|
+
expect(fs.existsSync(getPendingRepliesPath(tmp))).toBe(true);
|
|
35
|
+
expect(readPendingReplies(tmp)).toEqual([
|
|
36
|
+
{ chatId: 'chat-1', kind: 'restart-complete' },
|
|
37
|
+
{ chatId: 'chat-2', kind: 'upgrade-complete', messageId: 'msg-x' },
|
|
38
|
+
]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('dequeuePendingReply removes the matching entry and deletes the file when empty', () => {
|
|
42
|
+
enqueuePendingReply({ chatId: 'chat-1', kind: 'upgrade-complete', messageId: 'm-1' }, tmp);
|
|
43
|
+
expect(dequeuePendingReply((e) => e.messageId === 'm-1', tmp)).toBe(true);
|
|
44
|
+
expect(fs.existsSync(getPendingRepliesPath(tmp))).toBe(false);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('dequeuePendingReply returns false when no entry matches', () => {
|
|
48
|
+
enqueuePendingReply({ chatId: 'chat-1', kind: 'restart-complete' }, tmp);
|
|
49
|
+
expect(dequeuePendingReply((e) => e.messageId === 'nope', tmp)).toBe(false);
|
|
50
|
+
expect(readPendingReplies(tmp)).toHaveLength(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('treats a corrupt file as empty and removes it on drain', async () => {
|
|
54
|
+
fs.writeFileSync(getPendingRepliesPath(tmp), 'not json');
|
|
55
|
+
await drainPendingReplies('1.2.3', tmp);
|
|
56
|
+
expect(fs.existsSync(getPendingRepliesPath(tmp))).toBe(false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('drainPendingReplies appends a SystemMessage for each entry', async () => {
|
|
60
|
+
enqueuePendingReply({ chatId: 'default', kind: 'restart-complete' }, tmp);
|
|
61
|
+
enqueuePendingReply({ chatId: 'default', kind: 'upgrade-complete' }, tmp);
|
|
62
|
+
enqueuePendingReply(
|
|
63
|
+
{
|
|
64
|
+
chatId: 'default',
|
|
65
|
+
kind: 'upgrade-failed',
|
|
66
|
+
requestedVersion: '9.9.9',
|
|
67
|
+
reason: 'npm install -g exited with code 1',
|
|
68
|
+
},
|
|
69
|
+
tmp
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
await drainPendingReplies('1.2.3', tmp);
|
|
73
|
+
|
|
74
|
+
const chatLog = path.join(tmp, '.clawmini', 'chats', 'default', 'chat.jsonl');
|
|
75
|
+
expect(fs.existsSync(chatLog)).toBe(true);
|
|
76
|
+
const lines = fs
|
|
77
|
+
.readFileSync(chatLog, 'utf-8')
|
|
78
|
+
.split('\n')
|
|
79
|
+
.filter((l) => l.trim());
|
|
80
|
+
expect(lines).toHaveLength(3);
|
|
81
|
+
const parsed = lines.map((l) => JSON.parse(l) as { role: string; content: string });
|
|
82
|
+
expect(parsed[0]?.role).toBe('system');
|
|
83
|
+
expect(parsed[0]?.content).toBe('Clawmini restarted (v1.2.3).');
|
|
84
|
+
expect(parsed[1]?.content).toBe('Clawmini upgraded to v1.2.3.');
|
|
85
|
+
expect(parsed[2]?.content).toBe(
|
|
86
|
+
'Clawmini upgrade to v9.9.9 failed: npm install -g exited with code 1'
|
|
87
|
+
);
|
|
88
|
+
expect(fs.existsSync(getPendingRepliesPath(tmp))).toBe(false);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('drain consumes entries one at a time, leaving the rest on disk if a delivery throws', async () => {
|
|
92
|
+
// Make the second delivery throw by giving it an invalid chatId. The
|
|
93
|
+
// shared chats helper rejects ids containing path separators.
|
|
94
|
+
enqueuePendingReply({ chatId: 'a', kind: 'restart-complete' }, tmp);
|
|
95
|
+
enqueuePendingReply({ chatId: '../escape', kind: 'restart-complete' }, tmp);
|
|
96
|
+
enqueuePendingReply({ chatId: 'c', kind: 'restart-complete' }, tmp);
|
|
97
|
+
|
|
98
|
+
// Silence the expected error log from the failing entry.
|
|
99
|
+
const err = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
100
|
+
try {
|
|
101
|
+
await drainPendingReplies('1.0.0', tmp);
|
|
102
|
+
} finally {
|
|
103
|
+
err.mockRestore();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// All three are consumed (the failing one is dropped after logging) and
|
|
107
|
+
// the file is removed. The two valid chats got their messages.
|
|
108
|
+
expect(fs.existsSync(getPendingRepliesPath(tmp))).toBe(false);
|
|
109
|
+
expect(fs.existsSync(path.join(tmp, '.clawmini', 'chats', 'a', 'chat.jsonl'))).toBe(true);
|
|
110
|
+
expect(fs.existsSync(path.join(tmp, '.clawmini', 'chats', 'c', 'chat.jsonl'))).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
import { getClawminiDir } from '../shared/workspace.js';
|
|
6
|
+
import type { SystemMessage } from '../shared/chats.js';
|
|
7
|
+
import { appendMessage } from './chats.js';
|
|
8
|
+
|
|
9
|
+
export type PendingReplyKind = 'restart-complete' | 'upgrade-complete' | 'upgrade-failed';
|
|
10
|
+
|
|
11
|
+
export interface PendingReply {
|
|
12
|
+
chatId: string;
|
|
13
|
+
kind: PendingReplyKind;
|
|
14
|
+
/** Original user messageId, recorded so the SystemMessage can reference it. */
|
|
15
|
+
messageId?: string;
|
|
16
|
+
/** Requested target version (set for upgrade-* kinds). */
|
|
17
|
+
requestedVersion?: string;
|
|
18
|
+
/** Failure reason; set for upgrade-failed. */
|
|
19
|
+
reason?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface PendingRepliesFile {
|
|
23
|
+
version: 1;
|
|
24
|
+
entries: PendingReply[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function getPendingRepliesPath(startDir = process.cwd()): string {
|
|
28
|
+
return path.join(getClawminiDir(startDir), 'pending-replies.json');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readFileSafe(filePath: string): PendingRepliesFile | null {
|
|
32
|
+
if (!fs.existsSync(filePath)) return null;
|
|
33
|
+
try {
|
|
34
|
+
const raw = fs.readFileSync(filePath, 'utf-8');
|
|
35
|
+
const parsed = JSON.parse(raw) as PendingRepliesFile;
|
|
36
|
+
if (!parsed || parsed.version !== 1 || !Array.isArray(parsed.entries)) return null;
|
|
37
|
+
return parsed;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function writeAtomic(filePath: string, data: PendingRepliesFile): void {
|
|
44
|
+
// tmp + rename so a crash never leaves a half-written file. The target file
|
|
45
|
+
// is on the same filesystem as the workspace, so rename is atomic.
|
|
46
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
47
|
+
const tmpPath = `${filePath}.${process.pid}.tmp`;
|
|
48
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2));
|
|
49
|
+
fs.renameSync(tmpPath, filePath);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function tryUnlink(filePath: string): void {
|
|
53
|
+
if (!fs.existsSync(filePath)) return;
|
|
54
|
+
try {
|
|
55
|
+
fs.unlinkSync(filePath);
|
|
56
|
+
} catch {
|
|
57
|
+
// best-effort
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function enqueuePendingReply(entry: PendingReply, startDir = process.cwd()): void {
|
|
62
|
+
const filePath = getPendingRepliesPath(startDir);
|
|
63
|
+
const existing = readFileSafe(filePath);
|
|
64
|
+
const entries = existing?.entries ?? [];
|
|
65
|
+
entries.push(entry);
|
|
66
|
+
writeAtomic(filePath, { version: 1, entries });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Remove the first entry that matches the predicate. Used to roll back an
|
|
71
|
+
* enqueued entry when the supervisor reports the action could not be started.
|
|
72
|
+
*/
|
|
73
|
+
export function dequeuePendingReply(
|
|
74
|
+
predicate: (entry: PendingReply) => boolean,
|
|
75
|
+
startDir = process.cwd()
|
|
76
|
+
): boolean {
|
|
77
|
+
const filePath = getPendingRepliesPath(startDir);
|
|
78
|
+
const existing = readFileSafe(filePath);
|
|
79
|
+
if (!existing) return false;
|
|
80
|
+
const idx = existing.entries.findIndex(predicate);
|
|
81
|
+
if (idx === -1) return false;
|
|
82
|
+
existing.entries.splice(idx, 1);
|
|
83
|
+
if (existing.entries.length === 0) {
|
|
84
|
+
tryUnlink(filePath);
|
|
85
|
+
} else {
|
|
86
|
+
writeAtomic(filePath, existing);
|
|
87
|
+
}
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function readPendingReplies(startDir = process.cwd()): PendingReply[] {
|
|
92
|
+
return readFileSafe(getPendingRepliesPath(startDir))?.entries ?? [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function renderMessage(entry: PendingReply, runtimeVersion: string): string {
|
|
96
|
+
switch (entry.kind) {
|
|
97
|
+
case 'restart-complete':
|
|
98
|
+
return `Clawmini restarted (v${runtimeVersion}).`;
|
|
99
|
+
case 'upgrade-complete':
|
|
100
|
+
return `Clawmini upgraded to v${runtimeVersion}.`;
|
|
101
|
+
case 'upgrade-failed': {
|
|
102
|
+
const target = entry.requestedVersion ? ` to v${entry.requestedVersion}` : '';
|
|
103
|
+
const reason = entry.reason ? `: ${entry.reason}` : '.';
|
|
104
|
+
return `Clawmini upgrade${target} failed${reason}`;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Append a SystemMessage for each pending reply. Called from the daemon
|
|
111
|
+
* startup path so adapters (which reconnect via tRPC subscription with
|
|
112
|
+
* lastMessageId) replay the message after the daemon comes back online.
|
|
113
|
+
*
|
|
114
|
+
* Crash-safe: each entry is consumed only after its SystemMessage is
|
|
115
|
+
* appended. A crash mid-loop leaves the un-delivered entries on disk for the
|
|
116
|
+
* next daemon start to drain.
|
|
117
|
+
*/
|
|
118
|
+
export async function drainPendingReplies(
|
|
119
|
+
runtimeVersion: string,
|
|
120
|
+
startDir = process.cwd()
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
const filePath = getPendingRepliesPath(startDir);
|
|
123
|
+
const data = readFileSafe(filePath);
|
|
124
|
+
if (!data) {
|
|
125
|
+
// A corrupt file is treated as empty — remove it so it doesn't trip the
|
|
126
|
+
// next read, but otherwise no-op.
|
|
127
|
+
tryUnlink(filePath);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const remaining: PendingReply[] = [...data.entries];
|
|
132
|
+
while (remaining.length > 0) {
|
|
133
|
+
const entry = remaining[0]!;
|
|
134
|
+
const sysMsg: SystemMessage = {
|
|
135
|
+
id: randomUUID(),
|
|
136
|
+
role: 'system',
|
|
137
|
+
content: renderMessage(entry, runtimeVersion),
|
|
138
|
+
timestamp: new Date().toISOString(),
|
|
139
|
+
sessionId: undefined,
|
|
140
|
+
event: 'router',
|
|
141
|
+
displayRole: 'agent',
|
|
142
|
+
...(entry.messageId ? { messageId: entry.messageId } : {}),
|
|
143
|
+
};
|
|
144
|
+
try {
|
|
145
|
+
await appendMessage(entry.chatId, sysMsg, startDir);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
// A delivery failure (e.g. chatId no longer exists) shouldn't block the
|
|
148
|
+
// rest of the queue from draining. Drop the entry after logging — the
|
|
149
|
+
// alternative is an infinite redelivery loop on the next start.
|
|
150
|
+
console.error(
|
|
151
|
+
`Failed to deliver pending reply to chat ${entry.chatId}:`,
|
|
152
|
+
err instanceof Error ? err.message : err
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
remaining.shift();
|
|
156
|
+
if (remaining.length === 0) {
|
|
157
|
+
tryUnlink(filePath);
|
|
158
|
+
} else {
|
|
159
|
+
writeAtomic(filePath, { version: 1, entries: remaining });
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
@@ -22,7 +22,8 @@ export class PolicyRequestService {
|
|
|
22
22
|
chatId: string,
|
|
23
23
|
agentId: string,
|
|
24
24
|
skipSave: boolean = false,
|
|
25
|
-
subagentId?: string
|
|
25
|
+
subagentId?: string,
|
|
26
|
+
cwd?: string
|
|
26
27
|
): Promise<PolicyRequest> {
|
|
27
28
|
const allRequests = await this.store.list();
|
|
28
29
|
const pendingCount = allRequests.filter((r) => r.state === 'Pending').length;
|
|
@@ -47,6 +48,7 @@ export class PolicyRequestService {
|
|
|
47
48
|
commandName,
|
|
48
49
|
args,
|
|
49
50
|
fileMappings: snapshotMappings,
|
|
51
|
+
...(cwd ? { cwd } : {}),
|
|
50
52
|
state: skipSave ? 'Approved' : 'Pending',
|
|
51
53
|
createdAt: Date.now(),
|
|
52
54
|
chatId,
|
|
@@ -2,7 +2,14 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import os from 'node:os';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
createSnapshot,
|
|
7
|
+
interpolateArgs,
|
|
8
|
+
executeSafe,
|
|
9
|
+
MAX_SNAPSHOT_SIZE,
|
|
10
|
+
translateSandboxPath,
|
|
11
|
+
assertPathInsideDir,
|
|
12
|
+
} from './policy-utils.js';
|
|
6
13
|
|
|
7
14
|
describe('policy-utils', () => {
|
|
8
15
|
let tempDir: string;
|
|
@@ -135,4 +142,62 @@ describe('policy-utils', () => {
|
|
|
135
142
|
expect(result.stdout.trim()).toBe('hello && echo injected');
|
|
136
143
|
});
|
|
137
144
|
});
|
|
145
|
+
|
|
146
|
+
describe('translateSandboxPath', () => {
|
|
147
|
+
it('strips baseDir from sandboxCwd and resolves against hostTargetDir', () => {
|
|
148
|
+
const result = translateSandboxPath('/app/src/components', '/app', '/Users/host/env');
|
|
149
|
+
expect(result).toBe(path.resolve('/Users/host/env', 'src/components'));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('handles sandboxCwd exactly matching baseDir', () => {
|
|
153
|
+
const result = translateSandboxPath('/app', '/app', '/Users/host/env');
|
|
154
|
+
expect(result).toBe(path.resolve('/Users/host/env'));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('treats sandboxCwd as relative to hostTargetDir when it does not start with baseDir', () => {
|
|
158
|
+
// Does not throw — translation is pure. Validation is the caller's job.
|
|
159
|
+
const result = translateSandboxPath('/other/path', '/app', '/Users/host/env');
|
|
160
|
+
expect(result).toBe(path.resolve('/Users/host/env', 'other/path'));
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it('does not validate path traversal (caller validates)', () => {
|
|
164
|
+
// `..` segments collapse via path.resolve but no security error is thrown.
|
|
165
|
+
const result = translateSandboxPath('/app/../../etc/passwd', '/app', '/Users/host/env');
|
|
166
|
+
expect(result).toBe(path.resolve('/Users/host/env', '../../etc/passwd'));
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
describe('assertPathInsideDir', () => {
|
|
171
|
+
it('does not throw when cwd is inside boundaryDir', () => {
|
|
172
|
+
const inside = path.join(agentDir, 'sub');
|
|
173
|
+
expect(() => assertPathInsideDir(inside, agentDir)).not.toThrow();
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it('allows cwd equal to boundaryDir', () => {
|
|
177
|
+
expect(() => assertPathInsideDir(agentDir, agentDir)).not.toThrow();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('throws when cwd escapes boundaryDir', () => {
|
|
181
|
+
const outside = path.join(agentDir, '..', 'outside');
|
|
182
|
+
expect(() => assertPathInsideDir(outside, agentDir)).toThrow(
|
|
183
|
+
/Security Error: Path resolves outside the allowed directory/
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('follows symlinks before comparing (prevents symlink escape)', async () => {
|
|
188
|
+
const outsideTarget = path.join(tempDir, 'outside-target');
|
|
189
|
+
await fs.mkdir(outsideTarget);
|
|
190
|
+
const linkInsideAgent = path.join(agentDir, 'escape-link');
|
|
191
|
+
await fs.symlink(outsideTarget, linkInsideAgent);
|
|
192
|
+
|
|
193
|
+
expect(() => assertPathInsideDir(linkInsideAgent, agentDir)).toThrow(
|
|
194
|
+
/Security Error: Path resolves outside the allowed directory/
|
|
195
|
+
);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('tolerates non-existent paths (uses the path as-is)', () => {
|
|
199
|
+
const nonExistentInside = path.join(agentDir, 'does-not-exist', 'nested');
|
|
200
|
+
expect(() => assertPathInsideDir(nonExistentInside, agentDir)).not.toThrow();
|
|
201
|
+
});
|
|
202
|
+
});
|
|
138
203
|
});
|
|
@@ -1,12 +1,105 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
|
-
import { constants } from 'node:fs';
|
|
2
|
+
import fsSync, { constants } from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { randomBytes } from 'node:crypto';
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { pathIsInsideDir } from '../shared/utils/fs.js';
|
|
7
7
|
import type { PolicyRequest, PolicyDefinition } from '../shared/policies.js';
|
|
8
|
+
import { resolveAgentDir } from './api/router-utils.js';
|
|
9
|
+
import {
|
|
10
|
+
getWorkspaceRoot,
|
|
11
|
+
getActiveEnvironmentInfo,
|
|
12
|
+
readEnvironment,
|
|
13
|
+
} from '../shared/workspace.js';
|
|
8
14
|
|
|
9
15
|
export const MAX_SNAPSHOT_SIZE = 5 * 1024 * 1024;
|
|
16
|
+
export const MAX_INLINE_OUTPUT_LENGTH = 500;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Strips the sandbox `baseDir` from `sandboxCwd` and resolves the remainder
|
|
20
|
+
* against `hostTargetDir` (the host dir that mirrors baseDir inside the
|
|
21
|
+
* sandbox). Pure translation — no security validation; callers must validate
|
|
22
|
+
* the result with `assertPathInsideDir`.
|
|
23
|
+
*/
|
|
24
|
+
export function translateSandboxPath(
|
|
25
|
+
sandboxCwd: string,
|
|
26
|
+
baseDir: string,
|
|
27
|
+
hostTargetDir: string
|
|
28
|
+
): string {
|
|
29
|
+
let relativePath = sandboxCwd;
|
|
30
|
+
if (sandboxCwd.startsWith(baseDir)) {
|
|
31
|
+
relativePath = sandboxCwd.slice(baseDir.length);
|
|
32
|
+
}
|
|
33
|
+
if (relativePath.startsWith('/') || relativePath.startsWith('\\')) {
|
|
34
|
+
relativePath = relativePath.slice(1);
|
|
35
|
+
}
|
|
36
|
+
return path.resolve(hostTargetDir, relativePath);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Throws if `cwd` (after symlink resolution) is not inside `boundaryDir`.
|
|
41
|
+
*
|
|
42
|
+
* Security note (TOCTOU): There is an inherent race between validating the
|
|
43
|
+
* resolved path here and the moment `spawn` uses it as cwd. A symlink created
|
|
44
|
+
* on the host filesystem in that window could redirect execution outside
|
|
45
|
+
* boundaryDir. We accept this because the sandboxed agent cannot modify the
|
|
46
|
+
* host filesystem — only a local user or process with host-level access could
|
|
47
|
+
* exploit the gap, and that is outside our threat model.
|
|
48
|
+
*/
|
|
49
|
+
export function assertPathInsideDir(cwd: string, boundaryDir: string): void {
|
|
50
|
+
const realCwd = tryRealpath(cwd);
|
|
51
|
+
const realBoundary = tryRealpath(boundaryDir);
|
|
52
|
+
if (!pathIsInsideDir(realCwd, realBoundary, { allowSameDir: true })) {
|
|
53
|
+
throw new Error(`Security Error: Path resolves outside the allowed directory: ${cwd}`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Realpath that tolerates missing leaves. Walks up to the nearest existing
|
|
58
|
+
// ancestor, realpaths that, and re-appends the missing tail. Needed so that
|
|
59
|
+
// symlinks in the existing prefix still resolve (e.g. macOS /var → /private/var)
|
|
60
|
+
// when the full path is not yet on disk.
|
|
61
|
+
function tryRealpath(p: string): string {
|
|
62
|
+
const resolved = path.resolve(p);
|
|
63
|
+
try {
|
|
64
|
+
return fsSync.realpathSync(resolved);
|
|
65
|
+
} catch (err: unknown) {
|
|
66
|
+
if (
|
|
67
|
+
!(err instanceof Error && 'code' in err && (err as NodeJS.ErrnoException).code === 'ENOENT')
|
|
68
|
+
) {
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const parent = path.dirname(resolved);
|
|
73
|
+
if (parent === resolved) return resolved;
|
|
74
|
+
return path.join(tryRealpath(parent), path.basename(resolved));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function resolveRequestCwd(
|
|
78
|
+
requestCwd: string | undefined,
|
|
79
|
+
agentId: string | undefined,
|
|
80
|
+
workspaceRoot: string
|
|
81
|
+
): Promise<string> {
|
|
82
|
+
if (!requestCwd) {
|
|
83
|
+
// TODO throw error instead?
|
|
84
|
+
return workspaceRoot;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const agentDir = await resolveAgentDir(agentId, workspaceRoot);
|
|
88
|
+
const envInfo = await getActiveEnvironmentInfo(agentDir, workspaceRoot);
|
|
89
|
+
const envConfig = envInfo ? await readEnvironment(envInfo.name, workspaceRoot) : null;
|
|
90
|
+
|
|
91
|
+
// Translate sandbox → host only when the env declares a baseDir (VM-style
|
|
92
|
+
// sandbox). Otherwise requestCwd is already a host path; resolve relative
|
|
93
|
+
// paths against agentDir and keep absolute paths as-is.
|
|
94
|
+
const hostCwd =
|
|
95
|
+
envInfo && envConfig?.baseDir
|
|
96
|
+
? translateSandboxPath(requestCwd, envConfig.baseDir, envInfo.targetPath)
|
|
97
|
+
: path.resolve(agentDir, requestCwd);
|
|
98
|
+
|
|
99
|
+
// Boundary: the agent dir
|
|
100
|
+
assertPathInsideDir(hostCwd, agentDir);
|
|
101
|
+
return hostCwd;
|
|
102
|
+
}
|
|
10
103
|
|
|
11
104
|
export async function createSnapshot(
|
|
12
105
|
requestedPath: string,
|
|
@@ -145,6 +238,38 @@ export async function executeRequest(
|
|
|
145
238
|
return { stdout, stderr, exitCode, commandStr };
|
|
146
239
|
}
|
|
147
240
|
|
|
241
|
+
/**
|
|
242
|
+
* Saves large stdout/stderr to files in the agent's tmp/ directory and returns
|
|
243
|
+
* placeholder strings pointing to those files. Small outputs are returned as-is.
|
|
244
|
+
*/
|
|
245
|
+
export async function truncateLargeOutput(
|
|
246
|
+
stdout: string,
|
|
247
|
+
stderr: string,
|
|
248
|
+
requestId: string,
|
|
249
|
+
agentId: string | undefined
|
|
250
|
+
): Promise<{ stdout: string; stderr: string }> {
|
|
251
|
+
const agentDir = await resolveAgentDir(agentId, getWorkspaceRoot());
|
|
252
|
+
const tmpDir = path.join(agentDir, 'tmp');
|
|
253
|
+
const needsTmpDir =
|
|
254
|
+
stdout.length >= MAX_INLINE_OUTPUT_LENGTH || stderr.length >= MAX_INLINE_OUTPUT_LENGTH;
|
|
255
|
+
|
|
256
|
+
if (needsTmpDir) {
|
|
257
|
+
await fs.mkdir(tmpDir, { recursive: true });
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (stdout.length >= MAX_INLINE_OUTPUT_LENGTH) {
|
|
261
|
+
await fs.writeFile(path.join(tmpDir, `stdout-${requestId}.txt`), stdout, 'utf-8');
|
|
262
|
+
stdout = `stdout is ${stdout.length} characters, saved to ./tmp/stdout-${requestId}.txt\n`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (stderr.length >= MAX_INLINE_OUTPUT_LENGTH) {
|
|
266
|
+
await fs.writeFile(path.join(tmpDir, `stderr-${requestId}.txt`), stderr, 'utf-8');
|
|
267
|
+
stderr = `stderr is ${stderr.length} characters, saved to ./tmp/stderr-${requestId}.txt\n`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return { stdout, stderr };
|
|
271
|
+
}
|
|
272
|
+
|
|
148
273
|
export async function generateRequestPreview(request: PolicyRequest): Promise<string> {
|
|
149
274
|
let previewContent = `Sandbox Policy Request: ${request.commandName}\n`;
|
|
150
275
|
previewContent += `ID: ${request.id}\n`;
|
|
@@ -15,6 +15,8 @@ const PolicyRequestSchema = z.object({
|
|
|
15
15
|
rejectionReason: z.string().optional(),
|
|
16
16
|
chatId: z.string(),
|
|
17
17
|
agentId: z.string(),
|
|
18
|
+
subagentId: z.string().optional(),
|
|
19
|
+
cwd: z.string().optional(),
|
|
18
20
|
});
|
|
19
21
|
|
|
20
22
|
function isENOENT(err: unknown): boolean {
|
|
@@ -46,6 +48,35 @@ export class RequestStore {
|
|
|
46
48
|
await fs.writeFile(filePath, JSON.stringify(request, null, 2), 'utf8');
|
|
47
49
|
}
|
|
48
50
|
|
|
51
|
+
async delete(id: string): Promise<void> {
|
|
52
|
+
const normalizedId = normalizePolicyId(id);
|
|
53
|
+
const filePath = this.getFilePath(normalizedId);
|
|
54
|
+
try {
|
|
55
|
+
await fs.unlink(filePath);
|
|
56
|
+
} catch (err: unknown) {
|
|
57
|
+
if (!isENOENT(err)) throw err;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async cleanupCompleted(): Promise<number> {
|
|
62
|
+
let removed = 0;
|
|
63
|
+
try {
|
|
64
|
+
const files = await fs.readdir(this.baseDir);
|
|
65
|
+
for (const file of files) {
|
|
66
|
+
if (!file.endsWith('.json')) continue;
|
|
67
|
+
const id = path.basename(file, '.json');
|
|
68
|
+
const req = await this.load(id);
|
|
69
|
+
if (req && req.state !== 'Pending') {
|
|
70
|
+
await this.delete(id);
|
|
71
|
+
removed++;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch (err: unknown) {
|
|
75
|
+
if (!isENOENT(err)) throw err;
|
|
76
|
+
}
|
|
77
|
+
return removed;
|
|
78
|
+
}
|
|
79
|
+
|
|
49
80
|
async load(id: string): Promise<PolicyRequest | null> {
|
|
50
81
|
const normalizedId = normalizePolicyId(id);
|
|
51
82
|
const filePath = this.getFilePath(normalizedId);
|
|
@@ -35,6 +35,10 @@ export function createSessionTimeoutRouter(config: SessionTimeoutConfig = {}) {
|
|
|
35
35
|
return state;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
if (state.subagentId) {
|
|
39
|
+
return state;
|
|
40
|
+
}
|
|
41
|
+
|
|
38
42
|
const sessionId = state.sessionId || crypto.randomUUID();
|
|
39
43
|
const jobId = `__session_timeout__${sessionId}`;
|
|
40
44
|
|