@zerwiz/ymir 0.1.4 → 0.1.5
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/.agents/README.md +291 -0
- package/.agents/agents/bragi-marketer.md +111 -0
- package/.agents/agents/brokk.md +352 -0
- package/.agents/agents/forseti-reviewer.md +93 -0
- package/.agents/agents/frigg-consent.md +77 -0
- package/.agents/agents/groa-update.md +77 -0
- package/.agents/agents/hnoss-designer.md +69 -0
- package/.agents/agents/huginn-researcher.md +99 -0
- package/.agents/agents/jord-projects.md +78 -0
- package/.agents/agents/kvasir-scout.md +91 -0
- package/.agents/agents/mimir-planner.md +92 -0
- package/.agents/agents/muninn-stow.md +77 -0
- package/.agents/agents/saga-bearings.md +78 -0
- package/.agents/agents/sindri-developer.md +114 -0
- package/.agents/agents/snotra-documenter.md +105 -0
- package/.agents/agents/syn-recovery.md +78 -0
- package/.agents/agents/tyr-check.md +78 -0
- package/.agents/agents/urdh-hold.md +77 -0
- package/.agents/agents/volundr-smith.md +80 -0
- package/.agents/agents/vor-diagnostics.md +77 -0
- package/.agents/assets/agents/naming.md +50 -0
- package/.agents/assets/agents/registry.md +93 -0
- package/.agents/assets/agents/runtime.md +36 -0
- package/.agents/assets/agents/toon-tasks-cli.md +42 -0
- package/.agents/assets/templates/company_entity.template.md +37 -0
- package/.agents/backend/backends/cmux.sh +649 -0
- package/.agents/backend/backends/herdr-eventwait.py +157 -0
- package/.agents/backend/backends/herdr-workspace-move.py +114 -0
- package/.agents/backend/backends/herdr.sh +3386 -0
- package/.agents/backend/backends/orca.sh +290 -0
- package/.agents/backend/backends/tmux.sh +357 -0
- package/.agents/backend/backends/zellij.sh +700 -0
- package/.agents/backend/fm-afk-launch.sh +654 -0
- package/.agents/backend/fm-afk-return.sh +241 -0
- package/.agents/backend/fm-afk-start.sh +172 -0
- package/.agents/backend/fm-arm-command-policy.mjs +960 -0
- package/.agents/backend/fm-arm-pretool-check.sh +200 -0
- package/.agents/backend/fm-backend-hometag-lib.sh +52 -0
- package/.agents/backend/fm-backend.sh +981 -0
- package/.agents/backend/fm-backlog-handoff.sh +909 -0
- package/.agents/backend/fm-backlog-receive.sh +187 -0
- package/.agents/backend/fm-backlog-transition-lib.sh +779 -0
- package/.agents/backend/fm-bearings-board.sh +204 -0
- package/.agents/backend/fm-bearings-snapshot.sh +546 -0
- package/.agents/backend/fm-bootstrap.sh +1568 -0
- package/.agents/backend/fm-branch-outcome.sh +203 -0
- package/.agents/backend/fm-branch-prompt.sh +101 -0
- package/.agents/backend/fm-brief.sh +452 -0
- package/.agents/backend/fm-busy-event.sh +234 -0
- package/.agents/backend/fm-busy-lib.sh +986 -0
- package/.agents/backend/fm-captain-hold.sh +1001 -0
- package/.agents/backend/fm-cd-command-policy.mjs +153 -0
- package/.agents/backend/fm-cd-pretool-check.sh +190 -0
- package/.agents/backend/fm-check-lib.sh +74 -0
- package/.agents/backend/fm-check-register.sh +42 -0
- package/.agents/backend/fm-check-unregister.sh +52 -0
- package/.agents/backend/fm-classify-lib.sh +1770 -0
- package/.agents/backend/fm-claude-stop-autoarm.sh +334 -0
- package/.agents/backend/fm-composer-lib.sh +1416 -0
- package/.agents/backend/fm-config-inherit-lib.sh +1202 -0
- package/.agents/backend/fm-config-push.sh +254 -0
- package/.agents/backend/fm-control-lib.sh +251 -0
- package/.agents/backend/fm-control.sh +878 -0
- package/.agents/backend/fm-crew-state.sh +625 -0
- package/.agents/backend/fm-cursor-lib.sh +243 -0
- package/.agents/backend/fm-decision-hold.sh +232 -0
- package/.agents/backend/fm-doc-audience-check.sh +269 -0
- package/.agents/backend/fm-dod-lib.sh +67 -0
- package/.agents/backend/fm-ensure-agents-md.sh +253 -0
- package/.agents/backend/fm-extension-launch-barrier.mjs +129 -0
- package/.agents/backend/fm-extension.mjs +2577 -0
- package/.agents/backend/fm-extension.sh +16 -0
- package/.agents/backend/fm-ff-lib.sh +420 -0
- package/.agents/backend/fm-fleet-snapshot.sh +1463 -0
- package/.agents/backend/fm-fleet-sync.sh +458 -0
- package/.agents/backend/fm-fleet-view.sh +96 -0
- package/.agents/backend/fm-gate-refuse-lib.sh +102 -0
- package/.agents/backend/fm-guard.sh +242 -0
- package/.agents/backend/fm-harness.sh +195 -0
- package/.agents/backend/fm-herdr-ci-cleanup.sh +115 -0
- package/.agents/backend/fm-herdr-lab.sh +346 -0
- package/.agents/backend/fm-herdr-session-cleanup.sh +337 -0
- package/.agents/backend/fm-home-seed.sh +984 -0
- package/.agents/backend/fm-home-summary-refresh.sh +255 -0
- package/.agents/backend/fm-hook-host-lib.sh +36 -0
- package/.agents/backend/fm-inactive-reconcile.sh +531 -0
- package/.agents/backend/fm-inbox.sh +399 -0
- package/.agents/backend/fm-install-actionlint.sh +84 -0
- package/.agents/backend/fm-install-herdr.sh +96 -0
- package/.agents/backend/fm-install-shellcheck.sh +84 -0
- package/.agents/backend/fm-install-treehouse.sh +95 -0
- package/.agents/backend/fm-kimi-turnend-hook.sh +276 -0
- package/.agents/backend/fm-lease-lib.sh +218 -0
- package/.agents/backend/fm-lease.sh +189 -0
- package/.agents/backend/fm-line-cap-lib.sh +51 -0
- package/.agents/backend/fm-lint-workflows.sh +137 -0
- package/.agents/backend/fm-lint.sh +591 -0
- package/.agents/backend/fm-lock-lib.sh +104 -0
- package/.agents/backend/fm-lock.sh +107 -0
- package/.agents/backend/fm-marker-lib.sh +12 -0
- package/.agents/backend/fm-merge-local.sh +74 -0
- package/.agents/backend/fm-merge-outcome-lib.sh +138 -0
- package/.agents/backend/fm-nm-run-lib.sh +124 -0
- package/.agents/backend/fm-on.sh +125 -0
- package/.agents/backend/fm-operational-input.sh +253 -0
- package/.agents/backend/fm-peek.sh +46 -0
- package/.agents/backend/fm-pending-reply-lib.sh +1409 -0
- package/.agents/backend/fm-pr-check.sh +135 -0
- package/.agents/backend/fm-pr-lib.sh +1016 -0
- package/.agents/backend/fm-pr-merge.sh +707 -0
- package/.agents/backend/fm-pr-poll.sh +110 -0
- package/.agents/backend/fm-primary-scope-lib.sh +33 -0
- package/.agents/backend/fm-procevent-extension-capture.pl +259 -0
- package/.agents/backend/fm-procevent-lavish.sh +628 -0
- package/.agents/backend/fm-procevent-lib.sh +892 -0
- package/.agents/backend/fm-procevent-quota.sh +290 -0
- package/.agents/backend/fm-procevent-remote-reply.sh +577 -0
- package/.agents/backend/fm-procevent-when.sh +504 -0
- package/.agents/backend/fm-procevent.sh +1466 -0
- package/.agents/backend/fm-project-mode.sh +92 -0
- package/.agents/backend/fm-project-origin-lib.sh +180 -0
- package/.agents/backend/fm-promote.sh +282 -0
- package/.agents/backend/fm-public-followup-emit.sh +260 -0
- package/.agents/backend/fm-public-followup-lib.sh +398 -0
- package/.agents/backend/fm-public-followup.sh +1330 -0
- package/.agents/backend/fm-push-transition-lib.sh +168 -0
- package/.agents/backend/fm-quota-axi-lib.sh +93 -0
- package/.agents/backend/fm-quota-choose.sh +384 -0
- package/.agents/backend/fm-remote-delta-read.sh +189 -0
- package/.agents/backend/fm-remote-doctor.sh +800 -0
- package/.agents/backend/fm-remote-entrypoint.sh +193 -0
- package/.agents/backend/fm-remote-file.sh +234 -0
- package/.agents/backend/fm-remote-home-provision.sh +259 -0
- package/.agents/backend/fm-remote-home-seed.sh +259 -0
- package/.agents/backend/fm-remote-inherit-push.sh +90 -0
- package/.agents/backend/fm-remote-inherit.sh +179 -0
- package/.agents/backend/fm-remote-job-lib.sh +1241 -0
- package/.agents/backend/fm-remote-job-reap-orphans.sh +137 -0
- package/.agents/backend/fm-remote-job-worker.sh +1124 -0
- package/.agents/backend/fm-remote-readiness-lib.sh +44 -0
- package/.agents/backend/fm-remote-secondmate-control.sh +340 -0
- package/.agents/backend/fm-review-diff.sh +158 -0
- package/.agents/backend/fm-secondmate-charter-lib.sh +44 -0
- package/.agents/backend/fm-secondmate-nudge-lib.sh +68 -0
- package/.agents/backend/fm-secondmate-parent-lib.sh +70 -0
- package/.agents/backend/fm-secondmate-reconcile.sh +371 -0
- package/.agents/backend/fm-secondmate-registry-lib.sh +311 -0
- package/.agents/backend/fm-secondmate-report.sh +88 -0
- package/.agents/backend/fm-send.sh +1059 -0
- package/.agents/backend/fm-session-lock-lib.sh +176 -0
- package/.agents/backend/fm-session-start.sh +970 -0
- package/.agents/backend/fm-sessionstart-cursor.sh +40 -0
- package/.agents/backend/fm-sessionstart-nudge.sh +45 -0
- package/.agents/backend/fm-sessionstart-run.sh +145 -0
- package/.agents/backend/fm-spawn.sh +3153 -0
- package/.agents/backend/fm-startup-memory-budget-lib.sh +224 -0
- package/.agents/backend/fm-startup-memory-budget.sh +94 -0
- package/.agents/backend/fm-startup-network.sh +642 -0
- package/.agents/backend/fm-stow-cascade.sh +251 -0
- package/.agents/backend/fm-subagent-pretool-check.sh +207 -0
- package/.agents/backend/fm-supervise-daemon.sh +1732 -0
- package/.agents/backend/fm-supervision-instructions.sh +215 -0
- package/.agents/backend/fm-supervision-lib.sh +90 -0
- package/.agents/backend/fm-supervisor-target-lib.sh +78 -0
- package/.agents/backend/fm-tangle-lib.sh +53 -0
- package/.agents/backend/fm-task-inbox-lib.sh +402 -0
- package/.agents/backend/fm-tasks-axi-lib.sh +123 -0
- package/.agents/backend/fm-teardown.sh +2922 -0
- package/.agents/backend/fm-test-isolation-proof.sh +588 -0
- package/.agents/backend/fm-test-run.sh +2191 -0
- package/.agents/backend/fm-timeout-lib.sh +141 -0
- package/.agents/backend/fm-timing-lib.sh +179 -0
- package/.agents/backend/fm-tmux-lib.sh +291 -0
- package/.agents/backend/fm-tool-update-check.sh +898 -0
- package/.agents/backend/fm-trace-context-lib.sh +227 -0
- package/.agents/backend/fm-transition-lib.sh +103 -0
- package/.agents/backend/fm-turnend-guard-cursor.sh +391 -0
- package/.agents/backend/fm-turnend-guard-grok.sh +90 -0
- package/.agents/backend/fm-turnend-guard.sh +432 -0
- package/.agents/backend/fm-update.sh +110 -0
- package/.agents/backend/fm-vendor-auth-probe.sh +190 -0
- package/.agents/backend/fm-voice-client.py +1373 -0
- package/.agents/backend/fm-voice-relay.py +1256 -0
- package/.agents/backend/fm-wake-drain.sh +592 -0
- package/.agents/backend/fm-wake-grant.sh +127 -0
- package/.agents/backend/fm-wake-lib.sh +1849 -0
- package/.agents/backend/fm-watch-arm.sh +602 -0
- package/.agents/backend/fm-watch-checkpoint.sh +109 -0
- package/.agents/backend/fm-watch.sh +1962 -0
- package/.agents/backend/fm-x-dismiss.sh +116 -0
- package/.agents/backend/fm-x-followup.sh +286 -0
- package/.agents/backend/fm-x-lib.sh +1000 -0
- package/.agents/backend/fm-x-link.sh +234 -0
- package/.agents/backend/fm-x-poll.sh +185 -0
- package/.agents/backend/fm-x-reply.sh +385 -0
- package/.agents/backend/fm_voice_frame.py +166 -0
- package/.agents/backend/fm_voice_records.py +574 -0
- package/.agents/backend/model-bridge.py +290 -0
- package/.agents/backend/opencode-go-bridge.py +30 -0
- package/.agents/bus/README.md +12 -0
- package/.agents/bus/messages.json +1 -0
- package/.agents/config/agents.machine.example.yaml +16 -0
- package/.agents/config/agents.yaml +50 -0
- package/.agents/config/agents.yaml.example +72 -0
- package/.agents/config/crew-dispatch.json +22 -0
- package/.agents/config/cron.yaml +9 -0
- package/.agents/config/eindri-dispatch.json +67 -0
- package/.agents/config/eindri-harness +1 -0
- package/.agents/config/startup-memory-budget +1 -0
- package/.agents/config/tailscale-sync.yaml +27 -0
- package/.agents/filebrowser/.filebrowser.json +6 -0
- package/.agents/filebrowser/README.md +11 -0
- package/.agents/gateway/README.md +12 -0
- package/.agents/github/README.md +15 -0
- package/.agents/github/webhooks/.gitkeep +0 -0
- package/.agents/github/workflows/.gitkeep +0 -0
- package/.agents/harness/opencode/plugins/lib/rodd-operational-input.js +37 -0
- package/.agents/harness/opencode/plugins/package.json +4 -0
- package/.agents/harness/opencode/plugins/saga-sessionstart.js +77 -0
- package/.agents/harness/opencode/plugins/syn-cd-check.js +61 -0
- package/.agents/harness/opencode/plugins/syn-pretool-check.js +68 -0
- package/.agents/harness/opencode/plugins/syn-turnend-guard.js +105 -0
- package/.agents/harness/opencode/plugins/syn-watch-arm.js +521 -0
- package/.agents/memory/README.md +46 -0
- package/.agents/memory/kaia.engram +0 -0
- package/.agents/memory/kaia.engram-shm +0 -0
- package/.agents/memory/kaia.engram-wal +0 -0
- package/.agents/memory/runes_audit.md +5 -0
- package/.agents/memory/well/episodes.jsonl +367 -0
- package/.agents/memory/well/workspace.jsonl +27 -0
- package/.agents/migrations/0001-hodd-layout.sh +38 -0
- package/.agents/migrations/0002-a2a-mcp.sh +27 -0
- package/.agents/migrations/0003-private-data-separation.sh +111 -0
- package/.agents/migrations/0004-hoard-and-realms.sh +104 -0
- package/.agents/sandbox/Dockerfile.utgard +19 -0
- package/.agents/sandbox/README.md +14 -0
- package/.agents/sandbox/utgard.config.json +14 -0
- package/.agents/skills/README.md +59 -0
- package/.agents/skills/bragi-marketing/README.md +3 -0
- package/.agents/skills/bragi-marketing/SKILL.md +82 -0
- package/.agents/skills/eindri-homes/README.md +3 -0
- package/.agents/skills/eindri-homes/SKILL.md +291 -0
- package/.agents/skills/eindri-homes/assets/control-plane.md +122 -0
- package/.agents/skills/eindri-homes/assets/dispatch.md +80 -0
- package/.agents/skills/frigg-consent/README.md +3 -0
- package/.agents/skills/frigg-consent/SKILL.md +56 -0
- package/.agents/skills/galdr-ymirsystem/README.md +3 -0
- package/.agents/skills/galdr-ymirsystem/SKILL.md +158 -0
- package/.agents/skills/galdr-ymirsystem/assets/README.md +74 -0
- package/.agents/skills/galdr-ymirsystem/assets/brokk-distro-runtime.md +390 -0
- package/.agents/skills/galdr-ymirsystem/assets/build-method.md +113 -0
- package/.agents/skills/galdr-ymirsystem/assets/build-tool-categories.md +178 -0
- package/.agents/skills/galdr-ymirsystem/assets/eindri-orchestration.md +518 -0
- package/.agents/skills/galdr-ymirsystem/assets/eindri-profiles.md +136 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/README.md +673 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/claude-code.md +197 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/codex.md +191 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/cursor.md +193 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/opencode.md +262 -0
- package/.agents/skills/galdr-ymirsystem/assets/harness-integration/pi.md +295 -0
- package/.agents/skills/galdr-ymirsystem/assets/hlidskjalf-ui.md +496 -0
- package/.agents/skills/galdr-ymirsystem/assets/installation.md +571 -0
- package/.agents/skills/galdr-ymirsystem/assets/local-models/HOST-DATA.example.md +72 -0
- package/.agents/skills/galdr-ymirsystem/assets/local-models.md +274 -0
- package/.agents/skills/galdr-ymirsystem/assets/memory-well.md +161 -0
- package/.agents/skills/galdr-ymirsystem/assets/nornir-jobs.md +377 -0
- package/.agents/skills/galdr-ymirsystem/assets/norse-naming.md +325 -0
- package/.agents/skills/galdr-ymirsystem/assets/odrerir-hall.md +129 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/einherjar-spawn.schema.json +81 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/herdr-profile.toml +75 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/pi-profile.yml +96 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot/supervision-tree.yml +163 -0
- package/.agents/skills/galdr-ymirsystem/assets/pi-boot-guide.md +58 -0
- package/.agents/skills/galdr-ymirsystem/assets/porting-upstream-to-norse.md +301 -0
- package/.agents/skills/galdr-ymirsystem/assets/principles.md +385 -0
- package/.agents/skills/galdr-ymirsystem/assets/reference-adoption.md +99 -0
- package/.agents/skills/galdr-ymirsystem/assets/registry.md +379 -0
- package/.agents/skills/galdr-ymirsystem/assets/runtime-compliance.md +463 -0
- package/.agents/skills/galdr-ymirsystem/assets/runtime-components.md +553 -0
- package/.agents/skills/galdr-ymirsystem/assets/smidja.md +175 -0
- package/.agents/skills/galdr-ymirsystem/schemas/toon-schemas.md +190 -0
- package/.agents/skills/galdr-ymirsystem/scripts/README.md +45 -0
- package/.agents/skills/galdr-ymirsystem/scripts/bench-one.sh +248 -0
- package/.agents/skills/galdr-ymirsystem/scripts/compliance-check.sh +360 -0
- package/.agents/skills/galdr-ymirsystem/scripts/gpu-sample.sh +43 -0
- package/.agents/skills/galdr-ymirsystem/scripts/stop-all.sh +34 -0
- package/.agents/skills/galdr-ymirsystem/scripts/toon-check.py +188 -0
- package/.agents/skills/gjallarhorn-relay/README.md +3 -0
- package/.agents/skills/gjallarhorn-relay/SKILL.md +283 -0
- package/.agents/skills/groa-update/SKILL.md +70 -0
- package/.agents/skills/hamr-adapters/README.md +3 -0
- package/.agents/skills/hamr-adapters/SKILL.md +123 -0
- package/.agents/skills/hamr-adapters/references/common/control-and-recovery.md +37 -0
- package/.agents/skills/hamr-adapters/references/common/dispatch.md +32 -0
- package/.agents/skills/hamr-adapters/references/common/model-and-effort.md +42 -0
- package/.agents/skills/hamr-adapters/references/common/primary-hooks.md +40 -0
- package/.agents/skills/hamr-adapters/references/harness/claude.md +55 -0
- package/.agents/skills/hamr-adapters/references/harness/codex.md +43 -0
- package/.agents/skills/hamr-adapters/references/harness/cursor.md +75 -0
- package/.agents/skills/hamr-adapters/references/harness/grok.md +69 -0
- package/.agents/skills/hamr-adapters/references/harness/kimi.md +51 -0
- package/.agents/skills/hamr-adapters/references/harness/muse.md +70 -0
- package/.agents/skills/hamr-adapters/references/harness/opencode.md +42 -0
- package/.agents/skills/hamr-adapters/references/harness/pi.md +56 -0
- package/.agents/skills/herdr-panes/README.md +3 -0
- package/.agents/skills/herdr-panes/SKILL.md +92 -0
- package/.agents/skills/herdr-panes/assets/herdr-backend.md +346 -0
- package/.agents/skills/herdr-panes/assets/tmux-backend.md +115 -0
- package/.agents/skills/hnoss-design/DESIGN.md +19 -0
- package/.agents/skills/hnoss-design/README.md +3 -0
- package/.agents/skills/hnoss-design/SKILL.md +83 -0
- package/.agents/skills/hvild-afk/README.md +3 -0
- package/.agents/skills/hvild-afk/SKILL.md +241 -0
- package/.agents/skills/jord-projects/README.md +3 -0
- package/.agents/skills/jord-projects/SKILL.md +92 -0
- package/.agents/skills/lifecycle/SKILL.md +56 -0
- package/.agents/skills/lifecycle/smoke_test.sh +52 -0
- package/.agents/skills/lifecycle/start.sh +21 -0
- package/.agents/skills/lifecycle/status.sh +35 -0
- package/.agents/skills/lifecycle/stop.sh +16 -0
- package/.agents/skills/muninn-stow/README.md +3 -0
- package/.agents/skills/muninn-stow/SKILL.md +309 -0
- package/.agents/skills/no-mistakes/SKILL.md +379 -0
- package/.agents/skills/nornir-schedule/README.md +3 -0
- package/.agents/skills/nornir-schedule/SKILL.md +25 -0
- package/.agents/skills/nornir-schedule/assets/events.md +137 -0
- package/.agents/skills/nornir-schedule/assets/quota.md +131 -0
- package/.agents/skills/nsr-compliance/README.md +3 -0
- package/.agents/skills/nsr-compliance/SKILL.md +29 -0
- package/.agents/skills/nsr-compliance/assets/nsr/README.md +40 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/README.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/git-workflow.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/security.md +22 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/RULES/tech-stack.md +8 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/SKILL.md +71 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/config/core_four.yaml +16 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_danger.sh +102 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/check_wiring.sh +69 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/validate_code.sh +29 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/gates/verify_docs.py +66 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/generate.sh +109 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/result_envelope.json +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/envelopes/task_envelope.json +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/harness/runner.py +303 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/telemetry/logger.py +112 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/README.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/config/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_env.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_paths.sh +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/gates/check_platform.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/harness/envelopes/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/installer/install.sh +125 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/NSRcompliance/templates/compliance/telemetry/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/README.md +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/README.md +36 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/features/SKILL-template.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/README.md +25 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/SKILL.md +13 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/create_branch.sh +6 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/safe_commit.sh +9 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/git_ops/sync_upstream.sh +6 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/README.md +27 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/SKILL.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/smoke_test.sh +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/start.sh +19 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/status.sh +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/agents_skills/lifecycle/stop.sh +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/README.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/config/core_four.yaml +13 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_danger.sh +102 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_env.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_paths.sh +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_platform.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/gates/check_wiring.sh +69 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/result_envelope.json +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/envelopes/task_envelope.json +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/harness/runner.py +303 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/installer/install.sh +125 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/compliance/telemetry/logger.py +112 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/README.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/api.md +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/compliance-wiring.md +155 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/BEST_PRACTICES/frontend.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/README.md +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/README.md +16 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/clients/template-README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/deploy.sh +49 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/development.env.example +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/production.env.example +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/envs/staging.env.example +17 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/CI_CD/deployment/tenants/template.env.example +8 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/README.md +26 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/agent.md +7 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/developers/dev-1.md +20 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/linux.md +9 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/macos.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/shared.md +19 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/DEVELOPER_SETUP/windows.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/README.md +25 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/_template.md +41 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/HOSTING/hosting-1.md +41 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/README.md +21 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/README.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/deployment.md +19 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/disaster-recovery.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/RUNBOOK/ops-tasks.md +14 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/features/README.md +16 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/project-skills.md +146 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/research/software-compliance-research.md +269 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/docs/working-with-agents.md +260 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/AGENTS.md +38 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/ARCHITECTURE.md +36 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/BEST_PRACTICES.md +27 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/CI_CD.md +23 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/FEATURES.md +15 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/README.md +22 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/RULES.md +37 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/STRUCTURE.md +45 -0
- package/.agents/skills/nsr-compliance/assets/nsr/assets/root/TECH_STACK.md +28 -0
- package/.agents/skills/nsr-compliance/assets/nsr/scaffold-spec.md +63 -0
- package/.agents/skills/nsr-compliance/assets/nsr/scripts/scaffold.sh +103 -0
- package/.agents/skills/nsr-compliance/assets/nsr/scripts/validate.sh +52 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/SKILL.md +71 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/config/core_four.yaml +16 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_danger.sh +102 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/check_wiring.sh +69 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/validate_code.sh +29 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/gates/verify_docs.py +66 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/generate.sh +109 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/result_envelope.json +14 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/envelopes/task_envelope.json +20 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/harness/runner.py +303 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/telemetry/logger.py +112 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/README.md +18 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/config/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/README.md +12 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_env.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_paths.sh +20 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/gates/check_platform.sh +23 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/README.md +11 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/harness/envelopes/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/README.md +10 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/installer/install.sh +125 -0
- package/.agents/skills/nsr-compliance/assets/nsrcompliance/templates/compliance/telemetry/README.md +11 -0
- package/.agents/skills/pr-ops/README.md +3 -0
- package/.agents/skills/pr-ops/SKILL.md +109 -0
- package/.agents/skills/ratatoskr-a2a/README.md +3 -0
- package/.agents/skills/ratatoskr-a2a/SKILL.md +148 -0
- package/.agents/skills/rules-check-drift/SKILL.md +81 -0
- package/.agents/skills/saga-bearings/README.md +3 -0
- package/.agents/skills/saga-bearings/SKILL.md +26 -0
- package/.agents/skills/saga-bearings/assets/bearings.md +161 -0
- package/.agents/skills/saga-bearings/assets/board-template.html +731 -0
- package/.agents/skills/saga-bearings/assets/recap.md +56 -0
- package/.agents/skills/syn-recovery/README.md +3 -0
- package/.agents/skills/syn-recovery/SKILL.md +54 -0
- package/.agents/skills/tyr-check/README.md +3 -0
- package/.agents/skills/tyr-check/SKILL.md +218 -0
- package/.agents/skills/urdh-hold/README.md +3 -0
- package/.agents/skills/urdh-hold/SKILL.md +25 -0
- package/.agents/skills/urdh-hold/assets/decisions.md +15 -0
- package/.agents/skills/urdh-hold/assets/hold.md +54 -0
- package/.agents/skills/vor-diagnostics/README.md +3 -0
- package/.agents/skills/vor-diagnostics/SKILL.md +68 -0
- package/.agents/skills/ymir-host/README.md +3 -0
- package/.agents/skills/ymir-host/SKILL.md +34 -0
- package/.agents/skills/ymir-host/assets/desktop.md +36 -0
- package/.agents/skills/ymir-host/assets/omarchy.md +297 -0
- package/.agents/skills/ymir-host/assets/runbooks.md +15 -0
- package/.agents/skills/ymir-host/assets/thjazi.md +220 -0
- package/.agents/skills/ymir-host/assets/update.md +64 -0
- package/.agents/tests/assets/board-render-harness.mjs +117 -0
- package/.agents/tests/cmux-test-safety.sh +51 -0
- package/.agents/tests/fixtures.sh +291 -0
- package/.agents/tests/fm-afk-inject-e2e.test.sh +428 -0
- package/.agents/tests/fm-afk-inject-herdr-e2e.test.sh +536 -0
- package/.agents/tests/fm-afk-launch.test.sh +961 -0
- package/.agents/tests/fm-afk-pi-herdr-return-e2e.test.sh +297 -0
- package/.agents/tests/fm-afk-return.test.sh +283 -0
- package/.agents/tests/fm-arm-pretool-check.test.sh +482 -0
- package/.agents/tests/fm-ask-user-authority.test.sh +42 -0
- package/.agents/tests/fm-backend-autodetect-smoke.test.sh +176 -0
- package/.agents/tests/fm-backend-cmux-smoke.test.sh +188 -0
- package/.agents/tests/fm-backend-cmux.test.sh +1166 -0
- package/.agents/tests/fm-backend-herdr-eventwait-smoke.test.sh +136 -0
- package/.agents/tests/fm-backend-herdr-eventwait.test.py +106 -0
- package/.agents/tests/fm-backend-herdr-focus-flash-e2e.test.sh +409 -0
- package/.agents/tests/fm-backend-herdr-launcher-workspace-e2e.test.sh +437 -0
- package/.agents/tests/fm-backend-herdr-presentation-e2e.test.sh +1424 -0
- package/.agents/tests/fm-backend-herdr-prune-safety-e2e.test.sh +180 -0
- package/.agents/tests/fm-backend-herdr-respawn-idem-e2e.test.sh +182 -0
- package/.agents/tests/fm-backend-herdr-smoke.test.sh +355 -0
- package/.agents/tests/fm-backend-herdr-workspace-per-home-e2e.test.sh +251 -0
- package/.agents/tests/fm-backend-herdr.test.sh +4606 -0
- package/.agents/tests/fm-backend-orca.test.sh +1357 -0
- package/.agents/tests/fm-backend-tmux-smoke.test.sh +173 -0
- package/.agents/tests/fm-backend-zellij-smoke.test.sh +205 -0
- package/.agents/tests/fm-backend-zellij.test.sh +1360 -0
- package/.agents/tests/fm-backend.test.sh +1142 -0
- package/.agents/tests/fm-backlog-atomicity.test.sh +2302 -0
- package/.agents/tests/fm-backlog-handoff.test.sh +1360 -0
- package/.agents/tests/fm-bearings-board-render.test.sh +135 -0
- package/.agents/tests/fm-bearings-board.test.sh +414 -0
- package/.agents/tests/fm-bearings-snapshot.test.sh +1990 -0
- package/.agents/tests/fm-bootstrap-network-parallel.test.sh +316 -0
- package/.agents/tests/fm-bootstrap.test.sh +1178 -0
- package/.agents/tests/fm-branch-supervision.test.sh +552 -0
- package/.agents/tests/fm-brief.test.sh +785 -0
- package/.agents/tests/fm-busy-adapter-wiring.test.sh +324 -0
- package/.agents/tests/fm-busy-state.test.sh +465 -0
- package/.agents/tests/fm-calm-pi-extension.test.sh +3979 -0
- package/.agents/tests/fm-captain-hold-lifecycle.test.sh +1187 -0
- package/.agents/tests/fm-cd-pretool-check.test.sh +400 -0
- package/.agents/tests/fm-check-unregister.test.sh +198 -0
- package/.agents/tests/fm-classify-corr-token.test.sh +543 -0
- package/.agents/tests/fm-classify-decision-key.test.sh +340 -0
- package/.agents/tests/fm-claude-stop-autoarm-live-e2e.test.sh +164 -0
- package/.agents/tests/fm-claude-stop-autoarm.test.sh +1190 -0
- package/.agents/tests/fm-cmux-claude-composer-live-e2e.test.sh +110 -0
- package/.agents/tests/fm-codex-continuity-live-e2e.test.sh +55 -0
- package/.agents/tests/fm-composer-ghost.test.sh +716 -0
- package/.agents/tests/fm-composer-lib.test.sh +666 -0
- package/.agents/tests/fm-composer-matrix-live-e2e.test.sh +220 -0
- package/.agents/tests/fm-control-herdr-smoke.test.sh +149 -0
- package/.agents/tests/fm-control-relaunch.test.sh +1530 -0
- package/.agents/tests/fm-control.test.sh +910 -0
- package/.agents/tests/fm-crew-state.test.sh +1610 -0
- package/.agents/tests/fm-cursor-harness.test.sh +404 -0
- package/.agents/tests/fm-cursor-primary-live-e2e.test.sh +217 -0
- package/.agents/tests/fm-cursor-primary.test.sh +708 -0
- package/.agents/tests/fm-daemon.test.sh +2740 -0
- package/.agents/tests/fm-documentation-audiences.test.sh +141 -0
- package/.agents/tests/fm-ensure-agents-md.test.sh +371 -0
- package/.agents/tests/fm-extension-binding.test.sh +2187 -0
- package/.agents/tests/fm-fleet-snapshot-view.test.sh +816 -0
- package/.agents/tests/fm-fleet-sync.test.sh +721 -0
- package/.agents/tests/fm-gate-refuse.test.sh +356 -0
- package/.agents/tests/fm-gitignore-config.test.sh +89 -0
- package/.agents/tests/fm-gotmp.test.sh +230 -0
- package/.agents/tests/fm-grok-continuity-live-e2e.test.sh +112 -0
- package/.agents/tests/fm-grok-harness.test.sh +117 -0
- package/.agents/tests/fm-grok-stop-live-e2e.test.sh +219 -0
- package/.agents/tests/fm-guard-stale-banner.test.sh +709 -0
- package/.agents/tests/fm-harness-adapter-instructions-live-e2e.test.sh +134 -0
- package/.agents/tests/fm-harness-adapter-references.test.sh +30 -0
- package/.agents/tests/fm-harness-liveness-drift-live-e2e.test.sh +151 -0
- package/.agents/tests/fm-herdr-lab.test.sh +243 -0
- package/.agents/tests/fm-herdr-session-cleanup-e2e.test.sh +146 -0
- package/.agents/tests/fm-herdr-session-cleanup.test.sh +329 -0
- package/.agents/tests/fm-herdr-submit-confirm-live-e2e.test.sh +125 -0
- package/.agents/tests/fm-herdr-version-floor-live-e2e.test.sh +144 -0
- package/.agents/tests/fm-home-summary-refresh.test.sh +971 -0
- package/.agents/tests/fm-inactive-reconcile.test.sh +485 -0
- package/.agents/tests/fm-kimi-harness.test.sh +687 -0
- package/.agents/tests/fm-lint-workflows.test.sh +532 -0
- package/.agents/tests/fm-lint.test.sh +1023 -0
- package/.agents/tests/fm-muse-harness.test.sh +932 -0
- package/.agents/tests/fm-muse-signals-live-e2e.test.sh +205 -0
- package/.agents/tests/fm-no-mistakes-required.test.sh +72 -0
- package/.agents/tests/fm-on.test.sh +507 -0
- package/.agents/tests/fm-opencode-primary-live-e2e.test.sh +357 -0
- package/.agents/tests/fm-operational-input.test.sh +160 -0
- package/.agents/tests/fm-peek-remote.test.sh +110 -0
- package/.agents/tests/fm-pending-reply.test.sh +1288 -0
- package/.agents/tests/fm-pi-branch-extension.test.sh +3182 -0
- package/.agents/tests/fm-pi-branch-live-e2e.test.sh +527 -0
- package/.agents/tests/fm-pi-primary-live-e2e.test.sh +345 -0
- package/.agents/tests/fm-pi-primary-types.test.sh +68 -0
- package/.agents/tests/fm-pi-watch-extension.test.sh +2843 -0
- package/.agents/tests/fm-pr-check-security.test.sh +2140 -0
- package/.agents/tests/fm-pr-merge.test.sh +2135 -0
- package/.agents/tests/fm-procevent-quota.test.sh +225 -0
- package/.agents/tests/fm-procevent-when.test.sh +406 -0
- package/.agents/tests/fm-procevent.test.sh +1745 -0
- package/.agents/tests/fm-project-origin.test.sh +110 -0
- package/.agents/tests/fm-public-followup.test.sh +2272 -0
- package/.agents/tests/fm-quota-array-dispatch-live-e2e.test.sh +526 -0
- package/.agents/tests/fm-quota-choose.test.sh +626 -0
- package/.agents/tests/fm-remote-backlog-handoff.test.sh +453 -0
- package/.agents/tests/fm-remote-doctor.test.sh +625 -0
- package/.agents/tests/fm-remote-entrypoint.test.sh +59 -0
- package/.agents/tests/fm-remote-job-orphan-reap.test.sh +205 -0
- package/.agents/tests/fm-remote-job.test.sh +768 -0
- package/.agents/tests/fm-remote-reply.test.sh +520 -0
- package/.agents/tests/fm-remote-secondmate-lifecycle-e2e.test.sh +1256 -0
- package/.agents/tests/fm-remote-secondmate-parent-binding.test.sh +326 -0
- package/.agents/tests/fm-remote-secondmate-trace-context.test.sh +310 -0
- package/.agents/tests/fm-remote-transport-lanes.test.sh +431 -0
- package/.agents/tests/fm-review-diff.test.sh +176 -0
- package/.agents/tests/fm-secondmate-harness.test.sh +2600 -0
- package/.agents/tests/fm-secondmate-lifecycle-e2e.test.sh +259 -0
- package/.agents/tests/fm-secondmate-liveness.test.sh +559 -0
- package/.agents/tests/fm-secondmate-reconcile.test.sh +732 -0
- package/.agents/tests/fm-secondmate-safety.test.sh +2972 -0
- package/.agents/tests/fm-secondmate-sync.test.sh +913 -0
- package/.agents/tests/fm-send-inbox-doorbell-live-e2e.test.sh +206 -0
- package/.agents/tests/fm-send-inbox.test.sh +352 -0
- package/.agents/tests/fm-send-popup-settle.test.sh +167 -0
- package/.agents/tests/fm-send-remote-delivery.test.sh +796 -0
- package/.agents/tests/fm-send-resolve-key.test.sh +551 -0
- package/.agents/tests/fm-send-secondmate-marker-herdr-e2e.test.sh +187 -0
- package/.agents/tests/fm-send-secondmate-marker.test.sh +278 -0
- package/.agents/tests/fm-send-settle.test.sh +147 -0
- package/.agents/tests/fm-send-strict.test.sh +241 -0
- package/.agents/tests/fm-session-lock-ancestry.test.sh +365 -0
- package/.agents/tests/fm-session-start.test.sh +2515 -0
- package/.agents/tests/fm-sessionstart-hook-live-e2e.test.sh +631 -0
- package/.agents/tests/fm-sessionstart-instruction-refresh-live-e2e.test.sh +230 -0
- package/.agents/tests/fm-sessionstart-nudge.test.sh +1039 -0
- package/.agents/tests/fm-shared-captain-inheritance.test.sh +404 -0
- package/.agents/tests/fm-spawn-batch.test.sh +148 -0
- package/.agents/tests/fm-spawn-dispatch-profile.test.sh +827 -0
- package/.agents/tests/fm-spawn-pool-base-freshen.test.sh +440 -0
- package/.agents/tests/fm-spawn-worktree-settle.test.sh +147 -0
- package/.agents/tests/fm-startup-memory-budget.test.sh +333 -0
- package/.agents/tests/fm-startup-network.test.sh +712 -0
- package/.agents/tests/fm-stow-cascade.test.sh +370 -0
- package/.agents/tests/fm-subagent-pretool-check.test.sh +291 -0
- package/.agents/tests/fm-supervision-events.test.sh +156 -0
- package/.agents/tests/fm-supervision-instructions.test.sh +189 -0
- package/.agents/tests/fm-tangle-guard.test.sh +277 -0
- package/.agents/tests/fm-task-delivery.test.sh +420 -0
- package/.agents/tests/fm-task-inbox.test.sh +513 -0
- package/.agents/tests/fm-teardown-endpoint-safety.test.sh +374 -0
- package/.agents/tests/fm-teardown.test.sh +2665 -0
- package/.agents/tests/fm-test-fixture-cleanup.test.sh +172 -0
- package/.agents/tests/fm-test-fixtures.test.sh +132 -0
- package/.agents/tests/fm-test-isolation-proof.test.sh +278 -0
- package/.agents/tests/fm-test-run.test.sh +1196 -0
- package/.agents/tests/fm-tmux-agent-liveness.test.sh +354 -0
- package/.agents/tests/fm-tmux-submit-busy.test.sh +340 -0
- package/.agents/tests/fm-tool-update-check.test.sh +1044 -0
- package/.agents/tests/fm-trace-context-lib.test.sh +253 -0
- package/.agents/tests/fm-trace-context-spawn.test.sh +581 -0
- package/.agents/tests/fm-transition-lib.test.sh +54 -0
- package/.agents/tests/fm-turnend-guard.test.sh +1822 -0
- package/.agents/tests/fm-update.test.sh +304 -0
- package/.agents/tests/fm-vendor-auth-probe.test.sh +395 -0
- package/.agents/tests/fm-voice-relay.test.sh +4741 -0
- package/.agents/tests/fm-wake-daemon-lifecycle-e2e.test.sh +171 -0
- package/.agents/tests/fm-wake-drain-open-decisions-cursor.test.sh +356 -0
- package/.agents/tests/fm-wake-drain-open-decisions.test.sh +226 -0
- package/.agents/tests/fm-wake-drain-unread-status.test.sh +379 -0
- package/.agents/tests/fm-wake-queue.test.sh +1224 -0
- package/.agents/tests/fm-watch-arm.test.sh +821 -0
- package/.agents/tests/fm-watch-checkpoint.test.sh +87 -0
- package/.agents/tests/fm-watch-recovery-loop.test.sh +223 -0
- package/.agents/tests/fm-watch-triage.test.sh +3889 -0
- package/.agents/tests/fm-watcher-lock.test.sh +1133 -0
- package/.agents/tests/fm-x-mode.test.sh +3042 -0
- package/.agents/tests/gleipnir-guard-pretool-check.test.sh +51 -0
- package/.agents/tests/gleipnir-machine-lock.test.sh +130 -0
- package/.agents/tests/herdr-test-safety.sh +42 -0
- package/.agents/tests/lib.sh +315 -0
- package/.agents/tests/remote-herdr-fixture.sh +125 -0
- package/.agents/tests/secondmate-helpers.sh +193 -0
- package/.agents/tests/smoke.test.sh +93 -0
- package/.agents/tests/wake-helpers.sh +338 -0
- package/.agents/tests/zellij-test-safety.sh +60 -0
- package/.agents/tools/README.md +10 -0
- package/.agents/tools/bin/tasks-cli +267 -0
- package/.agents/tools/tasks-cli.ts +216 -0
- package/AGENTS.md +454 -0
- package/CHANGELOG.md +881 -0
- package/LICENSE +1 -1
- package/NOTICE +63 -0
- package/README.md +113 -59
- package/RULES/01-domains.md +51 -0
- package/RULES/02-agents.md +78 -0
- package/RULES/03-houses.md +61 -0
- package/RULES/04-hoard.md +65 -0
- package/RULES/05-platforms.md +67 -0
- package/RULES/06-append-only.md +58 -0
- package/RULES/07-config.md +45 -0
- package/RULES/08-delivery-gate.md +75 -0
- package/RULES/README.md +20 -0
- package/bin/README.md +107 -0
- package/bin/a2a-mcp.sh +98 -0
- package/bin/a2a-serve.py +103 -0
- package/bin/a2a-serve.sh +8 -0
- package/bin/a2a-talk.sh +50 -0
- package/bin/a2abridge-ensure.sh +108 -0
- package/bin/agent-run.sh +43 -0
- package/bin/agents-config.sh +233 -0
- package/bin/apodex-smoke-test.sh +92 -0
- package/bin/bifrost-bridge.sh +112 -0
- package/bin/bifrost-ingress.sh +77 -0
- package/bin/bootstrap-macos.sh +107 -0
- package/bin/bootstrap-windows.ps1 +112 -0
- package/bin/branch-guard.sh +64 -0
- package/bin/brokk +73 -0
- package/bin/brokk-classify-lib.sh +1770 -0
- package/bin/brokk-lease-lib.sh +222 -0
- package/bin/brokk-lease.sh +193 -0
- package/bin/brokk-lint.sh +65 -0
- package/bin/brokk-send.sh +38 -0
- package/bin/brokk-update.sh +9 -0
- package/bin/brokk-wake-grant.sh +127 -0
- package/bin/brokk-wake-lib.sh +1849 -0
- package/bin/changelog-guard.sh +165 -0
- package/bin/crash-sense.sh +160 -0
- package/bin/desktop-place.sh +224 -0
- package/bin/docs-guard.sh +39 -0
- package/bin/editor-place.sh +207 -0
- package/bin/eindri-acclaim.sh +34 -0
- package/bin/eindri-control.sh +28 -0
- package/bin/eindri-role.sh +83 -0
- package/bin/eindri-seen.sh +41 -0
- package/bin/eindri-send.sh +15 -0
- package/bin/eindri-start.sh +85 -0
- package/bin/eindri-watch.sh +57 -0
- package/bin/einherjar-spawn.sh +576 -0
- package/bin/eir-doctor.sh +112 -0
- package/bin/erindi-brief.sh +271 -0
- package/bin/fleet-apply.sh +68 -0
- package/bin/github-deploy.sh +81 -0
- package/bin/gjallarhorn-expose.sh +102 -0
- package/bin/gjallarhorn-notify.sh +58 -0
- package/bin/gjallarhorn-purge.sh +54 -0
- package/bin/gjallarhorn-tunnel.sh +72 -0
- package/bin/gleipnir-lock-lib.sh +284 -0
- package/bin/groa-update.sh +122 -0
- package/bin/hall-snapshot.sh +92 -0
- package/bin/hamr-harness.sh +246 -0
- package/bin/heimdall-ssh-keys.sh +374 -0
- package/bin/herdr-agents.py +44 -0
- package/bin/herdr-ensure.sh +104 -0
- package/bin/herdr-run.sh +431 -0
- package/bin/hermes-ensure.sh +88 -0
- package/bin/hoard-lib.sh +25 -0
- package/bin/hodd.sh +78 -0
- package/bin/host-sense.sh +199 -0
- package/bin/huginn-research-worker.sh +108 -0
- package/bin/local-model-lock.sh +51 -0
- package/bin/mimir-bridge.py +226 -0
- package/bin/mimir-bridge.sh +87 -0
- package/bin/mimir-ingest.sh +131 -0
- package/bin/mimir.sh +113 -0
- package/bin/mjollnir-webhook.sh +59 -0
- package/bin/mjollnir.sh +85 -0
- package/bin/model-hardware.sh +82 -0
- package/bin/model-resolve.sh +147 -0
- package/bin/models-detect.sh +178 -0
- package/bin/nornir-cron-start.sh +162 -0
- package/bin/nornir-job-daily-briefing.sh +191 -0
- package/bin/nornir-job-git-sync.sh +152 -0
- package/bin/nornir-job-memory-housekeeping.sh +114 -0
- package/bin/nornir-job-observer.sh +166 -0
- package/bin/nornir-job-skillopt-sleep.sh +76 -0
- package/bin/omarchy-hook-install.sh +60 -0
- package/bin/omarchy-install.sh +148 -0
- package/bin/omarchy-plugins.sh +174 -0
- package/bin/omarchy-sense.sh +149 -0
- package/bin/perm-guard.sh +30 -0
- package/bin/pi-agent.sh +68 -0
- package/bin/pi-ensure.sh +77 -0
- package/bin/pi-local.sh +37 -0
- package/bin/pi-seat.sh +63 -0
- package/bin/prereq-ensure.sh +166 -0
- package/bin/private-guard.sh +38 -0
- package/bin/project-git.sh +55 -0
- package/bin/public-guard.sh +70 -0
- package/bin/ratatoskr.sh +49 -0
- package/bin/realm-lib.sh +32 -0
- package/bin/repo-scrub.sh +62 -0
- package/bin/rodd-operational-input.sh +250 -0
- package/bin/runes-append.sh +198 -0
- package/bin/saga-session-start.sh +172 -0
- package/bin/saga-sessionstart-run.sh +79 -0
- package/bin/saga-wake-drain.sh +41 -0
- package/bin/secret-guard.sh +63 -0
- package/bin/sessrumnir-ensure.sh +100 -0
- package/bin/sessrumnir-sync.sh +237 -0
- package/bin/sessrumnir.sh +91 -0
- package/bin/skillopt-setup.sh +76 -0
- package/bin/skuld-branch-outcome.sh +203 -0
- package/bin/skuld-branch-prompt.sh +101 -0
- package/bin/smidja-bootstrap.sh +90 -0
- package/bin/smidja-observe.sh +86 -0
- package/bin/syn-arm-pretool-check.sh +57 -0
- package/bin/syn-asset-pretool-check.sh +76 -0
- package/bin/syn-cd-pretool-check.sh +28 -0
- package/bin/syn-guard-pretool-check.sh +83 -0
- package/bin/syn-turnend-guard.sh +36 -0
- package/bin/syn-watch-arm.sh +99 -0
- package/bin/tailscale-sync.sh +190 -0
- package/bin/telegram-bot.sh +69 -0
- package/bin/toolchain.sh +74 -0
- package/bin/utgard.sh +111 -0
- package/bin/valhalla.sh +115 -0
- package/bin/valknut-load.sh +260 -0
- package/bin/vor-crew-state.sh +124 -0
- package/bin/wedge-notify.sh +47 -0
- package/bin/workspace-provision.sh +80 -0
- package/bin/workspace-rag.sh +150 -0
- package/bin/wyrd-db.sh +47 -0
- package/bin/yggdrasil.sh +152 -0
- package/bin/ymir-edit.sh +145 -0
- package/bin/ymir-install.sh +641 -0
- package/bin/ymir-invite.sh +93 -0
- package/bin/ymir-isolation.sh +88 -0
- package/bin/ymir-migrate.sh +61 -0
- package/bin/ymir-platform.sh +348 -0
- package/bin/ymir-say.sh +97 -0
- package/bin/ymir-setup-auth.sh +108 -0
- package/bin/ymir-validate.sh +173 -0
- package/bin/ymir.js +74 -16
- package/docs/Architecture.md +339 -0
- package/docs/design.md +351 -0
- package/docs/installations/README.md +63 -0
- package/docs/installations/ymir-home.md +93 -0
- package/docs/integration.md +105 -0
- package/docs/lore.md +1056 -0
- package/docs/research/agent-harnesses.md +290 -0
- package/docs/runbooks/README.md +19 -0
- package/docs/runbooks/a2a.md +91 -0
- package/docs/runbooks/agent-permissions.md +99 -0
- package/docs/runbooks/agents.md +57 -0
- package/docs/runbooks/models.md +69 -0
- package/docs/runbooks/realm-onboarding.md +60 -0
- package/docs/runbooks/secrets-and-hoard.md +60 -0
- package/docs/runbooks/updates-and-migrations.md +110 -0
- package/docs/runbooks/worktrees-isolation.md +72 -0
- package/docs/session-start.md +105 -0
- package/docs/skillopt-integration.md +321 -0
- package/docs/sync-system.md +66 -0
- package/docs/workspaces.md +104 -0
- package/docs/ymir-rut.md +260 -0
- package/package.json +44 -14
- package/packaging/build.sh +74 -0
- package/packaging/macos/Ymir Installer.command +43 -0
- package/packaging/windows/ymir-setup.nsi +74 -0
- package/scripts/README.md +22 -0
- package/scripts/electron.sh +210 -0
- package/scripts/start.sh +167 -0
- package/scripts/stop.sh +113 -0
- package/install.sh +0 -35
|
@@ -0,0 +1,2577 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Trusted external Firstmate extension binding host.
|
|
3
|
+
//
|
|
4
|
+
// Usage:
|
|
5
|
+
// fm-extension.mjs bind <package-root> --adapter <name> [--adapter <name> ...]
|
|
6
|
+
// --trust-same-user-code [--consent <fact> ...] [--timeout-ms <milliseconds>]
|
|
7
|
+
// fm-extension.sh remote-bind <secondmate-id> <package-root> [bind options]
|
|
8
|
+
// fm-extension.mjs retire-binding <extension-id>
|
|
9
|
+
// --if-binding-digest <sha256:digest>
|
|
10
|
+
// fm-extension.mjs retire-transfer <extension-id>
|
|
11
|
+
// --if-transfer-digest <sha256:digest> --if-binding-digest <sha256:digest>
|
|
12
|
+
// fm-extension.mjs list
|
|
13
|
+
// fm-extension.mjs inspect <extension-id>
|
|
14
|
+
// fm-extension.mjs verify [extension-id]
|
|
15
|
+
// fm-extension.mjs resolve-process-event <adapter>
|
|
16
|
+
// fm-extension.mjs process-event <adapter> <operation> [internal options]
|
|
17
|
+
// fm-extension.mjs cleanup-invocations [--source-id <id> | --binding-digest <sha256:digest>]
|
|
18
|
+
//
|
|
19
|
+
// bind Validate a package, copy its complete tree into this home's
|
|
20
|
+
// content-addressed read-only package store, perform the protocol
|
|
21
|
+
// handshake, and atomically write one home-local enabled binding.
|
|
22
|
+
// --adapter is repeatable and enables only that manifest-declared
|
|
23
|
+
// process-event adapter name. --trust-same-user-code is mandatory.
|
|
24
|
+
// A package manifest may additionally require explicit --consent
|
|
25
|
+
// facts: network, credential-store, task-metadata, or
|
|
26
|
+
// artifact-references. No hash is hand-authored; this command computes
|
|
27
|
+
// and verifies every manifest, entrypoint, binding, and tree digest.
|
|
28
|
+
// list Show enabled home-local bindings. An absent registry is a quiet,
|
|
29
|
+
// state-free "no extension bindings" result.
|
|
30
|
+
// inspect Print one validated binding as deterministic JSON.
|
|
31
|
+
// verify Revalidate package confinement, ownership, modes, links, complete
|
|
32
|
+
// tree integrity, executable identity, and the live handshake.
|
|
33
|
+
// resolve-process-event
|
|
34
|
+
// Internal registration boundary. Resolve one adapter from explicit
|
|
35
|
+
// bindings, verify it and its handshake, and print one bounded
|
|
36
|
+
// machine-readable identity record.
|
|
37
|
+
// process-event
|
|
38
|
+
// Internal invocation boundary used by bin/fm-procevent.sh. It
|
|
39
|
+
// revalidates the exact registration-pinned binding and package,
|
|
40
|
+
// handshakes, then invokes source.poll, result.classify,
|
|
41
|
+
// result.terminal, or result.silent through strict JSON.
|
|
42
|
+
//
|
|
43
|
+
// Discovery is only $FM_HOME/config/extensions.d/*.json. Current directories,
|
|
44
|
+
// projects, task copies, environment payloads, worker text, and Pi packages are
|
|
45
|
+
// never searched. Package executables are spawned directly with shell=false,
|
|
46
|
+
// receive one bounded UTF-8 JSON document on stdin, and must return exactly one
|
|
47
|
+
// bounded UTF-8 JSON document on stdout. Extension stderr is bounded and never
|
|
48
|
+
// copied into authoritative records. Timeout, malformed output, nonzero exit,
|
|
49
|
+
// or a surviving invocation process group is rejected after TERM/KILL cleanup.
|
|
50
|
+
//
|
|
51
|
+
// This is a trust and integrity boundary, not an operating-system sandbox.
|
|
52
|
+
// Enabled packages are trusted same-user code and retain that user's OS access.
|
|
53
|
+
// Their protocol responses remain untrusted evidence: this host exposes no
|
|
54
|
+
// merge, decision, destination, force, discard, cleanup, credential-use, task
|
|
55
|
+
// mutation, or stronger-operation capability.
|
|
56
|
+
|
|
57
|
+
import { spawn } from "node:child_process";
|
|
58
|
+
import { constants as fsConstants, fstat, read } from "node:fs";
|
|
59
|
+
import {
|
|
60
|
+
chmod,
|
|
61
|
+
copyFile,
|
|
62
|
+
link,
|
|
63
|
+
lstat,
|
|
64
|
+
mkdir,
|
|
65
|
+
open,
|
|
66
|
+
readFile,
|
|
67
|
+
readlink,
|
|
68
|
+
readdir,
|
|
69
|
+
realpath,
|
|
70
|
+
rename,
|
|
71
|
+
rmdir,
|
|
72
|
+
rm,
|
|
73
|
+
unlink,
|
|
74
|
+
writeFile,
|
|
75
|
+
} from "node:fs/promises";
|
|
76
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
77
|
+
import path from "node:path";
|
|
78
|
+
import { fileURLToPath } from "node:url";
|
|
79
|
+
import { TextDecoder, promisify } from "node:util";
|
|
80
|
+
|
|
81
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
82
|
+
const CODE_ROOT = path.dirname(path.dirname(SELF));
|
|
83
|
+
const LAUNCH_BARRIER = path.join(CODE_ROOT, "bin", "fm-extension-launch-barrier.mjs");
|
|
84
|
+
const MANIFEST_NAME = "firstmate-extension.json";
|
|
85
|
+
const HOST_PROTOCOLS = [1];
|
|
86
|
+
const PROCESS_EVENT_CAPABILITY = "process-event-adapter";
|
|
87
|
+
const PROCESS_EVENT_VERSIONS = [1];
|
|
88
|
+
const MANIFEST_SCHEMA = "firstmate.extension-manifest.v1";
|
|
89
|
+
const BINDING_SCHEMA = "firstmate.extension-binding.v1";
|
|
90
|
+
const HANDSHAKE_REQUEST_SCHEMA = "firstmate.extension-handshake-request.v1";
|
|
91
|
+
const HANDSHAKE_RESPONSE_SCHEMA = "firstmate.extension-handshake-response.v1";
|
|
92
|
+
const REQUEST_SCHEMA = "firstmate.extension-request.v1";
|
|
93
|
+
const RESPONSE_SCHEMA = "firstmate.extension-response.v1";
|
|
94
|
+
const RESOLUTION_SCHEMA = "fm-extension-process-event-resolution.v1";
|
|
95
|
+
const ERROR_EVIDENCE_SCHEMA = "firstmate.process-event-extension-error.v1";
|
|
96
|
+
const INVOCATION_OWNER_SCHEMA = "firstmate.extension-invocation-owner.v1";
|
|
97
|
+
const INVOCATION_READY_SCHEMA = "firstmate.extension-invocation-ready.v1";
|
|
98
|
+
const INVOCATION_RELEASE_SCHEMA = "firstmate.extension-invocation-release.v1";
|
|
99
|
+
const CAPTURE_RESERVATION_SCHEMA = "fm-procevent-capture-reservation.v1";
|
|
100
|
+
const MAX_JSON_BYTES = 65536;
|
|
101
|
+
const MAX_RESULT_BYTES = 32768;
|
|
102
|
+
const MAX_STDERR_BYTES = 8192;
|
|
103
|
+
const MAX_TREE_ENTRIES = 4096;
|
|
104
|
+
const MAX_TREE_BYTES = 64 * 1024 * 1024;
|
|
105
|
+
const TRANSFER_SCHEMA = "firstmate.extension-package-transfer.v1";
|
|
106
|
+
const TRANSFER_MANIFEST_SCHEMA = "firstmate.extension-package-transfer-manifest.v1";
|
|
107
|
+
const MAX_TRANSFER_JSON_BYTES = 900000;
|
|
108
|
+
const MAX_TRANSFER_ENTRIES = 128;
|
|
109
|
+
const MAX_TRANSFER_FILE_BYTES = 256 * 1024;
|
|
110
|
+
const MAX_TRANSFER_PACKAGE_BYTES = 512 * 1024;
|
|
111
|
+
const MAX_BINDINGS = 128;
|
|
112
|
+
const HANDSHAKE_TIMEOUT_MS = 5000;
|
|
113
|
+
const DEFAULT_TIMEOUT_MS = 300000;
|
|
114
|
+
const MIN_TIMEOUT_MS = 100;
|
|
115
|
+
const MAX_TIMEOUT_MS = 3600000;
|
|
116
|
+
const TERMINATE_GRACE_MS = 250;
|
|
117
|
+
const CLEANUP_WAIT_MS = 2000;
|
|
118
|
+
const LAUNCH_READY_WAIT_MS = 5000;
|
|
119
|
+
const INVOCATION_POLL_MS = 20;
|
|
120
|
+
const CONSENT_NAMES = ["network", "credential-store", "task-metadata", "artifact-references"];
|
|
121
|
+
const RESPONSE_ERROR_CODES = new Set(["invalid-request", "incompatible", "conflict", "unavailable", "internal"]);
|
|
122
|
+
const ID_RE = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
|
|
123
|
+
const ADAPTER_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
124
|
+
const SEMVER_RE = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
125
|
+
const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
|
|
126
|
+
const REQUEST_ID_RE = /^sha256:[0-9a-f]{64}$/;
|
|
127
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
128
|
+
const fstatAsync = promisify(fstat);
|
|
129
|
+
const readAsync = promisify(read);
|
|
130
|
+
|
|
131
|
+
class HostError extends Error {
|
|
132
|
+
constructor(code, message) {
|
|
133
|
+
super(message);
|
|
134
|
+
this.name = "HostError";
|
|
135
|
+
this.code = code;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function fail(code, message) {
|
|
140
|
+
throw new HostError(code, message);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function readPinnedDescriptor(fd, limit) {
|
|
144
|
+
const chunks = [];
|
|
145
|
+
let size = 0;
|
|
146
|
+
while (true) {
|
|
147
|
+
const buffer = Buffer.allocUnsafe(Math.min(65536, limit - size + 1));
|
|
148
|
+
const { bytesRead } = await readAsync(fd, buffer, 0, buffer.length, null);
|
|
149
|
+
if (bytesRead === 0) break;
|
|
150
|
+
size += bytesRead;
|
|
151
|
+
if (size > limit) fail("path-unsafe", "pinned descriptor exceeds its size limit");
|
|
152
|
+
chunks.push(buffer.subarray(0, bytesRead));
|
|
153
|
+
}
|
|
154
|
+
return Buffer.concat(chunks, size);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function isPlainObject(value) {
|
|
158
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function exactKeys(value, keys, label) {
|
|
162
|
+
if (!isPlainObject(value)) fail("schema-invalid", `${label} must be an object`);
|
|
163
|
+
const actual = Object.keys(value).sort();
|
|
164
|
+
const expected = [...keys].sort();
|
|
165
|
+
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
|
|
166
|
+
fail("schema-invalid", `${label} fields must be exactly: ${expected.join(", ")}`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function integerIn(value, min, max, label) {
|
|
171
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) {
|
|
172
|
+
fail("schema-invalid", `${label} must be an integer from ${min} to ${max}`);
|
|
173
|
+
}
|
|
174
|
+
return value;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function boundedString(value, max, label, pattern = null) {
|
|
178
|
+
if (typeof value !== "string" || value.length === 0 || Buffer.byteLength(value, "utf8") > max) {
|
|
179
|
+
fail("schema-invalid", `${label} must be a non-empty UTF-8 string of at most ${max} bytes`);
|
|
180
|
+
}
|
|
181
|
+
if (/[\x00-\x1f\x7f]/u.test(value)) fail("schema-invalid", `${label} contains a control character`);
|
|
182
|
+
if (pattern && !pattern.test(value)) fail("schema-invalid", `${label} has an unsupported value`);
|
|
183
|
+
return value;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function uniqueArray(value, label, itemValidator) {
|
|
187
|
+
if (!Array.isArray(value) || value.length === 0) fail("schema-invalid", `${label} must be a non-empty array`);
|
|
188
|
+
const seen = new Set();
|
|
189
|
+
return value.map((item, index) => {
|
|
190
|
+
const normalized = itemValidator(item, `${label}[${index}]`);
|
|
191
|
+
const key = typeof normalized === "string" ? normalized : JSON.stringify(normalized);
|
|
192
|
+
if (seen.has(key)) fail("schema-invalid", `${label} contains a duplicate value`);
|
|
193
|
+
seen.add(key);
|
|
194
|
+
return normalized;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function validateUnicode(value, label = "JSON") {
|
|
199
|
+
if (typeof value === "string") {
|
|
200
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
201
|
+
const code = value.charCodeAt(index);
|
|
202
|
+
if (code >= 0xd800 && code <= 0xdbff) {
|
|
203
|
+
const next = value.charCodeAt(index + 1);
|
|
204
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) fail("json-invalid", `${label} contains an unpaired UTF-16 surrogate`);
|
|
205
|
+
index += 1;
|
|
206
|
+
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
|
207
|
+
fail("json-invalid", `${label} contains an unpaired UTF-16 surrogate`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (Array.isArray(value)) {
|
|
213
|
+
value.forEach((entry) => validateUnicode(entry, label));
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
if (isPlainObject(value)) {
|
|
217
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
218
|
+
validateUnicode(key, label);
|
|
219
|
+
validateUnicode(entry, label);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
class StrictJsonParser {
|
|
225
|
+
constructor(text, label) {
|
|
226
|
+
this.text = text;
|
|
227
|
+
this.label = label;
|
|
228
|
+
this.index = 0;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
parse() {
|
|
232
|
+
this.space();
|
|
233
|
+
const value = this.value();
|
|
234
|
+
this.space();
|
|
235
|
+
if (this.index !== this.text.length) fail("json-invalid", `${this.label} contains trailing or multiple JSON documents`);
|
|
236
|
+
validateUnicode(value, this.label);
|
|
237
|
+
return value;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
space() {
|
|
241
|
+
while (/[\x20\t\r\n]/.test(this.text[this.index] || "")) this.index += 1;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
value() {
|
|
245
|
+
this.space();
|
|
246
|
+
const char = this.text[this.index];
|
|
247
|
+
if (char === "{") return this.object();
|
|
248
|
+
if (char === "[") return this.array();
|
|
249
|
+
if (char === '"') return this.string();
|
|
250
|
+
if (this.text.startsWith("true", this.index)) return this.literal("true", true);
|
|
251
|
+
if (this.text.startsWith("false", this.index)) return this.literal("false", false);
|
|
252
|
+
if (this.text.startsWith("null", this.index)) return this.literal("null", null);
|
|
253
|
+
if (char === "-" || /[0-9]/.test(char || "")) return this.number();
|
|
254
|
+
fail("json-invalid", `${this.label} has invalid JSON at byte ${this.index}`);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
literal(token, value) {
|
|
258
|
+
this.index += token.length;
|
|
259
|
+
return value;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
object() {
|
|
263
|
+
const result = Object.create(null);
|
|
264
|
+
this.index += 1;
|
|
265
|
+
this.space();
|
|
266
|
+
if (this.text[this.index] === "}") {
|
|
267
|
+
this.index += 1;
|
|
268
|
+
return result;
|
|
269
|
+
}
|
|
270
|
+
while (this.index < this.text.length) {
|
|
271
|
+
this.space();
|
|
272
|
+
if (this.text[this.index] !== '"') fail("json-invalid", `${this.label} has a non-string object key`);
|
|
273
|
+
const key = this.string();
|
|
274
|
+
if (Object.hasOwn(result, key)) fail("json-invalid", `${this.label} contains duplicate object key: ${key}`);
|
|
275
|
+
this.space();
|
|
276
|
+
if (this.text[this.index] !== ":") fail("json-invalid", `${this.label} is missing ':' after object key`);
|
|
277
|
+
this.index += 1;
|
|
278
|
+
result[key] = this.value();
|
|
279
|
+
this.space();
|
|
280
|
+
if (this.text[this.index] === "}") {
|
|
281
|
+
this.index += 1;
|
|
282
|
+
return result;
|
|
283
|
+
}
|
|
284
|
+
if (this.text[this.index] !== ",") fail("json-invalid", `${this.label} is missing ',' between object fields`);
|
|
285
|
+
this.index += 1;
|
|
286
|
+
}
|
|
287
|
+
fail("json-invalid", `${this.label} has an unterminated object`);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
array() {
|
|
291
|
+
const result = [];
|
|
292
|
+
this.index += 1;
|
|
293
|
+
this.space();
|
|
294
|
+
if (this.text[this.index] === "]") {
|
|
295
|
+
this.index += 1;
|
|
296
|
+
return result;
|
|
297
|
+
}
|
|
298
|
+
while (this.index < this.text.length) {
|
|
299
|
+
result.push(this.value());
|
|
300
|
+
this.space();
|
|
301
|
+
if (this.text[this.index] === "]") {
|
|
302
|
+
this.index += 1;
|
|
303
|
+
return result;
|
|
304
|
+
}
|
|
305
|
+
if (this.text[this.index] !== ",") fail("json-invalid", `${this.label} is missing ',' between array values`);
|
|
306
|
+
this.index += 1;
|
|
307
|
+
}
|
|
308
|
+
fail("json-invalid", `${this.label} has an unterminated array`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
string() {
|
|
312
|
+
const start = this.index;
|
|
313
|
+
this.index += 1;
|
|
314
|
+
let escaped = false;
|
|
315
|
+
while (this.index < this.text.length) {
|
|
316
|
+
const code = this.text.charCodeAt(this.index);
|
|
317
|
+
const char = this.text[this.index];
|
|
318
|
+
if (!escaped && char === '"') {
|
|
319
|
+
this.index += 1;
|
|
320
|
+
try {
|
|
321
|
+
return JSON.parse(this.text.slice(start, this.index));
|
|
322
|
+
} catch {
|
|
323
|
+
fail("json-invalid", `${this.label} has an invalid JSON string`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (!escaped && code < 0x20) fail("json-invalid", `${this.label} has an unescaped control character`);
|
|
327
|
+
if (!escaped && char === "\\") {
|
|
328
|
+
escaped = true;
|
|
329
|
+
} else {
|
|
330
|
+
escaped = false;
|
|
331
|
+
}
|
|
332
|
+
this.index += 1;
|
|
333
|
+
}
|
|
334
|
+
fail("json-invalid", `${this.label} has an unterminated string`);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
number() {
|
|
338
|
+
const remainder = this.text.slice(this.index);
|
|
339
|
+
const match = remainder.match(/^-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?/);
|
|
340
|
+
if (!match) fail("json-invalid", `${this.label} has an invalid number`);
|
|
341
|
+
this.index += match[0].length;
|
|
342
|
+
const value = Number(match[0]);
|
|
343
|
+
if (!Number.isFinite(value)) fail("json-invalid", `${this.label} has a non-finite number`);
|
|
344
|
+
return value;
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function parseStrictJson(bytes, label, maxBytes = MAX_JSON_BYTES) {
|
|
349
|
+
if (!Buffer.isBuffer(bytes)) bytes = Buffer.from(bytes);
|
|
350
|
+
if (bytes.length === 0) fail("json-invalid", `${label} is empty`);
|
|
351
|
+
if (bytes.length > maxBytes) fail("json-oversized", `${label} exceeds ${maxBytes} bytes`);
|
|
352
|
+
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
|
|
353
|
+
fail("json-invalid", `${label} must not begin with a UTF-8 BOM`);
|
|
354
|
+
}
|
|
355
|
+
let text;
|
|
356
|
+
try {
|
|
357
|
+
text = decoder.decode(bytes);
|
|
358
|
+
} catch {
|
|
359
|
+
fail("json-invalid", `${label} is not valid UTF-8`);
|
|
360
|
+
}
|
|
361
|
+
return new StrictJsonParser(text, label).parse();
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function canonicalJson(value) {
|
|
365
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
366
|
+
if (isPlainObject(value)) {
|
|
367
|
+
return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
|
|
368
|
+
}
|
|
369
|
+
return JSON.stringify(value);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function prettyJson(value) {
|
|
373
|
+
const sort = (entry) => {
|
|
374
|
+
if (Array.isArray(entry)) return entry.map(sort);
|
|
375
|
+
if (!isPlainObject(entry)) return entry;
|
|
376
|
+
const result = Object.create(null);
|
|
377
|
+
for (const key of Object.keys(entry).sort()) result[key] = sort(entry[key]);
|
|
378
|
+
return result;
|
|
379
|
+
};
|
|
380
|
+
return `${JSON.stringify(sort(value), null, 2)}\n`;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function digestBytes(bytes) {
|
|
384
|
+
return `sha256:${createHash("sha256").update(bytes).digest("hex")}`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function makeRequestId(seed = randomBytes(32)) {
|
|
388
|
+
const bytes = Buffer.isBuffer(seed) ? seed : Buffer.from(seed, "utf8");
|
|
389
|
+
return digestBytes(Buffer.concat([Buffer.from("firstmate-extension-request-v1\0"), bytes]));
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function modeOf(info) {
|
|
393
|
+
return info.mode & 0o777;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function currentUid() {
|
|
397
|
+
if (typeof process.getuid !== "function") fail("platform-unsupported", "extension bindings require a POSIX user identity");
|
|
398
|
+
return process.getuid();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function maybeLstat(target) {
|
|
402
|
+
try {
|
|
403
|
+
return await lstat(target);
|
|
404
|
+
} catch (error) {
|
|
405
|
+
if (error && error.code === "ENOENT") return null;
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
async function activeHome() {
|
|
411
|
+
const configured = process.env.FM_HOME || process.env.FM_ROOT_OVERRIDE || CODE_ROOT;
|
|
412
|
+
const absolute = path.resolve(configured);
|
|
413
|
+
const info = await maybeLstat(absolute);
|
|
414
|
+
if (!info || !info.isDirectory()) fail("home-invalid", `Firstmate home is not a directory: ${absolute}`);
|
|
415
|
+
return realpath(absolute);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function isInside(root, candidate) {
|
|
419
|
+
const relative = path.relative(root, candidate);
|
|
420
|
+
return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function assertOwnedSafeDirectory(target, label, exactPrivate = false) {
|
|
424
|
+
const info = await maybeLstat(target);
|
|
425
|
+
if (!info || !info.isDirectory() || info.isSymbolicLink()) fail("path-unsafe", `${label} is not a real directory: ${target}`);
|
|
426
|
+
if (info.uid !== currentUid()) fail("owner-mismatch", `${label} is not owned by the active user: ${target}`);
|
|
427
|
+
const mode = modeOf(info);
|
|
428
|
+
if (exactPrivate ? mode !== 0o700 : (mode & 0o022) !== 0) {
|
|
429
|
+
fail("mode-unsafe", `${label} has unsafe mode ${mode.toString(8)}: ${target}`);
|
|
430
|
+
}
|
|
431
|
+
const canonical = await realpath(target);
|
|
432
|
+
if (canonical !== target) fail("path-unsafe", `${label} traverses a symbolic link: ${target}`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function ensureDirectory(target, mode, label, exactPrivate = true) {
|
|
436
|
+
const existing = await maybeLstat(target);
|
|
437
|
+
if (!existing) await mkdir(target, { mode });
|
|
438
|
+
await assertOwnedSafeDirectory(target, label, exactPrivate);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function ensureHomePrivatePath(home, segments) {
|
|
442
|
+
let current = home;
|
|
443
|
+
for (let index = 0; index < segments.length; index += 1) {
|
|
444
|
+
current = path.join(current, segments[index]);
|
|
445
|
+
const exact = index > 0 || segments[0] !== "data" && segments[0] !== "state" && segments[0] !== "config";
|
|
446
|
+
const existing = await maybeLstat(current);
|
|
447
|
+
if (!existing) await mkdir(current, { mode: 0o700 });
|
|
448
|
+
await assertOwnedSafeDirectory(current, segments.slice(0, index + 1).join("/"), exact);
|
|
449
|
+
}
|
|
450
|
+
return current;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
function safeTreeName(name, label) {
|
|
454
|
+
if (!name || name === "." || name === ".." || /[\u0000-\u001f\u007f]/u.test(name)) {
|
|
455
|
+
fail("path-unsafe", `${label} has an unsafe path component`);
|
|
456
|
+
}
|
|
457
|
+
if (Buffer.from(name, "utf8").toString("utf8") !== name) fail("path-unsafe", `${label} has a non-UTF-8 path component`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function scanTree(root, { installed = false } = {}) {
|
|
461
|
+
const uid = currentUid();
|
|
462
|
+
const entries = [];
|
|
463
|
+
let entryCount = 0;
|
|
464
|
+
let totalBytes = 0;
|
|
465
|
+
const rootInfo = await maybeLstat(root);
|
|
466
|
+
if (!rootInfo || !rootInfo.isDirectory() || rootInfo.isSymbolicLink()) fail("package-invalid", `package root is not a real directory: ${root}`);
|
|
467
|
+
if (rootInfo.uid !== uid) fail("owner-mismatch", `package root is not owned by the active user: ${root}`);
|
|
468
|
+
if (installed ? modeOf(rootInfo) !== 0o555 : (modeOf(rootInfo) & 0o022) !== 0) {
|
|
469
|
+
fail("mode-unsafe", `package root mode is unsafe: ${modeOf(rootInfo).toString(8)}`);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function walk(directory, relativeDirectory) {
|
|
473
|
+
const names = await readdir(directory, { encoding: "buffer" });
|
|
474
|
+
names.sort(Buffer.compare);
|
|
475
|
+
for (const rawName of names) {
|
|
476
|
+
let name;
|
|
477
|
+
try {
|
|
478
|
+
name = decoder.decode(rawName);
|
|
479
|
+
} catch {
|
|
480
|
+
fail("path-unsafe", `package path ${relativeDirectory || "."} has a non-UTF-8 component`);
|
|
481
|
+
}
|
|
482
|
+
safeTreeName(name, `package path ${relativeDirectory || "."}`);
|
|
483
|
+
const absolute = path.join(directory, name);
|
|
484
|
+
const relative = relativeDirectory ? `${relativeDirectory}/${name}` : name;
|
|
485
|
+
const info = await lstat(absolute);
|
|
486
|
+
entryCount += 1;
|
|
487
|
+
if (entryCount > MAX_TREE_ENTRIES) fail("package-oversized", `package tree exceeds ${MAX_TREE_ENTRIES} entries`);
|
|
488
|
+
if (info.uid !== uid) fail("owner-mismatch", `package entry is not owned by the active user: ${relative}`);
|
|
489
|
+
if (info.isSymbolicLink()) fail("link-unsafe", `package tree contains a symbolic link: ${relative}`);
|
|
490
|
+
if (info.isDirectory()) {
|
|
491
|
+
const mode = modeOf(info);
|
|
492
|
+
if (installed ? mode !== 0o555 : (mode & 0o022) !== 0) {
|
|
493
|
+
fail("mode-unsafe", `package directory has unsafe mode ${mode.toString(8)}: ${relative}`);
|
|
494
|
+
}
|
|
495
|
+
entries.push({ type: "directory", relative, executable: true, info });
|
|
496
|
+
await walk(absolute, relative);
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
if (!info.isFile()) fail("package-invalid", `package tree contains a non-file entry: ${relative}`);
|
|
500
|
+
if (info.nlink !== 1) fail("link-unsafe", `package file has ${info.nlink} hard links: ${relative}`);
|
|
501
|
+
const mode = modeOf(info);
|
|
502
|
+
if (installed) {
|
|
503
|
+
const wanted = (mode & 0o111) !== 0 ? 0o555 : 0o444;
|
|
504
|
+
if (mode !== wanted) fail("mode-unsafe", `installed package file has mode ${mode.toString(8)}, expected ${wanted.toString(8)}: ${relative}`);
|
|
505
|
+
} else if ((mode & 0o022) !== 0) {
|
|
506
|
+
fail("mode-unsafe", `package file is group/world writable: ${relative}`);
|
|
507
|
+
}
|
|
508
|
+
totalBytes += info.size;
|
|
509
|
+
if (totalBytes > MAX_TREE_BYTES) fail("package-oversized", `package tree exceeds ${MAX_TREE_BYTES} bytes`);
|
|
510
|
+
const bytes = await readFile(absolute);
|
|
511
|
+
entries.push({
|
|
512
|
+
type: "file",
|
|
513
|
+
relative,
|
|
514
|
+
executable: (mode & 0o111) !== 0,
|
|
515
|
+
size: bytes.length,
|
|
516
|
+
digest: digestBytes(bytes),
|
|
517
|
+
info,
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
await walk(root, "");
|
|
523
|
+
const hash = createHash("sha256");
|
|
524
|
+
hash.update("firstmate-package-tree-v1\0");
|
|
525
|
+
for (const entry of entries) {
|
|
526
|
+
hash.update(entry.type === "directory" ? "D\0" : "F\0");
|
|
527
|
+
hash.update(entry.relative, "utf8");
|
|
528
|
+
hash.update("\0");
|
|
529
|
+
hash.update(entry.executable ? "x\0" : "-\0");
|
|
530
|
+
if (entry.type === "file") {
|
|
531
|
+
hash.update(String(entry.size));
|
|
532
|
+
hash.update("\0");
|
|
533
|
+
hash.update(entry.digest);
|
|
534
|
+
hash.update("\0");
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
return { entries, digest: `sha256:${hash.digest("hex")}`, entryCount, totalBytes };
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
function validateManifest(value) {
|
|
541
|
+
exactKeys(value, ["schema", "id", "version", "host_protocols", "entrypoint", "capabilities", "required_consents"], "extension manifest");
|
|
542
|
+
if (value.schema !== MANIFEST_SCHEMA) fail("schema-invalid", `unsupported extension manifest schema: ${value.schema}`);
|
|
543
|
+
const id = boundedString(value.id, 128, "manifest id", ID_RE);
|
|
544
|
+
const version = boundedString(value.version, 128, "manifest version", SEMVER_RE);
|
|
545
|
+
const hostProtocols = uniqueArray(value.host_protocols, "manifest host_protocols", (entry, label) => integerIn(entry, 1, 2147483647, label));
|
|
546
|
+
const entrypoint = boundedString(value.entrypoint, 256, "manifest entrypoint");
|
|
547
|
+
if (path.isAbsolute(entrypoint) || entrypoint.includes("\\") || entrypoint.split("/").some((part) => part === "" || part === "." || part === "..")) {
|
|
548
|
+
fail("path-unsafe", "manifest entrypoint must be a normalized relative POSIX path");
|
|
549
|
+
}
|
|
550
|
+
const requiredConsents = uniqueArrayOrEmpty(value.required_consents, "manifest required_consents", (entry, label) => {
|
|
551
|
+
const consent = boundedString(entry, 64, label);
|
|
552
|
+
if (!CONSENT_NAMES.includes(consent)) fail("schema-invalid", `${label} is not a supported consent fact`);
|
|
553
|
+
return consent;
|
|
554
|
+
});
|
|
555
|
+
if (!Array.isArray(value.capabilities) || value.capabilities.length !== 1) {
|
|
556
|
+
fail("schema-invalid", "manifest capabilities must contain exactly process-event-adapter");
|
|
557
|
+
}
|
|
558
|
+
const capability = value.capabilities[0];
|
|
559
|
+
exactKeys(capability, ["name", "versions", "adapter_names"], "process-event capability");
|
|
560
|
+
if (capability.name !== PROCESS_EVENT_CAPABILITY) fail("schema-invalid", "only process-event-adapter is supported in this binding version");
|
|
561
|
+
const versions = uniqueArray(capability.versions, "capability versions", (entry, label) => integerIn(entry, 1, 2147483647, label));
|
|
562
|
+
const adapterNames = uniqueArray(capability.adapter_names, "capability adapter_names", (entry, label) => boundedString(entry, 32, label, ADAPTER_RE));
|
|
563
|
+
return {
|
|
564
|
+
schema: value.schema,
|
|
565
|
+
id,
|
|
566
|
+
version,
|
|
567
|
+
host_protocols: hostProtocols,
|
|
568
|
+
entrypoint,
|
|
569
|
+
capabilities: [{ name: PROCESS_EVENT_CAPABILITY, versions, adapter_names: adapterNames }],
|
|
570
|
+
required_consents: requiredConsents,
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function uniqueArrayOrEmpty(value, label, itemValidator) {
|
|
575
|
+
if (!Array.isArray(value)) fail("schema-invalid", `${label} must be an array`);
|
|
576
|
+
if (value.length === 0) return [];
|
|
577
|
+
return uniqueArray(value, label, itemValidator);
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
async function validatePackage(root, { installed = false, expected = null } = {}) {
|
|
581
|
+
const canonical = await realpath(root).catch(() => fail("package-missing", `package root is unavailable: ${root}`));
|
|
582
|
+
if (canonical !== root) fail("path-unsafe", `package root is not canonical: ${root}`);
|
|
583
|
+
const tree = await scanTree(root, { installed });
|
|
584
|
+
const manifestEntry = tree.entries.find((entry) => entry.relative === MANIFEST_NAME);
|
|
585
|
+
if (!manifestEntry || manifestEntry.type !== "file") fail("manifest-missing", `package has no ${MANIFEST_NAME}`);
|
|
586
|
+
if (manifestEntry.size > MAX_JSON_BYTES) fail("manifest-oversized", `extension manifest exceeds ${MAX_JSON_BYTES} bytes`);
|
|
587
|
+
const manifestBytes = await readFile(path.join(root, MANIFEST_NAME));
|
|
588
|
+
const manifest = validateManifest(parseStrictJson(manifestBytes, "extension manifest"));
|
|
589
|
+
const entrypointEntry = tree.entries.find((entry) => entry.relative === manifest.entrypoint);
|
|
590
|
+
if (!entrypointEntry || entrypointEntry.type !== "file") fail("entrypoint-missing", `manifest entrypoint is missing: ${manifest.entrypoint}`);
|
|
591
|
+
if (!entrypointEntry.executable) fail("entrypoint-invalid", `manifest entrypoint is not executable: ${manifest.entrypoint}`);
|
|
592
|
+
const packageInfo = {
|
|
593
|
+
root,
|
|
594
|
+
tree,
|
|
595
|
+
manifest,
|
|
596
|
+
manifestDigest: digestBytes(manifestBytes),
|
|
597
|
+
entrypoint: path.join(root, manifest.entrypoint),
|
|
598
|
+
entrypointDigest: entrypointEntry.digest,
|
|
599
|
+
};
|
|
600
|
+
if (expected) {
|
|
601
|
+
if (tree.digest !== expected.package_digest) fail("integrity-mismatch", "installed package tree digest does not match the binding");
|
|
602
|
+
if (packageInfo.manifestDigest !== expected.manifest_sha256) fail("integrity-mismatch", "installed package manifest digest does not match the binding");
|
|
603
|
+
if (manifest.entrypoint !== expected.entrypoint || packageInfo.entrypointDigest !== expected.entrypoint_sha256) {
|
|
604
|
+
fail("integrity-mismatch", "installed package executable identity does not match the binding");
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
return packageInfo;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function hasGitAncestor(root) {
|
|
611
|
+
let current = root;
|
|
612
|
+
while (true) {
|
|
613
|
+
const marker = await maybeLstat(path.join(current, ".git"));
|
|
614
|
+
if (marker) return true;
|
|
615
|
+
const parent = path.dirname(current);
|
|
616
|
+
if (parent === current) return false;
|
|
617
|
+
current = parent;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
async function validateSourceRoot(home, input) {
|
|
622
|
+
const absolute = path.resolve(input);
|
|
623
|
+
const finalInfo = await maybeLstat(absolute);
|
|
624
|
+
if (!finalInfo || !finalInfo.isDirectory() || finalInfo.isSymbolicLink()) fail("package-missing", `package root is not a real directory: ${absolute}`);
|
|
625
|
+
const canonical = await realpath(absolute);
|
|
626
|
+
if (canonical !== absolute) fail("path-unsafe", `package root traverses a symbolic link: ${absolute}`);
|
|
627
|
+
if (isInside(home, canonical)) fail("path-unsafe", "package source must be outside the active Firstmate home");
|
|
628
|
+
if (await hasGitAncestor(canonical)) fail("path-unsafe", "package source must not be inside a Git project or task copy");
|
|
629
|
+
return canonical;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
async function makeManagedTreeRemovable(root) {
|
|
633
|
+
const info = await maybeLstat(root);
|
|
634
|
+
if (!info) return;
|
|
635
|
+
if (!info.isDirectory() || info.isSymbolicLink()) return;
|
|
636
|
+
await chmod(root, 0o700);
|
|
637
|
+
const names = await readdir(root);
|
|
638
|
+
for (const name of names) {
|
|
639
|
+
const child = path.join(root, name);
|
|
640
|
+
const childInfo = await lstat(child);
|
|
641
|
+
if (childInfo.isDirectory() && !childInfo.isSymbolicLink()) {
|
|
642
|
+
await makeManagedTreeRemovable(child);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async function removeManagedTree(root) {
|
|
648
|
+
await makeManagedTreeRemovable(root).catch(() => {});
|
|
649
|
+
await rm(root, { recursive: true, force: true });
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function installPackage(home, sourceInfo) {
|
|
653
|
+
const digestHex = sourceInfo.tree.digest.slice("sha256:".length);
|
|
654
|
+
const parent = await ensureHomePrivatePath(home, ["data", "extensions", "packages", sourceInfo.manifest.id, sourceInfo.manifest.version]);
|
|
655
|
+
const destination = path.join(parent, digestHex);
|
|
656
|
+
const existing = await maybeLstat(destination);
|
|
657
|
+
if (existing) {
|
|
658
|
+
const installed = await validatePackage(destination, { installed: true });
|
|
659
|
+
if (installed.tree.digest !== sourceInfo.tree.digest) fail("integrity-mismatch", "existing content-addressed package directory has different bytes");
|
|
660
|
+
return { packageInfo: installed };
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const temporary = path.join(parent, `.install-${process.pid}-${randomBytes(8).toString("hex")}`);
|
|
664
|
+
await mkdir(temporary, { mode: 0o700 });
|
|
665
|
+
try {
|
|
666
|
+
for (const entry of sourceInfo.tree.entries.filter((candidate) => candidate.type === "directory")) {
|
|
667
|
+
await mkdir(path.join(temporary, entry.relative), { recursive: true, mode: 0o700 });
|
|
668
|
+
}
|
|
669
|
+
for (const entry of sourceInfo.tree.entries.filter((candidate) => candidate.type === "file")) {
|
|
670
|
+
const target = path.join(temporary, entry.relative);
|
|
671
|
+
await mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
672
|
+
await copyFile(path.join(sourceInfo.root, entry.relative), target, fsConstants.COPYFILE_EXCL);
|
|
673
|
+
await chmod(target, entry.executable ? 0o555 : 0o444);
|
|
674
|
+
}
|
|
675
|
+
const directories = sourceInfo.tree.entries
|
|
676
|
+
.filter((candidate) => candidate.type === "directory")
|
|
677
|
+
.sort((left, right) => right.relative.split("/").length - left.relative.split("/").length);
|
|
678
|
+
for (const entry of directories) await chmod(path.join(temporary, entry.relative), 0o555);
|
|
679
|
+
await chmod(temporary, 0o555);
|
|
680
|
+
const copied = await validatePackage(temporary, { installed: true });
|
|
681
|
+
const sourceAfterCopy = await validatePackage(sourceInfo.root, { installed: false });
|
|
682
|
+
if (copied.tree.digest !== sourceInfo.tree.digest
|
|
683
|
+
|| copied.manifestDigest !== sourceInfo.manifestDigest
|
|
684
|
+
|| sourceAfterCopy.tree.digest !== sourceInfo.tree.digest
|
|
685
|
+
|| sourceAfterCopy.manifestDigest !== sourceInfo.manifestDigest) {
|
|
686
|
+
fail("integrity-mismatch", "package changed while it was copied into the managed store");
|
|
687
|
+
}
|
|
688
|
+
try {
|
|
689
|
+
await rename(temporary, destination);
|
|
690
|
+
return { packageInfo: await validatePackage(destination, { installed: true }) };
|
|
691
|
+
} catch (error) {
|
|
692
|
+
if (!error || !["EEXIST", "ENOTEMPTY"].includes(error.code)) throw error;
|
|
693
|
+
await removeManagedTree(temporary);
|
|
694
|
+
const winner = await validatePackage(destination, { installed: true });
|
|
695
|
+
if (winner.tree.digest !== sourceInfo.tree.digest) fail("integrity-mismatch", "concurrent package install produced a different tree");
|
|
696
|
+
return { packageInfo: winner };
|
|
697
|
+
}
|
|
698
|
+
} catch (error) {
|
|
699
|
+
await removeManagedTree(temporary).catch(() => {});
|
|
700
|
+
throw error;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function validateBinding(value, home) {
|
|
705
|
+
exactKeys(value, [
|
|
706
|
+
"schema", "extension_id", "extension_version", "source", "package_root",
|
|
707
|
+
"manifest_sha256", "package_digest", "entrypoint", "entrypoint_sha256",
|
|
708
|
+
"host_protocol", "capabilities", "consents", "timeout_ms",
|
|
709
|
+
], "extension binding");
|
|
710
|
+
if (value.schema !== BINDING_SCHEMA) fail("schema-invalid", `unsupported extension binding schema: ${value.schema}`);
|
|
711
|
+
const extensionId = boundedString(value.extension_id, 128, "binding extension_id", ID_RE);
|
|
712
|
+
const extensionVersion = boundedString(value.extension_version, 128, "binding extension_version", SEMVER_RE);
|
|
713
|
+
exactKeys(value.source, ["kind", "path"], "binding source");
|
|
714
|
+
if (value.source.kind !== "local-directory") fail("schema-invalid", "binding source kind must be local-directory");
|
|
715
|
+
const sourcePath = boundedString(value.source.path, 4096, "binding source path");
|
|
716
|
+
if (!path.isAbsolute(sourcePath) || path.normalize(sourcePath) !== sourcePath) fail("path-unsafe", "binding source path must be canonical and absolute");
|
|
717
|
+
const packageRoot = boundedString(value.package_root, 4096, "binding package_root");
|
|
718
|
+
if (!path.isAbsolute(packageRoot) || path.normalize(packageRoot) !== packageRoot) fail("path-unsafe", "binding package_root must be canonical and absolute");
|
|
719
|
+
for (const [name, digest] of Object.entries({
|
|
720
|
+
manifest_sha256: value.manifest_sha256,
|
|
721
|
+
package_digest: value.package_digest,
|
|
722
|
+
entrypoint_sha256: value.entrypoint_sha256,
|
|
723
|
+
})) {
|
|
724
|
+
if (typeof digest !== "string" || !DIGEST_RE.test(digest)) fail("schema-invalid", `binding ${name} is not a SHA-256 digest`);
|
|
725
|
+
}
|
|
726
|
+
const entrypoint = boundedString(value.entrypoint, 256, "binding entrypoint");
|
|
727
|
+
integerIn(value.host_protocol, 1, 2147483647, "binding host_protocol");
|
|
728
|
+
if (value.host_protocol !== 1) fail("protocol-incompatible", `binding selects unsupported host protocol ${value.host_protocol}`);
|
|
729
|
+
if (!Array.isArray(value.capabilities) || value.capabilities.length !== 1) fail("schema-invalid", "binding capabilities must contain exactly process-event-adapter");
|
|
730
|
+
const capability = value.capabilities[0];
|
|
731
|
+
exactKeys(capability, ["name", "version", "adapter_names"], "binding capability");
|
|
732
|
+
if (capability.name !== PROCESS_EVENT_CAPABILITY || capability.version !== 1) {
|
|
733
|
+
fail("protocol-incompatible", "binding must select process-event-adapter/1");
|
|
734
|
+
}
|
|
735
|
+
const adapterNames = uniqueArray(capability.adapter_names, "binding adapter_names", (entry, label) => boundedString(entry, 32, label, ADAPTER_RE));
|
|
736
|
+
exactKeys(value.consents, ["trusted_same_user_code", "network", "credential_store", "task_metadata", "artifact_references"], "binding consents");
|
|
737
|
+
for (const [name, consent] of Object.entries(value.consents)) {
|
|
738
|
+
if (typeof consent !== "boolean") fail("schema-invalid", `binding consent ${name} must be boolean`);
|
|
739
|
+
}
|
|
740
|
+
if (value.consents.trusted_same_user_code !== true) fail("consent-missing", "binding lacks trusted-same-user-code consent");
|
|
741
|
+
const timeoutMs = integerIn(value.timeout_ms, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS, "binding timeout_ms");
|
|
742
|
+
const expectedRoot = path.join(home, "data", "extensions", "packages", extensionId, extensionVersion, value.package_digest.slice("sha256:".length));
|
|
743
|
+
if (packageRoot !== expectedRoot) fail("path-unsafe", "binding package_root is outside this home's content-addressed package store");
|
|
744
|
+
return {
|
|
745
|
+
schema: value.schema,
|
|
746
|
+
extension_id: extensionId,
|
|
747
|
+
extension_version: extensionVersion,
|
|
748
|
+
source: { kind: "local-directory", path: sourcePath },
|
|
749
|
+
package_root: packageRoot,
|
|
750
|
+
manifest_sha256: value.manifest_sha256,
|
|
751
|
+
package_digest: value.package_digest,
|
|
752
|
+
entrypoint,
|
|
753
|
+
entrypoint_sha256: value.entrypoint_sha256,
|
|
754
|
+
host_protocol: value.host_protocol,
|
|
755
|
+
capabilities: [{ name: PROCESS_EVENT_CAPABILITY, version: 1, adapter_names: adapterNames }],
|
|
756
|
+
consents: { ...value.consents },
|
|
757
|
+
timeout_ms: timeoutMs,
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
async function validateBindingPackage(binding, home) {
|
|
762
|
+
const canonical = await realpath(binding.package_root).catch(() => fail("package-missing", `bound package is unavailable: ${binding.package_root}`));
|
|
763
|
+
if (canonical !== binding.package_root) fail("path-unsafe", "bound package_root is no longer canonical");
|
|
764
|
+
const packageInfo = await validatePackage(binding.package_root, { installed: true, expected: binding });
|
|
765
|
+
const manifest = packageInfo.manifest;
|
|
766
|
+
if (manifest.id !== binding.extension_id || manifest.version !== binding.extension_version) {
|
|
767
|
+
fail("integrity-mismatch", "bound package manifest identity does not match the binding");
|
|
768
|
+
}
|
|
769
|
+
if (!manifest.host_protocols.includes(binding.host_protocol)) fail("protocol-incompatible", "manifest no longer declares the bound host protocol");
|
|
770
|
+
const capability = manifest.capabilities[0];
|
|
771
|
+
if (!capability.versions.includes(1)) fail("protocol-incompatible", "manifest no longer declares process-event-adapter/1");
|
|
772
|
+
for (const adapter of binding.capabilities[0].adapter_names) {
|
|
773
|
+
if (!capability.adapter_names.includes(adapter)) fail("protocol-incompatible", `manifest no longer allows adapter: ${adapter}`);
|
|
774
|
+
}
|
|
775
|
+
for (const consent of manifest.required_consents) {
|
|
776
|
+
const key = consent.replaceAll("-", "_");
|
|
777
|
+
if (binding.consents[key] !== true) fail("consent-missing", `binding lacks manifest-required consent: ${consent}`);
|
|
778
|
+
}
|
|
779
|
+
return packageInfo;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
async function registryPath(home) {
|
|
783
|
+
return path.join(home, "config", "extensions.d");
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
async function loadBindingRecord(home, file, label, { packages = true } = {}) {
|
|
787
|
+
const fileInfo = await lstat(file);
|
|
788
|
+
if (!fileInfo.isFile() || fileInfo.isSymbolicLink() || fileInfo.nlink !== 1) fail("link-unsafe", `${label} is not a single regular file`);
|
|
789
|
+
if (fileInfo.uid !== currentUid()) fail("owner-mismatch", `${label} is not owned by the active user`);
|
|
790
|
+
if (modeOf(fileInfo) !== 0o600) fail("mode-unsafe", `${label} must have mode 0600`);
|
|
791
|
+
if (fileInfo.size > MAX_JSON_BYTES) fail("binding-oversized", `${label} exceeds ${MAX_JSON_BYTES} bytes`);
|
|
792
|
+
const bytes = await readFile(file);
|
|
793
|
+
const binding = validateBinding(parseStrictJson(bytes, label), home);
|
|
794
|
+
return {
|
|
795
|
+
binding,
|
|
796
|
+
bindingDigest: digestBytes(bytes),
|
|
797
|
+
bindingPath: file,
|
|
798
|
+
packageInfo: packages ? await validateBindingPackage(binding, home) : null,
|
|
799
|
+
bytes,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
async function loadBindings(home, { packages = true } = {}) {
|
|
804
|
+
const registry = await registryPath(home);
|
|
805
|
+
const info = await maybeLstat(registry);
|
|
806
|
+
if (!info) return [];
|
|
807
|
+
await assertOwnedSafeDirectory(registry, "extension binding registry", true);
|
|
808
|
+
const names = await readdir(registry);
|
|
809
|
+
if (names.length > MAX_BINDINGS) fail("registry-oversized", `extension binding registry exceeds ${MAX_BINDINGS} entries`);
|
|
810
|
+
names.sort((left, right) => Buffer.compare(Buffer.from(left), Buffer.from(right)));
|
|
811
|
+
const bindings = [];
|
|
812
|
+
const adapters = new Map();
|
|
813
|
+
for (const name of names) {
|
|
814
|
+
if (!name.endsWith(".json") || name.startsWith(".")) fail("registry-invalid", `unexpected file in extension binding registry: ${name}`);
|
|
815
|
+
safeTreeName(name, "extension binding registry");
|
|
816
|
+
const file = path.join(registry, name);
|
|
817
|
+
const record = await loadBindingRecord(home, file, `extension binding ${name}`, { packages });
|
|
818
|
+
const { binding } = record;
|
|
819
|
+
if (name !== `${binding.extension_id}.json`) fail("registry-invalid", `binding filename does not match extension id: ${name}`);
|
|
820
|
+
for (const adapter of binding.capabilities[0].adapter_names) {
|
|
821
|
+
if (adapters.has(adapter)) fail("adapter-conflict", `adapter ${adapter} is enabled by more than one binding`);
|
|
822
|
+
adapters.set(adapter, binding.extension_id);
|
|
823
|
+
}
|
|
824
|
+
bindings.push(record);
|
|
825
|
+
}
|
|
826
|
+
return bindings;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function selectAdapter(bindings, adapter) {
|
|
830
|
+
const matches = bindings.filter((record) => record.binding.capabilities[0].adapter_names.includes(adapter));
|
|
831
|
+
if (matches.length === 0) fail("adapter-unbound", `no home-local extension binding enables adapter: ${adapter}`);
|
|
832
|
+
if (matches.length !== 1) fail("adapter-conflict", `more than one extension binding enables adapter: ${adapter}`);
|
|
833
|
+
return matches[0];
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
function sanitizedPath() {
|
|
837
|
+
const candidates = [path.dirname(process.execPath), "/usr/bin", "/bin", "/usr/sbin", "/sbin"];
|
|
838
|
+
return [...new Set(candidates)].join(path.delimiter);
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function effectiveStateRoot(home) {
|
|
842
|
+
return path.resolve(process.env.FM_STATE_OVERRIDE || path.join(home, "state"));
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function ensureExtensionState(home, binding) {
|
|
846
|
+
let root;
|
|
847
|
+
if (process.env.FM_STATE_OVERRIDE) {
|
|
848
|
+
const stateRoot = effectiveStateRoot(home);
|
|
849
|
+
await assertOwnedSafeDirectory(stateRoot, "extension state root");
|
|
850
|
+
root = path.join(stateRoot, "extensions");
|
|
851
|
+
await ensureDirectory(root, 0o700, "state/extensions", true);
|
|
852
|
+
} else {
|
|
853
|
+
root = await ensureHomePrivatePath(home, ["state", "extensions"]);
|
|
854
|
+
}
|
|
855
|
+
const statePath = path.join(root, binding.extension_id);
|
|
856
|
+
await ensureDirectory(statePath, 0o700, `extension state ${binding.extension_id}`, true);
|
|
857
|
+
return statePath;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function childEnvironment(binding, statePath = "") {
|
|
861
|
+
const env = {
|
|
862
|
+
PATH: sanitizedPath(),
|
|
863
|
+
LANG: "C",
|
|
864
|
+
LC_ALL: "C",
|
|
865
|
+
FIRSTMATE_EXTENSION_ID: binding.extension_id,
|
|
866
|
+
FIRSTMATE_EXTENSION_VERSION: binding.extension_version,
|
|
867
|
+
};
|
|
868
|
+
if (statePath) env.FIRSTMATE_EXTENSION_STATE = statePath;
|
|
869
|
+
if (binding.consents.credential_store) {
|
|
870
|
+
for (const name of ["HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "SSH_AUTH_SOCK"]) {
|
|
871
|
+
if (process.env[name]) env[name] = process.env[name];
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
return env;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
let activeInvocation = null;
|
|
878
|
+
let terminatingForSignal = false;
|
|
879
|
+
let signalCleanupFailureHold = null;
|
|
880
|
+
let activeLifecycleLock = null;
|
|
881
|
+
let cachedSelfIdentity = null;
|
|
882
|
+
|
|
883
|
+
function groupAlive(pid) {
|
|
884
|
+
if (!pid || process.platform === "win32") return false;
|
|
885
|
+
try {
|
|
886
|
+
process.kill(-pid, 0);
|
|
887
|
+
return true;
|
|
888
|
+
} catch {
|
|
889
|
+
return false;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function pidAlive(pid) {
|
|
894
|
+
if (!pid) return false;
|
|
895
|
+
try {
|
|
896
|
+
process.kill(pid, 0);
|
|
897
|
+
return true;
|
|
898
|
+
} catch {
|
|
899
|
+
return false;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function signalProcessGroup(invocation, signal) {
|
|
904
|
+
if (!invocation?.pid) return;
|
|
905
|
+
try {
|
|
906
|
+
process.kill(-invocation.pid, signal);
|
|
907
|
+
} catch {}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
async function sleep(milliseconds) {
|
|
911
|
+
await new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
async function capturedProcessOutput(command, args, maxBytes = 8192) {
|
|
915
|
+
const child = spawn(command, args, {
|
|
916
|
+
env: { PATH: sanitizedPath(), LANG: "C", LC_ALL: "C" },
|
|
917
|
+
shell: false,
|
|
918
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
919
|
+
});
|
|
920
|
+
const chunks = [];
|
|
921
|
+
let bytes = 0;
|
|
922
|
+
child.stdout.on("data", (chunk) => {
|
|
923
|
+
bytes += chunk.length;
|
|
924
|
+
if (bytes <= maxBytes) chunks.push(chunk);
|
|
925
|
+
});
|
|
926
|
+
const outcome = await new Promise((resolve) => {
|
|
927
|
+
child.once("error", () => resolve({ code: 125, signal: null }));
|
|
928
|
+
child.once("close", (code, signal) => resolve({ code, signal }));
|
|
929
|
+
});
|
|
930
|
+
if (outcome.signal || outcome.code !== 0 || bytes === 0 || bytes > maxBytes) {
|
|
931
|
+
fail("process-identity-uncertain", "cannot inspect extension process identity");
|
|
932
|
+
}
|
|
933
|
+
return Buffer.concat(chunks).toString("utf8").trim();
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
async function pidIdentity(pid) {
|
|
937
|
+
if (process.platform === "linux") {
|
|
938
|
+
const stat = await readFile(`/proc/${pid}/stat`, "utf8").catch(() => fail("process-identity-uncertain", "cannot inspect extension process identity"));
|
|
939
|
+
const cmdline = await readFile(`/proc/${pid}/cmdline`).catch(() => fail("process-identity-uncertain", "cannot inspect extension process identity"));
|
|
940
|
+
const close = stat.lastIndexOf(")");
|
|
941
|
+
const fields = close >= 0 ? stat.slice(close + 1).trim().split(/\s+/u) : [];
|
|
942
|
+
if (fields.length < 20 || !/^[0-9]+$/u.test(fields[19]) || cmdline.length === 0) {
|
|
943
|
+
fail("process-identity-uncertain", "cannot inspect extension process identity");
|
|
944
|
+
}
|
|
945
|
+
return `linux-starttime=${fields[19]} cmdline-hex=${cmdline.toString("hex")}`;
|
|
946
|
+
}
|
|
947
|
+
return capturedProcessOutput("/bin/ps", ["-p", String(pid), "-o", "lstart=", "-o", "command="]);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
async function selfIdentity() {
|
|
951
|
+
if (!cachedSelfIdentity) {
|
|
952
|
+
cachedSelfIdentity = `host-token:${makeRequestId()}`;
|
|
953
|
+
// The private generation token gives recovery a direct PID-reuse check
|
|
954
|
+
// without a process-table fork on every normal invocation.
|
|
955
|
+
process.title = `firstmate-extension-host ${cachedSelfIdentity}`;
|
|
956
|
+
}
|
|
957
|
+
return cachedSelfIdentity;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
async function processGroupId(pid) {
|
|
961
|
+
const output = await capturedProcessOutput("/bin/ps", ["-p", String(pid), "-o", "pgid="]);
|
|
962
|
+
if (!/^[0-9]+$/u.test(output)) fail("process-identity-uncertain", "cannot inspect extension process group");
|
|
963
|
+
return Number(output);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
async function processIdentityState(pid, expected) {
|
|
967
|
+
if (!pidAlive(pid)) return 1;
|
|
968
|
+
if (expected.startsWith("host-token:")) {
|
|
969
|
+
try {
|
|
970
|
+
if (process.platform === "linux") {
|
|
971
|
+
const cmdline = await readFile(`/proc/${pid}/cmdline`);
|
|
972
|
+
return cmdline.includes(Buffer.from(expected, "utf8")) ? 0 : 2;
|
|
973
|
+
}
|
|
974
|
+
const command = await capturedProcessOutput("/bin/ps", ["-p", String(pid), "-o", "command="]);
|
|
975
|
+
return command.includes(expected) ? 0 : 2;
|
|
976
|
+
} catch {
|
|
977
|
+
return pidAlive(pid) ? 2 : 1;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
let actual;
|
|
981
|
+
try {
|
|
982
|
+
actual = await pidIdentity(pid);
|
|
983
|
+
} catch {
|
|
984
|
+
return pidAlive(pid) ? 2 : 1;
|
|
985
|
+
}
|
|
986
|
+
return actual === expected ? 0 : 2;
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
async function barrierProcessGroupState(pid, expectedIdentity) {
|
|
990
|
+
const token = expectedIdentity.slice("barrier-token:".length);
|
|
991
|
+
try {
|
|
992
|
+
if (process.platform === "linux") {
|
|
993
|
+
const stat = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
994
|
+
const cmdline = await readFile(`/proc/${pid}/cmdline`);
|
|
995
|
+
const close = stat.lastIndexOf(")");
|
|
996
|
+
const fields = close >= 0 ? stat.slice(close + 1).trim().split(/\s+/u) : [];
|
|
997
|
+
const argv = cmdline.toString("utf8").split("\0").filter(Boolean);
|
|
998
|
+
if (fields.length < 3 || Number(fields[2]) !== pid || !argv.includes(LAUNCH_BARRIER) || !argv.includes(token)) return 2;
|
|
999
|
+
return 0;
|
|
1000
|
+
}
|
|
1001
|
+
const output = await capturedProcessOutput("/bin/ps", ["-p", String(pid), "-o", "pgid=", "-o", "command="]);
|
|
1002
|
+
const match = output.match(/^\s*([0-9]+)\s+(.+)$/su);
|
|
1003
|
+
if (!match || Number(match[1]) !== pid || !match[2].includes(LAUNCH_BARRIER) || !match[2].includes(token)) return 2;
|
|
1004
|
+
return 0;
|
|
1005
|
+
} catch {
|
|
1006
|
+
return pidAlive(pid) ? 2 : (groupAlive(pid) ? 3 : 1);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
async function processGroupState(pid, expectedIdentity = null, trustedChild = false) {
|
|
1011
|
+
if (!pidAlive(pid)) return groupAlive(pid) ? 3 : 1;
|
|
1012
|
+
if (expectedIdentity?.startsWith("barrier-token:")) return barrierProcessGroupState(pid, expectedIdentity);
|
|
1013
|
+
if (expectedIdentity) {
|
|
1014
|
+
let actual;
|
|
1015
|
+
try {
|
|
1016
|
+
actual = await pidIdentity(pid);
|
|
1017
|
+
} catch {
|
|
1018
|
+
return pidAlive(pid) ? 2 : (groupAlive(pid) ? 3 : 1);
|
|
1019
|
+
}
|
|
1020
|
+
if (actual !== expectedIdentity) return 2;
|
|
1021
|
+
} else if (!trustedChild) {
|
|
1022
|
+
return 2;
|
|
1023
|
+
}
|
|
1024
|
+
let pgid;
|
|
1025
|
+
try {
|
|
1026
|
+
pgid = await processGroupId(pid);
|
|
1027
|
+
} catch {
|
|
1028
|
+
return pidAlive(pid) ? 2 : (groupAlive(pid) ? 3 : 1);
|
|
1029
|
+
}
|
|
1030
|
+
return pgid === pid ? 0 : 2;
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
async function cleanupExactProcessGroup(invocation) {
|
|
1034
|
+
if (!invocation?.pid) return;
|
|
1035
|
+
let state = await processGroupState(invocation.pid, invocation.groupIdentity, invocation.trustedChild === true);
|
|
1036
|
+
if (state === 1) return;
|
|
1037
|
+
if (state === 2) fail("process-cleanup-failed", "extension process group identity cannot be proved");
|
|
1038
|
+
signalProcessGroup(invocation, "SIGTERM");
|
|
1039
|
+
const termUntil = Date.now() + TERMINATE_GRACE_MS;
|
|
1040
|
+
while (Date.now() < termUntil && groupAlive(invocation.pid)) await sleep(INVOCATION_POLL_MS);
|
|
1041
|
+
if (groupAlive(invocation.pid)) signalProcessGroup(invocation, "SIGKILL");
|
|
1042
|
+
const killUntil = Date.now() + CLEANUP_WAIT_MS;
|
|
1043
|
+
while (Date.now() < killUntil && groupAlive(invocation.pid)) await sleep(INVOCATION_POLL_MS);
|
|
1044
|
+
if (groupAlive(invocation.pid)) fail("process-cleanup-failed", "extension process group survived TERM and KILL");
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
async function invocationRoot(home, create = false) {
|
|
1048
|
+
const stateRoot = effectiveStateRoot(home);
|
|
1049
|
+
const root = path.join(stateRoot, "extension-invocations");
|
|
1050
|
+
const info = await maybeLstat(root);
|
|
1051
|
+
// Preserve built-in parity: an absent cleanup registry costs one bounded
|
|
1052
|
+
// lstat and does not require or canonicalize unrelated state directories.
|
|
1053
|
+
if (!info && !create) return root;
|
|
1054
|
+
if (process.env.FM_STATE_OVERRIDE) {
|
|
1055
|
+
const stateInfo = await maybeLstat(stateRoot);
|
|
1056
|
+
if (!stateInfo) fail("path-unsafe", "extension state root is unavailable");
|
|
1057
|
+
await assertOwnedSafeDirectory(stateRoot, "extension state root");
|
|
1058
|
+
} else if (create) {
|
|
1059
|
+
await ensureHomePrivatePath(home, ["state"]);
|
|
1060
|
+
}
|
|
1061
|
+
if (!info) await ensureDirectory(root, 0o700, "state/extension-invocations", true);
|
|
1062
|
+
else await assertOwnedSafeDirectory(root, "state/extension-invocations", true);
|
|
1063
|
+
return root;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function invocationPaths(root, token) {
|
|
1067
|
+
const name = token.slice("sha256:".length);
|
|
1068
|
+
return {
|
|
1069
|
+
ownerFile: path.join(root, `${name}.owner.json`),
|
|
1070
|
+
ownerPublish: path.join(root, `${name}.owner.json.publish`),
|
|
1071
|
+
ownerTemporary: path.join(root, `${name}.owner.json.tmp`),
|
|
1072
|
+
readyFile: path.join(root, `${name}.ready.json`),
|
|
1073
|
+
readyTemporary: path.join(root, `${name}.ready.json.tmp`),
|
|
1074
|
+
releaseFile: path.join(root, `${name}.release.json`),
|
|
1075
|
+
releasePublish: path.join(root, `${name}.release.json.publish`),
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
async function readPrivateJson(file, label) {
|
|
1080
|
+
const info = await maybeLstat(file);
|
|
1081
|
+
if (!info) return null;
|
|
1082
|
+
if (!info.isFile() || info.isSymbolicLink() || info.nlink !== 1 || info.uid !== currentUid() || modeOf(info) !== 0o600) {
|
|
1083
|
+
fail("process-cleanup-failed", `${label} is not one private host-owned file`);
|
|
1084
|
+
}
|
|
1085
|
+
if (info.size === 0 || info.size > MAX_JSON_BYTES) fail("process-cleanup-failed", `${label} has an invalid size`);
|
|
1086
|
+
return parseStrictJson(await readFile(file), label);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function validateInvocationOwner(value) {
|
|
1090
|
+
exactKeys(value, [
|
|
1091
|
+
"schema", "token", "phase", "host_pid", "host_identity", "group_pid", "group_identity",
|
|
1092
|
+
"extension_id", "binding_digest", "request_id", "source_id", "operation",
|
|
1093
|
+
], "extension invocation owner");
|
|
1094
|
+
if (value.schema !== INVOCATION_OWNER_SCHEMA || !DIGEST_RE.test(value.token) || !DIGEST_RE.test(value.binding_digest)
|
|
1095
|
+
|| !REQUEST_ID_RE.test(value.request_id)) fail("process-cleanup-failed", "extension invocation owner identity is invalid");
|
|
1096
|
+
integerIn(value.host_pid, 2, 2147483647, "extension invocation host_pid");
|
|
1097
|
+
boundedString(value.host_identity, 8192, "extension invocation host_identity");
|
|
1098
|
+
boundedString(value.extension_id, 128, "extension invocation extension_id", ID_RE);
|
|
1099
|
+
if (value.source_id !== null) boundedString(value.source_id, 64, "extension invocation source_id", /^[A-Za-z0-9._-]+$/u);
|
|
1100
|
+
if (!["handshake", "source.poll", "result.classify", "result.terminal", "result.silent"].includes(value.operation)) {
|
|
1101
|
+
fail("process-cleanup-failed", "extension invocation operation is invalid");
|
|
1102
|
+
}
|
|
1103
|
+
if (value.phase === "reserved") {
|
|
1104
|
+
if (value.group_pid !== null || value.group_identity !== null) fail("process-cleanup-failed", "reserved invocation unexpectedly names a process group");
|
|
1105
|
+
} else if (value.phase === "group") {
|
|
1106
|
+
integerIn(value.group_pid, 2, 2147483647, "extension invocation group_pid");
|
|
1107
|
+
boundedString(value.group_identity, 8192, "extension invocation group_identity");
|
|
1108
|
+
} else {
|
|
1109
|
+
fail("process-cleanup-failed", "extension invocation phase is invalid");
|
|
1110
|
+
}
|
|
1111
|
+
return value;
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
function validateInvocationReady(value, token) {
|
|
1115
|
+
exactKeys(value, ["schema", "token", "group_pid", "group_identity"], "extension invocation readiness");
|
|
1116
|
+
if (value.schema !== INVOCATION_READY_SCHEMA || value.token !== token) fail("process-cleanup-failed", "extension invocation readiness identity is invalid");
|
|
1117
|
+
integerIn(value.group_pid, 2, 2147483647, "extension invocation ready group_pid");
|
|
1118
|
+
boundedString(value.group_identity, 8192, "extension invocation ready group_identity");
|
|
1119
|
+
return value;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function validateInvocationRelease(value, token) {
|
|
1123
|
+
exactKeys(value, ["schema", "token"], "extension invocation release");
|
|
1124
|
+
if (value.schema !== INVOCATION_RELEASE_SCHEMA || value.token !== token) {
|
|
1125
|
+
fail("process-cleanup-failed", "extension invocation release identity is invalid");
|
|
1126
|
+
}
|
|
1127
|
+
return value;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
async function writePrivateJsonExclusive(file, value) {
|
|
1131
|
+
const temporary = `${file}.publish`;
|
|
1132
|
+
const handle = await open(temporary, "wx", 0o600)
|
|
1133
|
+
.catch(() => fail("process-cleanup-failed", "cannot stage extension invocation ownership"));
|
|
1134
|
+
try {
|
|
1135
|
+
await handle.writeFile(`${canonicalJson(value)}\n`, "utf8");
|
|
1136
|
+
} finally {
|
|
1137
|
+
await handle.close();
|
|
1138
|
+
}
|
|
1139
|
+
await chmod(temporary, 0o600);
|
|
1140
|
+
try {
|
|
1141
|
+
await link(temporary, file);
|
|
1142
|
+
await unlink(temporary);
|
|
1143
|
+
} catch {
|
|
1144
|
+
await rm(temporary, { force: true });
|
|
1145
|
+
fail("process-cleanup-failed", "cannot publish extension invocation ownership");
|
|
1146
|
+
}
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
async function replaceInvocationOwner(invocation, value) {
|
|
1150
|
+
const current = validateInvocationOwner(await readPrivateJson(invocation.ownerFile, "extension invocation owner"));
|
|
1151
|
+
if (current.token !== invocation.token || current.phase !== "reserved" || current.host_pid !== process.pid
|
|
1152
|
+
|| current.host_identity !== invocation.hostIdentity) {
|
|
1153
|
+
fail("process-cleanup-failed", "extension invocation owner changed before group publication");
|
|
1154
|
+
}
|
|
1155
|
+
const handle = await open(invocation.ownerTemporary, "wx", 0o600)
|
|
1156
|
+
.catch(() => fail("process-cleanup-failed", "cannot stage extension invocation ownership"));
|
|
1157
|
+
try {
|
|
1158
|
+
await handle.writeFile(`${canonicalJson(value)}\n`, "utf8");
|
|
1159
|
+
} finally {
|
|
1160
|
+
await handle.close();
|
|
1161
|
+
}
|
|
1162
|
+
await chmod(invocation.ownerTemporary, 0o600);
|
|
1163
|
+
const rechecked = validateInvocationOwner(await readPrivateJson(invocation.ownerFile, "extension invocation owner"));
|
|
1164
|
+
if (rechecked.token !== invocation.token || rechecked.phase !== "reserved" || rechecked.host_identity !== invocation.hostIdentity) {
|
|
1165
|
+
await rm(invocation.ownerTemporary, { force: true });
|
|
1166
|
+
fail("process-cleanup-failed", "extension invocation owner changed during group publication");
|
|
1167
|
+
}
|
|
1168
|
+
await rename(invocation.ownerTemporary, invocation.ownerFile);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
async function clearInvocationFiles(invocation) {
|
|
1172
|
+
const ownerValue = await readPrivateJson(invocation.ownerFile, "extension invocation owner");
|
|
1173
|
+
if (ownerValue) {
|
|
1174
|
+
const owner = validateInvocationOwner(ownerValue);
|
|
1175
|
+
if (owner.token !== invocation.token) fail("process-cleanup-failed", "extension invocation owner changed before cleanup");
|
|
1176
|
+
}
|
|
1177
|
+
const readyValue = await readPrivateJson(invocation.readyFile, "extension invocation readiness");
|
|
1178
|
+
if (readyValue) validateInvocationReady(readyValue, invocation.token);
|
|
1179
|
+
const releaseValue = await readPrivateJson(invocation.releaseFile, "extension invocation release");
|
|
1180
|
+
if (releaseValue) validateInvocationRelease(releaseValue, invocation.token);
|
|
1181
|
+
for (const file of [
|
|
1182
|
+
invocation.releaseFile, invocation.releasePublish, invocation.readyFile, invocation.readyTemporary,
|
|
1183
|
+
invocation.ownerTemporary, invocation.ownerPublish, invocation.ownerFile,
|
|
1184
|
+
]) {
|
|
1185
|
+
await rm(file, { force: true });
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
async function finalizeInvocation(invocation) {
|
|
1190
|
+
if (!invocation) return;
|
|
1191
|
+
if (!invocation.cleanupPromise) {
|
|
1192
|
+
invocation.cleanupPromise = (async () => {
|
|
1193
|
+
await cleanupExactProcessGroup(invocation);
|
|
1194
|
+
await clearInvocationFiles(invocation);
|
|
1195
|
+
})();
|
|
1196
|
+
}
|
|
1197
|
+
await invocation.cleanupPromise;
|
|
1198
|
+
if (activeInvocation === invocation) activeInvocation = null;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
async function reserveInvocation(home, record, verb, request, statePath) {
|
|
1202
|
+
if (process.platform === "win32") fail("platform-unsupported", "extension launch cleanup requires POSIX process groups");
|
|
1203
|
+
const root = await invocationRoot(home, true);
|
|
1204
|
+
const token = makeRequestId();
|
|
1205
|
+
const paths = invocationPaths(root, token);
|
|
1206
|
+
const hostIdentity = await selfIdentity();
|
|
1207
|
+
const sourceId = request?.input?.source_id || null;
|
|
1208
|
+
const owner = {
|
|
1209
|
+
schema: INVOCATION_OWNER_SCHEMA,
|
|
1210
|
+
token,
|
|
1211
|
+
phase: "reserved",
|
|
1212
|
+
host_pid: process.pid,
|
|
1213
|
+
host_identity: hostIdentity,
|
|
1214
|
+
group_pid: null,
|
|
1215
|
+
group_identity: null,
|
|
1216
|
+
extension_id: record.binding.extension_id,
|
|
1217
|
+
binding_digest: record.bindingDigest,
|
|
1218
|
+
request_id: request.request_id,
|
|
1219
|
+
source_id: sourceId,
|
|
1220
|
+
operation: verb === "handshake" ? "handshake" : request.operation,
|
|
1221
|
+
};
|
|
1222
|
+
await writePrivateJsonExclusive(paths.ownerFile, owner);
|
|
1223
|
+
let child;
|
|
1224
|
+
try {
|
|
1225
|
+
const barrierNodeArgs = process.execArgv.includes("--disallow-code-generation-from-strings")
|
|
1226
|
+
? ["--disallow-code-generation-from-strings"]
|
|
1227
|
+
: [];
|
|
1228
|
+
child = spawn(process.execPath, [
|
|
1229
|
+
...barrierNodeArgs,
|
|
1230
|
+
LAUNCH_BARRIER,
|
|
1231
|
+
token,
|
|
1232
|
+
paths.ownerFile,
|
|
1233
|
+
paths.readyFile,
|
|
1234
|
+
paths.releaseFile,
|
|
1235
|
+
String(process.pid),
|
|
1236
|
+
record.packageInfo.entrypoint,
|
|
1237
|
+
record.packageInfo.root,
|
|
1238
|
+
verb,
|
|
1239
|
+
], {
|
|
1240
|
+
cwd: record.packageInfo.root,
|
|
1241
|
+
detached: true,
|
|
1242
|
+
env: childEnvironment(record.binding, statePath),
|
|
1243
|
+
shell: false,
|
|
1244
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
1245
|
+
});
|
|
1246
|
+
} catch {
|
|
1247
|
+
await clearInvocationFiles({ ...paths, token });
|
|
1248
|
+
fail("entrypoint-missing", "bound extension entrypoint could not be started");
|
|
1249
|
+
}
|
|
1250
|
+
const invocation = {
|
|
1251
|
+
...paths,
|
|
1252
|
+
token,
|
|
1253
|
+
hostIdentity,
|
|
1254
|
+
child,
|
|
1255
|
+
pid: child.pid,
|
|
1256
|
+
groupIdentity: null,
|
|
1257
|
+
trustedChild: true,
|
|
1258
|
+
cleanupPromise: null,
|
|
1259
|
+
};
|
|
1260
|
+
activeInvocation = invocation;
|
|
1261
|
+
return { invocation, owner };
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
async function publishInvocationGroup(invocation, owner) {
|
|
1265
|
+
const deadline = Date.now() + LAUNCH_READY_WAIT_MS;
|
|
1266
|
+
let ready = null;
|
|
1267
|
+
while (Date.now() < deadline) {
|
|
1268
|
+
const value = await readPrivateJson(invocation.readyFile, "extension invocation readiness");
|
|
1269
|
+
if (value) {
|
|
1270
|
+
ready = validateInvocationReady(value, invocation.token);
|
|
1271
|
+
break;
|
|
1272
|
+
}
|
|
1273
|
+
if (!pidAlive(invocation.pid)) fail("entrypoint-missing", "extension launch barrier exited before publishing ownership");
|
|
1274
|
+
await sleep(INVOCATION_POLL_MS);
|
|
1275
|
+
}
|
|
1276
|
+
if (!ready) fail("timeout", "extension launch barrier did not publish ownership in time");
|
|
1277
|
+
if (ready.group_pid !== invocation.pid) fail("process-cleanup-failed", "extension launch barrier published a different process group");
|
|
1278
|
+
// The tracked barrier is the exact detached child this host just created.
|
|
1279
|
+
// Package code cannot run until after this ready record is accepted and the
|
|
1280
|
+
// one-shot release is published, so its self-captured identity is the safe
|
|
1281
|
+
// recovery identity without another contended process-table round trip.
|
|
1282
|
+
invocation.groupIdentity = ready.group_identity;
|
|
1283
|
+
invocation.trustedChild = false;
|
|
1284
|
+
const groupOwner = { ...owner, phase: "group", group_pid: ready.group_pid, group_identity: ready.group_identity };
|
|
1285
|
+
await replaceInvocationOwner(invocation, groupOwner);
|
|
1286
|
+
await writePrivateJsonExclusive(invocation.releaseFile, { schema: INVOCATION_RELEASE_SCHEMA, token: invocation.token });
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
async function cleanupRecordedInvocations(home, { sourceId = null, bindingDigest = null } = {}) {
|
|
1290
|
+
const root = await invocationRoot(home, false);
|
|
1291
|
+
const info = await maybeLstat(root);
|
|
1292
|
+
if (!info) return 0;
|
|
1293
|
+
await assertOwnedSafeDirectory(root, "state/extension-invocations", true);
|
|
1294
|
+
const names = await readdir(root);
|
|
1295
|
+
const ownerNames = names.filter((name) => /^[0-9a-f]{64}\.owner\.json$/u.test(name)).sort();
|
|
1296
|
+
let cleaned = 0;
|
|
1297
|
+
for (const name of ownerNames) {
|
|
1298
|
+
const ownerFile = path.join(root, name);
|
|
1299
|
+
const owner = validateInvocationOwner(await readPrivateJson(ownerFile, "extension invocation owner"));
|
|
1300
|
+
if (sourceId !== null && owner.source_id !== sourceId) continue;
|
|
1301
|
+
if (bindingDigest !== null && owner.binding_digest !== bindingDigest) continue;
|
|
1302
|
+
const paths = invocationPaths(root, owner.token);
|
|
1303
|
+
const hostState = await processIdentityState(owner.host_pid, owner.host_identity);
|
|
1304
|
+
if (hostState === 0) fail("process-cleanup-failed", "an extension invocation host is still active");
|
|
1305
|
+
if (hostState === 2) fail("process-cleanup-failed", "extension invocation host identity cannot be proved stale");
|
|
1306
|
+
let groupPid = owner.group_pid;
|
|
1307
|
+
let groupIdentity = owner.group_identity;
|
|
1308
|
+
if (owner.phase === "reserved") {
|
|
1309
|
+
const readyValue = await readPrivateJson(paths.readyFile, "extension invocation readiness");
|
|
1310
|
+
if (!readyValue) fail("process-cleanup-failed", "an interrupted extension launch has not published exact group ownership");
|
|
1311
|
+
const ready = validateInvocationReady(readyValue, owner.token);
|
|
1312
|
+
groupPid = ready.group_pid;
|
|
1313
|
+
groupIdentity = ready.group_identity;
|
|
1314
|
+
}
|
|
1315
|
+
const invocation = { ...paths, token: owner.token, pid: groupPid, groupIdentity, trustedChild: false, cleanupPromise: null };
|
|
1316
|
+
await cleanupExactProcessGroup(invocation);
|
|
1317
|
+
await clearInvocationFiles(invocation);
|
|
1318
|
+
cleaned += 1;
|
|
1319
|
+
}
|
|
1320
|
+
const remaining = await readdir(root);
|
|
1321
|
+
const known = new Set();
|
|
1322
|
+
for (const name of remaining.filter((entry) => /^[0-9a-f]{64}\.owner\.json$/u.test(entry))) {
|
|
1323
|
+
const stem = name.slice(0, -".owner.json".length);
|
|
1324
|
+
known.add(`${stem}.owner.json`);
|
|
1325
|
+
known.add(`${stem}.owner.json.publish`);
|
|
1326
|
+
known.add(`${stem}.owner.json.tmp`);
|
|
1327
|
+
known.add(`${stem}.ready.json`);
|
|
1328
|
+
known.add(`${stem}.ready.json.tmp`);
|
|
1329
|
+
known.add(`${stem}.release.json`);
|
|
1330
|
+
known.add(`${stem}.release.json.publish`);
|
|
1331
|
+
}
|
|
1332
|
+
for (const name of remaining) {
|
|
1333
|
+
if (!known.has(name)) fail("process-cleanup-failed", `unexpected extension invocation cleanup artifact: ${name}`);
|
|
1334
|
+
}
|
|
1335
|
+
return cleaned;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
async function runExtensionProcess(home, record, verb, request, timeoutMs, statePath = "") {
|
|
1339
|
+
const requestBytes = Buffer.from(`${canonicalJson(request)}\n`, "utf8");
|
|
1340
|
+
if (requestBytes.length > MAX_JSON_BYTES) fail("request-oversized", `extension request exceeds ${MAX_JSON_BYTES} bytes`);
|
|
1341
|
+
const entryInfo = await lstat(record.packageInfo.entrypoint).catch(() => fail("entrypoint-missing", "bound extension entrypoint is missing"));
|
|
1342
|
+
if (!entryInfo.isFile() || entryInfo.isSymbolicLink() || entryInfo.nlink !== 1 || entryInfo.uid !== currentUid()) {
|
|
1343
|
+
fail("entrypoint-invalid", "bound extension entrypoint identity is unsafe");
|
|
1344
|
+
}
|
|
1345
|
+
const { invocation, owner } = await reserveInvocation(home, record, verb, request, statePath);
|
|
1346
|
+
const { child } = invocation;
|
|
1347
|
+
let stdoutBytes = 0;
|
|
1348
|
+
let stderrBytes = 0;
|
|
1349
|
+
const stdout = [];
|
|
1350
|
+
let forcedCode = "";
|
|
1351
|
+
let forcedMessage = "";
|
|
1352
|
+
let killTimer = null;
|
|
1353
|
+
|
|
1354
|
+
const forceStop = (code, message) => {
|
|
1355
|
+
if (forcedCode) return;
|
|
1356
|
+
forcedCode = code;
|
|
1357
|
+
forcedMessage = message;
|
|
1358
|
+
signalProcessGroup(invocation, "SIGTERM");
|
|
1359
|
+
killTimer = setTimeout(() => signalProcessGroup(invocation, "SIGKILL"), TERMINATE_GRACE_MS);
|
|
1360
|
+
};
|
|
1361
|
+
|
|
1362
|
+
const completion = new Promise((resolve, reject) => {
|
|
1363
|
+
child.once("error", () => reject(new HostError("entrypoint-missing", "bound extension entrypoint could not be started")));
|
|
1364
|
+
child.stdout.on("data", (chunk) => {
|
|
1365
|
+
stdoutBytes += chunk.length;
|
|
1366
|
+
if (stdoutBytes > MAX_JSON_BYTES) {
|
|
1367
|
+
forceStop("response-oversized", `extension stdout exceeds ${MAX_JSON_BYTES} bytes`);
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
stdout.push(chunk);
|
|
1371
|
+
});
|
|
1372
|
+
child.stderr.on("data", (chunk) => {
|
|
1373
|
+
stderrBytes += chunk.length;
|
|
1374
|
+
if (stderrBytes > MAX_STDERR_BYTES) forceStop("stderr-oversized", `extension stderr exceeds ${MAX_STDERR_BYTES} bytes`);
|
|
1375
|
+
});
|
|
1376
|
+
child.once("close", (code, signal) => resolve({ code, signal }));
|
|
1377
|
+
});
|
|
1378
|
+
|
|
1379
|
+
try {
|
|
1380
|
+
await publishInvocationGroup(invocation, owner);
|
|
1381
|
+
} catch (error) {
|
|
1382
|
+
await finalizeInvocation(invocation);
|
|
1383
|
+
throw error;
|
|
1384
|
+
}
|
|
1385
|
+
const timeout = setTimeout(() => forceStop("timeout", `extension ${verb} exceeded ${timeoutMs} ms`), timeoutMs);
|
|
1386
|
+
child.stdin.on("error", () => {});
|
|
1387
|
+
child.stdin.end(requestBytes);
|
|
1388
|
+
|
|
1389
|
+
let outcome;
|
|
1390
|
+
try {
|
|
1391
|
+
outcome = await completion;
|
|
1392
|
+
} catch (error) {
|
|
1393
|
+
clearTimeout(timeout);
|
|
1394
|
+
if (killTimer) clearTimeout(killTimer);
|
|
1395
|
+
await finalizeInvocation(invocation);
|
|
1396
|
+
throw error;
|
|
1397
|
+
}
|
|
1398
|
+
clearTimeout(timeout);
|
|
1399
|
+
if (killTimer) clearTimeout(killTimer);
|
|
1400
|
+
const leakedProcessGroup = !forcedCode && groupAlive(invocation.pid);
|
|
1401
|
+
await finalizeInvocation(invocation);
|
|
1402
|
+
if (forcedCode) fail(forcedCode, forcedMessage);
|
|
1403
|
+
if (leakedProcessGroup) fail("process-leak", `extension ${verb} left a background process in its invocation group`);
|
|
1404
|
+
if (outcome.signal || outcome.code !== 0) fail("process-failed", `extension ${verb} exited nonzero`);
|
|
1405
|
+
return parseStrictJson(Buffer.concat(stdout), `extension ${verb} response`);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
async function handleSignal(signal) {
|
|
1409
|
+
if (terminatingForSignal) return;
|
|
1410
|
+
terminatingForSignal = true;
|
|
1411
|
+
try {
|
|
1412
|
+
await finalizeInvocation(activeInvocation);
|
|
1413
|
+
process.exit(signal === "SIGTERM" ? 143 : 130);
|
|
1414
|
+
} catch (error) {
|
|
1415
|
+
const message = error instanceof Error ? error.message : "extension process cleanup failed";
|
|
1416
|
+
process.stderr.write(`error[process-cleanup-failed]: ${message}\n`);
|
|
1417
|
+
process.exitCode = 1;
|
|
1418
|
+
signalCleanupFailureHold ||= setInterval(() => {}, 1000);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
process.on("SIGTERM", () => { void handleSignal("SIGTERM"); });
|
|
1423
|
+
process.on("SIGINT", () => { void handleSignal("SIGINT"); });
|
|
1424
|
+
|
|
1425
|
+
function validateHandshakeResponse(response, request, binding) {
|
|
1426
|
+
exactKeys(response, ["schema", "request_id", "extension_id", "extension_version", "host_protocol", "capability", "capability_version", "adapter_names"], "handshake response");
|
|
1427
|
+
if (response.schema !== HANDSHAKE_RESPONSE_SCHEMA) fail("handshake-invalid", "extension returned an unsupported handshake response schema");
|
|
1428
|
+
if (response.request_id !== request.request_id) fail("request-id-mismatch", "extension handshake response request_id does not match");
|
|
1429
|
+
if (response.extension_id !== binding.extension_id || response.extension_version !== binding.extension_version) {
|
|
1430
|
+
fail("handshake-invalid", "extension handshake identity does not match the binding");
|
|
1431
|
+
}
|
|
1432
|
+
if (response.host_protocol !== binding.host_protocol || response.capability !== PROCESS_EVENT_CAPABILITY || response.capability_version !== 1) {
|
|
1433
|
+
fail("handshake-invalid", "extension handshake protocol or capability does not match the binding");
|
|
1434
|
+
}
|
|
1435
|
+
const names = uniqueArray(response.adapter_names, "handshake adapter_names", (entry, label) => boundedString(entry, 32, label, ADAPTER_RE));
|
|
1436
|
+
const expected = [...binding.capabilities[0].adapter_names].sort();
|
|
1437
|
+
const actual = [...names].sort();
|
|
1438
|
+
if (actual.length !== expected.length || actual.some((name, index) => name !== expected[index])) {
|
|
1439
|
+
fail("handshake-invalid", "extension handshake adapter names do not match the enabled binding subset");
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
async function handshake(home, record, statePath = "") {
|
|
1444
|
+
const binding = record.binding;
|
|
1445
|
+
const request = {
|
|
1446
|
+
schema: HANDSHAKE_REQUEST_SCHEMA,
|
|
1447
|
+
request_id: makeRequestId(),
|
|
1448
|
+
host_protocols: HOST_PROTOCOLS,
|
|
1449
|
+
extension_id: binding.extension_id,
|
|
1450
|
+
extension_version: binding.extension_version,
|
|
1451
|
+
package_digest: binding.package_digest,
|
|
1452
|
+
capability: {
|
|
1453
|
+
name: PROCESS_EVENT_CAPABILITY,
|
|
1454
|
+
versions: PROCESS_EVENT_VERSIONS,
|
|
1455
|
+
adapter_names: binding.capabilities[0].adapter_names,
|
|
1456
|
+
},
|
|
1457
|
+
};
|
|
1458
|
+
const response = await runExtensionProcess(home, record, "handshake", request, HANDSHAKE_TIMEOUT_MS, statePath);
|
|
1459
|
+
validateHandshakeResponse(response, request, binding);
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
function validateResponseEnvelope(response, request) {
|
|
1463
|
+
exactKeys(response, ["schema", "request_id", "ok", "result", "error"], "extension response");
|
|
1464
|
+
if (response.schema !== RESPONSE_SCHEMA) fail("response-invalid", "extension returned an unsupported response schema");
|
|
1465
|
+
if (response.request_id !== request.request_id) fail("request-id-mismatch", "extension response request_id does not match");
|
|
1466
|
+
if (typeof response.ok !== "boolean") fail("response-invalid", "extension response ok must be boolean");
|
|
1467
|
+
if (response.ok) {
|
|
1468
|
+
if (!isPlainObject(response.result) || response.error !== null) fail("response-invalid", "successful extension response must carry result and null error");
|
|
1469
|
+
return response.result;
|
|
1470
|
+
}
|
|
1471
|
+
if (response.result !== null || !isPlainObject(response.error)) fail("response-invalid", "failed extension response must carry null result and an error");
|
|
1472
|
+
exactKeys(response.error, ["code", "retryable", "diagnostic"], "extension response error");
|
|
1473
|
+
if (!RESPONSE_ERROR_CODES.has(response.error.code) || typeof response.error.retryable !== "boolean") {
|
|
1474
|
+
fail("response-invalid", "extension response error has an unsupported code or retryable value");
|
|
1475
|
+
}
|
|
1476
|
+
boundedString(response.error.diagnostic, 512, "extension response diagnostic");
|
|
1477
|
+
fail(`extension-${response.error.code}`, `extension reported ${response.error.code}`);
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
function validateOperationResult(operation, result) {
|
|
1481
|
+
if (operation === "source.poll") {
|
|
1482
|
+
exactKeys(result, ["status", "output"], "source.poll result");
|
|
1483
|
+
if (result.status !== "result" && result.status !== "no-result") fail("response-invalid", "source.poll status must be result or no-result");
|
|
1484
|
+
if (typeof result.output !== "string") fail("response-invalid", "source.poll output must be a UTF-8 string");
|
|
1485
|
+
validateUnicode(result.output, "source.poll output");
|
|
1486
|
+
const size = Buffer.byteLength(result.output, "utf8");
|
|
1487
|
+
if (size > MAX_RESULT_BYTES) fail("response-oversized", `source.poll output exceeds ${MAX_RESULT_BYTES} bytes`);
|
|
1488
|
+
if (result.status === "result" && size === 0) fail("response-invalid", "source.poll result output must not be empty");
|
|
1489
|
+
if (result.status === "no-result" && size !== 0) fail("response-invalid", "source.poll no-result output must be empty");
|
|
1490
|
+
return result;
|
|
1491
|
+
}
|
|
1492
|
+
if (operation === "result.classify") {
|
|
1493
|
+
exactKeys(result, ["classification"], "result.classify result");
|
|
1494
|
+
boundedString(result.classification, 64, "result.classify classification", /^[a-z0-9]+(?:-[a-z0-9]+)*$/);
|
|
1495
|
+
return result;
|
|
1496
|
+
}
|
|
1497
|
+
if (operation === "result.terminal" || operation === "result.silent") {
|
|
1498
|
+
exactKeys(result, ["value"], `${operation} result`);
|
|
1499
|
+
if (typeof result.value !== "boolean") fail("response-invalid", `${operation} value must be boolean`);
|
|
1500
|
+
return result;
|
|
1501
|
+
}
|
|
1502
|
+
fail("operation-unsupported", `unsupported process-event operation: ${operation}`);
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
async function consumeCaptureReservation(home, resultFile, operation, expected) {
|
|
1506
|
+
const capability = activeLifecycleLock?.captureCapability;
|
|
1507
|
+
if (!capability || (operation !== "result.terminal" && operation !== "result.silent")) return null;
|
|
1508
|
+
const { token, claimPid, claimIdentity, claimToken, sourceId, sequence } = capability;
|
|
1509
|
+
const match = resultFile.match(/^\.\/([A-Za-z0-9._-]{1,64})\.([0-9]+)\.result$/);
|
|
1510
|
+
if (!match) fail("path-unsafe", "captured result is not pinned to the process-event inbox");
|
|
1511
|
+
if (match[1] !== sourceId || match[2] !== sequence) fail("path-unsafe", "captured result does not match its active claim");
|
|
1512
|
+
const reservationRoot = path.join(effectiveStateRoot(home), "procevent-capture-reservations");
|
|
1513
|
+
await assertOwnedSafeDirectory(reservationRoot, "process-event capture reservation root", true);
|
|
1514
|
+
const pending = path.join(reservationRoot, `.extension-capture-${claimToken}.${token}.json`);
|
|
1515
|
+
const consumed = path.join(reservationRoot, `.extension-capture-${claimToken}.${token}.consumed-${makeRequestId().slice(7)}`);
|
|
1516
|
+
try {
|
|
1517
|
+
await rename(pending, consumed);
|
|
1518
|
+
} catch {
|
|
1519
|
+
fail("path-unsafe", "captured result reservation is unavailable");
|
|
1520
|
+
}
|
|
1521
|
+
try {
|
|
1522
|
+
const info = await maybeLstat(consumed);
|
|
1523
|
+
if (!info || !info.isFile() || info.isSymbolicLink() || info.nlink !== 1
|
|
1524
|
+
|| info.uid !== currentUid() || modeOf(info) !== 0o600 || info.size > MAX_JSON_BYTES) {
|
|
1525
|
+
fail("path-unsafe", "captured result reservation is unsafe");
|
|
1526
|
+
}
|
|
1527
|
+
const record = parseStrictJson(await readFile(consumed), "captured result reservation");
|
|
1528
|
+
exactKeys(record, ["schema", "token", "operation", "source_id", "sequence", "inbox_device", "inbox_inode", "result_device", "result_inode", "claim_pid", "claim_identity", "claim_token", "binding_digest"], "captured result reservation");
|
|
1529
|
+
if (record.schema !== CAPTURE_RESERVATION_SCHEMA || record.token !== token || record.operation !== operation
|
|
1530
|
+
|| record.source_id !== match[1] || String(record.sequence) !== match[2]
|
|
1531
|
+
|| record.binding_digest !== expected["--expect-binding-digest"]
|
|
1532
|
+
|| record.claim_pid !== claimPid || record.claim_identity !== claimIdentity || record.claim_token !== claimToken
|
|
1533
|
+
|| !/^[A-Za-z0-9._-]{1,256}$/.test(record.claim_token) || !/^[0-9]+$/.test(record.inbox_device)
|
|
1534
|
+
|| !/^[0-9]+$/.test(record.inbox_inode) || !/^[0-9]+$/.test(record.result_device)
|
|
1535
|
+
|| !/^[0-9]+$/.test(record.result_inode)) {
|
|
1536
|
+
fail("path-unsafe", "captured result reservation does not match this invocation");
|
|
1537
|
+
}
|
|
1538
|
+
if (record.binding_digest !== capability.bindingDigest || record.source_id !== capability.sourceId
|
|
1539
|
+
|| String(record.sequence) !== capability.sequence || record.result_device !== capability.resultDevice
|
|
1540
|
+
|| record.result_inode !== capability.resultInode) {
|
|
1541
|
+
fail("path-unsafe", "captured result reservation does not match its capability");
|
|
1542
|
+
}
|
|
1543
|
+
if (await processIdentityState(Number(claimPid), claimIdentity) !== 0) {
|
|
1544
|
+
fail("process-identity-uncertain", "captured result owner is no longer active");
|
|
1545
|
+
}
|
|
1546
|
+
const inboxInfo = await fstatAsync(8).catch(() => fail("path-unsafe", "captured result inbox descriptor is unavailable"));
|
|
1547
|
+
if (!inboxInfo.isDirectory() || String(inboxInfo.dev) !== record.inbox_device || String(inboxInfo.ino) !== record.inbox_inode) {
|
|
1548
|
+
fail("path-unsafe", "captured result inbox descriptor does not match its reservation");
|
|
1549
|
+
}
|
|
1550
|
+
try {
|
|
1551
|
+
const resultInfo = await fstatAsync(9);
|
|
1552
|
+
if (!resultInfo.isFile() || resultInfo.nlink !== 1 || resultInfo.uid !== currentUid() || modeOf(resultInfo) !== 0o600
|
|
1553
|
+
|| String(resultInfo.dev) !== record.result_device || String(resultInfo.ino) !== record.result_inode
|
|
1554
|
+
|| resultInfo.size > MAX_RESULT_BYTES) {
|
|
1555
|
+
fail("path-unsafe", "captured result does not match its reservation");
|
|
1556
|
+
}
|
|
1557
|
+
const bytes = await readPinnedDescriptor(9, MAX_RESULT_BYTES);
|
|
1558
|
+
let content;
|
|
1559
|
+
try { content = decoder.decode(bytes); } catch { fail("json-invalid", "captured extension result is not valid UTF-8"); }
|
|
1560
|
+
return { sourceId: record.source_id, sequence: Number(record.sequence), content };
|
|
1561
|
+
} catch (error) {
|
|
1562
|
+
if (error instanceof HostError) throw error;
|
|
1563
|
+
fail("path-unsafe", "captured result is unavailable through its pinned inbox");
|
|
1564
|
+
}
|
|
1565
|
+
} finally {
|
|
1566
|
+
await unlink(consumed).catch(() => {});
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
async function inheritedCaptureCapability(home) {
|
|
1571
|
+
const [claimInfo, capabilityInfo, inboxInfo, resultInfo] = await Promise.all([
|
|
1572
|
+
fstatAsync(6).catch(() => null),
|
|
1573
|
+
fstatAsync(7).catch(() => null),
|
|
1574
|
+
fstatAsync(8).catch(() => null),
|
|
1575
|
+
fstatAsync(9).catch(() => null),
|
|
1576
|
+
]);
|
|
1577
|
+
// Node may retain unrelated descriptors at the capability descriptor numbers
|
|
1578
|
+
// on an ordinary lifecycle invocation. The unlinked capability file is the
|
|
1579
|
+
// direct capture handoff marker; every partial handoff remains a hard failure
|
|
1580
|
+
// below.
|
|
1581
|
+
if (!capabilityInfo || !capabilityInfo.isFile() || capabilityInfo.uid !== currentUid()
|
|
1582
|
+
|| modeOf(capabilityInfo) !== 0o600 || capabilityInfo.nlink !== 0) return null;
|
|
1583
|
+
if (!claimInfo || !capabilityInfo || !resultInfo || !claimInfo.isFile() || !capabilityInfo.isFile() || !resultInfo.isFile()
|
|
1584
|
+
|| claimInfo.uid !== currentUid() || capabilityInfo.uid !== currentUid()
|
|
1585
|
+
|| modeOf(claimInfo) !== 0o600 || modeOf(capabilityInfo) !== 0o600
|
|
1586
|
+
|| claimInfo.nlink !== 1 || capabilityInfo.nlink !== 0
|
|
1587
|
+
|| resultInfo.uid !== currentUid() || modeOf(resultInfo) !== 0o600 || resultInfo.nlink !== 1
|
|
1588
|
+
|| claimInfo.size === 0 || claimInfo.size > MAX_JSON_BYTES
|
|
1589
|
+
|| capabilityInfo.size === 0 || capabilityInfo.size > MAX_JSON_BYTES) {
|
|
1590
|
+
fail("path-unsafe", "capture handoff descriptors are unsafe");
|
|
1591
|
+
}
|
|
1592
|
+
const [claimBytes, capabilityBytes] = await Promise.all([
|
|
1593
|
+
readPinnedDescriptor(6, MAX_JSON_BYTES).catch(() => fail("path-unsafe", "capture claim descriptor is unavailable")),
|
|
1594
|
+
readPinnedDescriptor(7, MAX_JSON_BYTES).catch(() => fail("path-unsafe", "capture capability descriptor is unavailable")),
|
|
1595
|
+
]);
|
|
1596
|
+
let claimText;
|
|
1597
|
+
try { claimText = decoder.decode(claimBytes); } catch { fail("json-invalid", "capture claim descriptor is not valid UTF-8"); }
|
|
1598
|
+
const claimLines = claimText.split("\n");
|
|
1599
|
+
if (claimLines.pop() !== "" || (claimLines.length !== 7 && claimLines.length !== 12)) fail("path-unsafe", "capture claim descriptor is malformed");
|
|
1600
|
+
const [claimHome, claimPid, claimToken, claimIdentity, claimRegistry, claimRegistryIdentity, claimState,
|
|
1601
|
+
claimStateRoot, claimStateDevice, claimStateInode, claimStateOwner, claimStateMode] = claimLines;
|
|
1602
|
+
if (claimHome !== home || !/^[0-9]+$/.test(claimPid) || !/^[A-Za-z0-9._-]{1,256}$/.test(claimToken)
|
|
1603
|
+
|| !claimIdentity || !claimRegistry.startsWith("/") || !claimRegistryIdentity.includes(":") || claimState !== "active") {
|
|
1604
|
+
fail("path-unsafe", "capture claim descriptor is invalid");
|
|
1605
|
+
}
|
|
1606
|
+
if (claimLines.length === 12 && (!claimStateRoot.startsWith("/") || /[\u0000-\u001f\u007f]/.test(claimStateRoot) || !/^[0-9]+$/.test(claimStateDevice)
|
|
1607
|
+
|| !/^[0-9]+$/.test(claimStateInode) || !/^[0-9]+$/.test(claimStateOwner)
|
|
1608
|
+
|| !/^[0-7]+$/.test(claimStateMode) || (Number.parseInt(claimStateMode, 8) & 0o22) !== 0)) {
|
|
1609
|
+
fail("path-unsafe", "capture claim state root is invalid");
|
|
1610
|
+
}
|
|
1611
|
+
const capability = parseStrictJson(capabilityBytes, "capture capability");
|
|
1612
|
+
exactKeys(capability, ["schema", "token", "operation", "source_id", "sequence", "binding_digest", "claim_home", "claim_pid", "claim_identity", "claim_token", "claim_device", "claim_inode", "inbox_device", "inbox_inode", "result_device", "result_inode"], "capture capability");
|
|
1613
|
+
if (capability.schema !== "fm-procevent-capture-capability.v1" || !/^[a-f0-9]{64}$/.test(capability.token)
|
|
1614
|
+
|| (capability.operation !== "result.terminal" && capability.operation !== "result.silent")
|
|
1615
|
+
|| !/^[A-Za-z0-9._-]{1,64}$/.test(capability.source_id) || !Number.isSafeInteger(capability.sequence) || capability.sequence < 0
|
|
1616
|
+
|| !DIGEST_RE.test(capability.binding_digest) || capability.claim_home !== claimHome
|
|
1617
|
+
|| capability.claim_pid !== claimPid || capability.claim_identity !== claimIdentity || capability.claim_token !== claimToken
|
|
1618
|
+
|| String(claimInfo.dev) !== capability.claim_device || String(claimInfo.ino) !== capability.claim_inode
|
|
1619
|
+
|| !/^[0-9]+$/.test(capability.inbox_device) || !/^[0-9]+$/.test(capability.inbox_inode)
|
|
1620
|
+
|| !/^[0-9]+$/.test(capability.result_device) || !/^[0-9]+$/.test(capability.result_inode)) {
|
|
1621
|
+
fail("path-unsafe", "capture capability does not match its active claim");
|
|
1622
|
+
}
|
|
1623
|
+
if (String(resultInfo.dev) !== capability.result_device || String(resultInfo.ino) !== capability.result_inode) {
|
|
1624
|
+
fail("path-unsafe", "capture result descriptor does not match its capability");
|
|
1625
|
+
}
|
|
1626
|
+
if (await processIdentityState(Number(claimPid), claimIdentity) !== 0) {
|
|
1627
|
+
fail("process-identity-uncertain", "capture claim owner is no longer active");
|
|
1628
|
+
}
|
|
1629
|
+
if (!inboxInfo) fail("path-unsafe", "capture inbox descriptor is unavailable");
|
|
1630
|
+
if (!inboxInfo.isDirectory() || String(inboxInfo.dev) !== capability.inbox_device || String(inboxInfo.ino) !== capability.inbox_inode) {
|
|
1631
|
+
fail("path-unsafe", "capture inbox descriptor does not match its capability");
|
|
1632
|
+
}
|
|
1633
|
+
return { token: capability.token, operation: capability.operation, sourceId: capability.source_id, sequence: String(capability.sequence),
|
|
1634
|
+
bindingDigest: capability.binding_digest, claimPid, claimIdentity, claimToken, resultDevice: capability.result_device, resultInode: capability.result_inode };
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
async function readCapturedResult(home, resultFile, operation, expected) {
|
|
1638
|
+
const reserved = await consumeCaptureReservation(home, resultFile, operation, expected);
|
|
1639
|
+
if (reserved) return reserved;
|
|
1640
|
+
const absolute = path.resolve(resultFile);
|
|
1641
|
+
const inbox = path.join(effectiveStateRoot(home), "procevent-inbox");
|
|
1642
|
+
if (!isInside(inbox, absolute) || path.dirname(absolute) !== inbox) fail("path-unsafe", "captured result must be directly inside this home's process-event inbox");
|
|
1643
|
+
const canonicalInbox = await realpath(inbox).catch(() => fail("path-unsafe", "process-event inbox is unavailable"));
|
|
1644
|
+
if (canonicalInbox !== inbox) fail("path-unsafe", "process-event inbox traverses a symbolic link");
|
|
1645
|
+
const info = await maybeLstat(absolute);
|
|
1646
|
+
if (!info || !info.isFile() || info.isSymbolicLink() || info.nlink !== 1) fail("link-unsafe", "captured result is not one regular file");
|
|
1647
|
+
if (info.uid !== currentUid() || modeOf(info) !== 0o600) fail("mode-unsafe", "captured result owner or mode is unsafe");
|
|
1648
|
+
if (info.size > MAX_RESULT_BYTES) fail("request-oversized", `captured extension result exceeds ${MAX_RESULT_BYTES} bytes`);
|
|
1649
|
+
const bytes = await readFile(absolute);
|
|
1650
|
+
let content;
|
|
1651
|
+
try {
|
|
1652
|
+
content = decoder.decode(bytes);
|
|
1653
|
+
} catch {
|
|
1654
|
+
fail("json-invalid", "captured extension result is not valid UTF-8");
|
|
1655
|
+
}
|
|
1656
|
+
const base = path.basename(absolute, ".result");
|
|
1657
|
+
const match = base.match(/^([A-Za-z0-9._-]{1,64})\.([0-9]+)$/);
|
|
1658
|
+
const sequence = match ? Number(match[2]) : Number.NaN;
|
|
1659
|
+
if (!match || !Number.isSafeInteger(sequence)) fail("path-unsafe", "captured result filename has no valid source identity");
|
|
1660
|
+
return { sourceId: match[1], sequence, content };
|
|
1661
|
+
}
|
|
1662
|
+
|
|
1663
|
+
function parseExpectedOptions(args) {
|
|
1664
|
+
const expected = Object.create(null);
|
|
1665
|
+
const rest = [];
|
|
1666
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
1667
|
+
const name = args[index];
|
|
1668
|
+
if (["--expect-extension", "--expect-version", "--expect-capability-version", "--expect-package-digest", "--expect-binding-digest", "--source-id", "--config-ref", "--result-file", "--request-id"].includes(name)) {
|
|
1669
|
+
if (index + 1 >= args.length) fail("usage", `${name} requires a value`);
|
|
1670
|
+
if (Object.hasOwn(expected, name)) fail("usage", `${name} may be supplied only once`);
|
|
1671
|
+
expected[name] = args[index + 1];
|
|
1672
|
+
index += 1;
|
|
1673
|
+
} else {
|
|
1674
|
+
rest.push(name);
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
if (rest.length) fail("usage", `unknown process-event option: ${rest[0]}`);
|
|
1678
|
+
return expected;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
function assertExpectedRecord(record, expected) {
|
|
1682
|
+
const required = ["--expect-extension", "--expect-version", "--expect-capability-version", "--expect-package-digest", "--expect-binding-digest"];
|
|
1683
|
+
for (const name of required) if (!Object.hasOwn(expected, name)) fail("usage", `${name} is required`);
|
|
1684
|
+
if (record.binding.extension_id !== expected["--expect-extension"]
|
|
1685
|
+
|| record.binding.extension_version !== expected["--expect-version"]
|
|
1686
|
+
|| expected["--expect-capability-version"] !== "1"
|
|
1687
|
+
|| record.binding.package_digest !== expected["--expect-package-digest"]
|
|
1688
|
+
|| record.bindingDigest !== expected["--expect-binding-digest"]) {
|
|
1689
|
+
fail("owner-mismatch", "current extension binding does not match the process-event registration owner");
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
async function invokeProcessEvent(home, adapter, operation, options) {
|
|
1694
|
+
boundedString(adapter, 32, "adapter", ADAPTER_RE);
|
|
1695
|
+
if (!["source.poll", "result.classify", "result.terminal", "result.silent"].includes(operation)) {
|
|
1696
|
+
fail("operation-unsupported", `unsupported process-event operation: ${operation}`);
|
|
1697
|
+
}
|
|
1698
|
+
const bindings = await loadBindings(home, { packages: true });
|
|
1699
|
+
const record = selectAdapter(bindings, adapter);
|
|
1700
|
+
assertExpectedRecord(record, options);
|
|
1701
|
+
const statePath = await ensureExtensionState(home, record.binding);
|
|
1702
|
+
await handshake(home, record, statePath);
|
|
1703
|
+
let input;
|
|
1704
|
+
if (operation === "source.poll") {
|
|
1705
|
+
const sourceId = boundedString(options["--source-id"], 64, "source id", /^[A-Za-z0-9._-]+$/);
|
|
1706
|
+
const configRef = boundedString(options["--config-ref"], 512, "source configuration reference");
|
|
1707
|
+
input = { source_id: sourceId, config_ref: configRef };
|
|
1708
|
+
} else {
|
|
1709
|
+
if (!options["--result-file"]) fail("usage", `${operation} requires --result-file`);
|
|
1710
|
+
const captured = await readCapturedResult(home, options["--result-file"], operation, options);
|
|
1711
|
+
input = { source_id: captured.sourceId, sequence: captured.sequence, content: captured.content };
|
|
1712
|
+
}
|
|
1713
|
+
const requestId = options["--request-id"] || makeRequestId();
|
|
1714
|
+
if (!REQUEST_ID_RE.test(requestId)) fail("usage", "--request-id must be sha256:<64 lowercase hex>");
|
|
1715
|
+
const request = {
|
|
1716
|
+
schema: REQUEST_SCHEMA,
|
|
1717
|
+
request_id: requestId,
|
|
1718
|
+
host_protocol: record.binding.host_protocol,
|
|
1719
|
+
extension_id: record.binding.extension_id,
|
|
1720
|
+
extension_version: record.binding.extension_version,
|
|
1721
|
+
package_digest: record.binding.package_digest,
|
|
1722
|
+
capability: PROCESS_EVENT_CAPABILITY,
|
|
1723
|
+
capability_version: 1,
|
|
1724
|
+
adapter,
|
|
1725
|
+
operation,
|
|
1726
|
+
input,
|
|
1727
|
+
};
|
|
1728
|
+
const response = await runExtensionProcess(home, record, "invoke", request, record.binding.timeout_ms, statePath);
|
|
1729
|
+
return validateOperationResult(operation, validateResponseEnvelope(response, request));
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
function errorEvidence(error, extensionId, operation) {
|
|
1733
|
+
const allowedCode = typeof error?.code === "string" && /^[a-z0-9-]{1,64}$/.test(error.code) ? error.code : "internal";
|
|
1734
|
+
const safeExtensionId = typeof extensionId === "string"
|
|
1735
|
+
&& Buffer.byteLength(extensionId, "utf8") <= 128
|
|
1736
|
+
&& ID_RE.test(extensionId) ? extensionId : "unknown";
|
|
1737
|
+
return `${canonicalJson({
|
|
1738
|
+
schema: ERROR_EVIDENCE_SCHEMA,
|
|
1739
|
+
extension_id: safeExtensionId,
|
|
1740
|
+
operation,
|
|
1741
|
+
code: allowedCode,
|
|
1742
|
+
})}\n`;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
function parseBindArguments(args) {
|
|
1746
|
+
if (args.length === 0) fail("usage", "bind requires <package-root>");
|
|
1747
|
+
const packageRoot = args[0];
|
|
1748
|
+
const adapters = [];
|
|
1749
|
+
const consents = new Set();
|
|
1750
|
+
let trust = false;
|
|
1751
|
+
let timeoutMs = DEFAULT_TIMEOUT_MS;
|
|
1752
|
+
for (let index = 1; index < args.length; index += 1) {
|
|
1753
|
+
const name = args[index];
|
|
1754
|
+
if (name === "--adapter" || name === "--consent" || name === "--timeout-ms") {
|
|
1755
|
+
if (index + 1 >= args.length) fail("usage", `${name} requires a value`);
|
|
1756
|
+
const value = args[index + 1];
|
|
1757
|
+
index += 1;
|
|
1758
|
+
if (name === "--adapter") adapters.push(value);
|
|
1759
|
+
else if (name === "--consent") consents.add(value);
|
|
1760
|
+
else timeoutMs = Number(value);
|
|
1761
|
+
continue;
|
|
1762
|
+
}
|
|
1763
|
+
if (name === "--trust-same-user-code") {
|
|
1764
|
+
if (trust) fail("usage", "--trust-same-user-code may be supplied only once");
|
|
1765
|
+
trust = true;
|
|
1766
|
+
continue;
|
|
1767
|
+
}
|
|
1768
|
+
fail("usage", `unknown bind option: ${name}`);
|
|
1769
|
+
}
|
|
1770
|
+
if (!trust) fail("consent-missing", "bind requires --trust-same-user-code");
|
|
1771
|
+
if (adapters.length === 0) fail("usage", "bind requires at least one --adapter");
|
|
1772
|
+
integerIn(timeoutMs, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS, "--timeout-ms");
|
|
1773
|
+
for (const consent of consents) if (!CONSENT_NAMES.includes(consent)) fail("usage", `unsupported consent fact: ${consent}`);
|
|
1774
|
+
return { packageRoot, adapters, consents, timeoutMs };
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
async function atomicWriteBinding(registry, destination, bytes) {
|
|
1778
|
+
const temporary = path.join(registry, `.binding-${process.pid}-${randomBytes(8).toString("hex")}`);
|
|
1779
|
+
const handle = await open(temporary, "wx", 0o600);
|
|
1780
|
+
try {
|
|
1781
|
+
await handle.writeFile(bytes);
|
|
1782
|
+
await handle.sync();
|
|
1783
|
+
} finally {
|
|
1784
|
+
await handle.close();
|
|
1785
|
+
}
|
|
1786
|
+
await chmod(temporary, 0o600);
|
|
1787
|
+
let published = false;
|
|
1788
|
+
try {
|
|
1789
|
+
// Atomic no-replace publication: a concurrent binding always wins rather
|
|
1790
|
+
// than being overwritten between the caller's absence check and commit.
|
|
1791
|
+
await link(temporary, destination);
|
|
1792
|
+
published = true;
|
|
1793
|
+
await unlink(temporary);
|
|
1794
|
+
} catch (error) {
|
|
1795
|
+
if (published) await unlink(destination).catch(() => {});
|
|
1796
|
+
await rm(temporary, { force: true });
|
|
1797
|
+
throw error;
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
async function cmdBind(args) {
|
|
1802
|
+
await runLifecycleBinding("bind", args);
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1805
|
+
async function cmdBindFrom(args, stagedRoot) {
|
|
1806
|
+
const parsed = parseBindArguments(args);
|
|
1807
|
+
const home = await activeHome();
|
|
1808
|
+
const sourceRoot = stagedRoot === null
|
|
1809
|
+
? await validateSourceRoot(home, parsed.packageRoot)
|
|
1810
|
+
: await realpath(stagedRoot);
|
|
1811
|
+
if (stagedRoot !== null && (path.resolve(parsed.packageRoot) !== stagedRoot || sourceRoot !== stagedRoot)) {
|
|
1812
|
+
fail("path-unsafe", "received package root does not match its published staging path");
|
|
1813
|
+
}
|
|
1814
|
+
const sourceInfo = await validatePackage(sourceRoot, { installed: false });
|
|
1815
|
+
const selected = uniqueArray(parsed.adapters, "--adapter values", (entry, label) => boundedString(entry, 32, label, ADAPTER_RE));
|
|
1816
|
+
for (const adapter of selected) {
|
|
1817
|
+
if (!sourceInfo.manifest.capabilities[0].adapter_names.includes(adapter)) fail("capability-mismatch", `manifest does not allow adapter: ${adapter}`);
|
|
1818
|
+
if (await maybeLstat(path.join(CODE_ROOT, "bin", `fm-procevent-${adapter}.sh`))) {
|
|
1819
|
+
fail("adapter-conflict", `adapter name is already owned by a built-in: ${adapter}`);
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
for (const consent of sourceInfo.manifest.required_consents) {
|
|
1823
|
+
if (!parsed.consents.has(consent)) fail("consent-missing", `manifest requires explicit --consent ${consent}`);
|
|
1824
|
+
}
|
|
1825
|
+
const commonHost = sourceInfo.manifest.host_protocols.filter((version) => HOST_PROTOCOLS.includes(version)).sort((a, b) => b - a)[0];
|
|
1826
|
+
const commonCapability = sourceInfo.manifest.capabilities[0].versions.filter((version) => PROCESS_EVENT_VERSIONS.includes(version)).sort((a, b) => b - a)[0];
|
|
1827
|
+
if (!commonHost || !commonCapability) fail("protocol-incompatible", "package and host have no common process-event protocol version");
|
|
1828
|
+
|
|
1829
|
+
const existingBindings = await loadBindings(home, { packages: false });
|
|
1830
|
+
if (existingBindings.some((record) => record.binding.extension_id === sourceInfo.manifest.id)) {
|
|
1831
|
+
fail("binding-exists", `binding already exists for extension: ${sourceInfo.manifest.id}`);
|
|
1832
|
+
}
|
|
1833
|
+
for (const adapter of selected) {
|
|
1834
|
+
if (existingBindings.some((record) => record.binding.capabilities[0].adapter_names.includes(adapter))) {
|
|
1835
|
+
fail("adapter-conflict", `adapter is already enabled by another binding: ${adapter}`);
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
const installed = await installPackage(home, sourceInfo);
|
|
1840
|
+
const binding = {
|
|
1841
|
+
schema: BINDING_SCHEMA,
|
|
1842
|
+
extension_id: sourceInfo.manifest.id,
|
|
1843
|
+
extension_version: sourceInfo.manifest.version,
|
|
1844
|
+
source: { kind: "local-directory", path: sourceRoot },
|
|
1845
|
+
package_root: installed.packageInfo.root,
|
|
1846
|
+
manifest_sha256: installed.packageInfo.manifestDigest,
|
|
1847
|
+
package_digest: installed.packageInfo.tree.digest,
|
|
1848
|
+
entrypoint: installed.packageInfo.manifest.entrypoint,
|
|
1849
|
+
entrypoint_sha256: installed.packageInfo.entrypointDigest,
|
|
1850
|
+
host_protocol: commonHost,
|
|
1851
|
+
capabilities: [{ name: PROCESS_EVENT_CAPABILITY, version: commonCapability, adapter_names: selected }],
|
|
1852
|
+
consents: {
|
|
1853
|
+
trusted_same_user_code: true,
|
|
1854
|
+
network: parsed.consents.has("network"),
|
|
1855
|
+
credential_store: parsed.consents.has("credential-store"),
|
|
1856
|
+
task_metadata: parsed.consents.has("task-metadata"),
|
|
1857
|
+
artifact_references: parsed.consents.has("artifact-references"),
|
|
1858
|
+
},
|
|
1859
|
+
timeout_ms: parsed.timeoutMs,
|
|
1860
|
+
};
|
|
1861
|
+
const record = {
|
|
1862
|
+
binding,
|
|
1863
|
+
bindingDigest: digestBytes(Buffer.from(prettyJson(binding), "utf8")),
|
|
1864
|
+
packageInfo: installed.packageInfo,
|
|
1865
|
+
};
|
|
1866
|
+
const statePath = await ensureExtensionState(home, binding);
|
|
1867
|
+
let publishedBinding = "";
|
|
1868
|
+
let publishedBytes = null;
|
|
1869
|
+
try {
|
|
1870
|
+
await handshake(home, record, statePath);
|
|
1871
|
+
const registry = await ensureHomePrivatePath(home, ["config", "extensions.d"]);
|
|
1872
|
+
const destination = path.join(registry, `${binding.extension_id}.json`);
|
|
1873
|
+
if (await maybeLstat(destination)) fail("binding-exists", `binding already exists for extension: ${binding.extension_id}`);
|
|
1874
|
+
const bytes = Buffer.from(prettyJson(binding), "utf8");
|
|
1875
|
+
await atomicWriteBinding(registry, destination, bytes);
|
|
1876
|
+
publishedBinding = destination;
|
|
1877
|
+
publishedBytes = bytes;
|
|
1878
|
+
const loaded = (await loadBindings(home, { packages: true })).find((candidate) => candidate.binding.extension_id === binding.extension_id);
|
|
1879
|
+
if (!loaded) fail("binding-write-failed", "binding was not readable after publication");
|
|
1880
|
+
await handshake(home, loaded, statePath);
|
|
1881
|
+
process.stdout.write(`bound: ${binding.extension_id}@${binding.extension_version}\n`);
|
|
1882
|
+
process.stdout.write(`binding: ${destination}\n`);
|
|
1883
|
+
process.stdout.write(`binding-digest: ${loaded.bindingDigest}\n`);
|
|
1884
|
+
process.stdout.write(`package: ${binding.package_root}\n`);
|
|
1885
|
+
process.stdout.write(`package-digest: ${binding.package_digest}\n`);
|
|
1886
|
+
process.stdout.write(`verified: ${PROCESS_EVENT_CAPABILITY}/${commonCapability} (${selected.join(",")})\n`);
|
|
1887
|
+
} catch (error) {
|
|
1888
|
+
if (publishedBinding && publishedBytes) {
|
|
1889
|
+
const current = await readFile(publishedBinding).catch(() => null);
|
|
1890
|
+
if (current && Buffer.compare(current, publishedBytes) === 0) {
|
|
1891
|
+
await rm(publishedBinding, { force: true }).catch(() => {});
|
|
1892
|
+
}
|
|
1893
|
+
}
|
|
1894
|
+
throw error;
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1897
|
+
|
|
1898
|
+
function transferEntryPath(value, label) {
|
|
1899
|
+
const relative = boundedString(value, 512, label);
|
|
1900
|
+
if (path.posix.isAbsolute(relative) || relative.includes("\\")
|
|
1901
|
+
|| relative.split("/").some((part) => part === "" || part === "." || part === "..")) {
|
|
1902
|
+
fail("path-unsafe", `${label} must be a normalized relative POSIX path`);
|
|
1903
|
+
}
|
|
1904
|
+
return relative;
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
function validateTransferEnvelope(value) {
|
|
1908
|
+
exactKeys(value, ["schema", "manifest", "manifest_sha256", "payloads"], "package transfer envelope");
|
|
1909
|
+
if (value.schema !== TRANSFER_SCHEMA) fail("schema-invalid", "unsupported package transfer envelope schema");
|
|
1910
|
+
if (!DIGEST_RE.test(value.manifest_sha256)) fail("schema-invalid", "transfer manifest_sha256 is not a SHA-256 digest");
|
|
1911
|
+
exactKeys(value.manifest, ["schema", "extension_id", "extension_version", "package_digest", "entry_count", "total_bytes", "entries"], "package transfer manifest");
|
|
1912
|
+
const manifest = value.manifest;
|
|
1913
|
+
if (manifest.schema !== TRANSFER_MANIFEST_SCHEMA) fail("schema-invalid", "unsupported package transfer manifest schema");
|
|
1914
|
+
boundedString(manifest.extension_id, 128, "transfer extension_id", ID_RE);
|
|
1915
|
+
boundedString(manifest.extension_version, 128, "transfer extension_version", SEMVER_RE);
|
|
1916
|
+
if (!DIGEST_RE.test(manifest.package_digest)) fail("schema-invalid", "transfer package_digest is not a SHA-256 digest");
|
|
1917
|
+
integerIn(manifest.entry_count, 1, MAX_TRANSFER_ENTRIES, "transfer entry_count");
|
|
1918
|
+
integerIn(manifest.total_bytes, 1, MAX_TRANSFER_PACKAGE_BYTES, "transfer total_bytes");
|
|
1919
|
+
if (!Array.isArray(manifest.entries) || manifest.entries.length !== manifest.entry_count) fail("schema-invalid", "transfer entry_count does not match entries");
|
|
1920
|
+
if (!Array.isArray(value.payloads) || value.payloads.length !== manifest.entry_count) fail("schema-invalid", "transfer payload count does not match entries");
|
|
1921
|
+
const seen = new Map();
|
|
1922
|
+
let total = 0;
|
|
1923
|
+
let previous = "";
|
|
1924
|
+
for (let index = 0; index < manifest.entries.length; index += 1) {
|
|
1925
|
+
const entry = manifest.entries[index];
|
|
1926
|
+
exactKeys(entry, ["path", "type", "mode", "size", "sha256"], `transfer entry ${index}`);
|
|
1927
|
+
const relative = transferEntryPath(entry.path, `transfer entry ${index} path`);
|
|
1928
|
+
if (previous && Buffer.compare(Buffer.from(previous), Buffer.from(relative)) >= 0) fail("schema-invalid", "transfer entries must be uniquely byte-sorted");
|
|
1929
|
+
previous = relative;
|
|
1930
|
+
for (const ancestor of relative.split("/").slice(0, -1).map((_, partIndex, parts) => parts.slice(0, partIndex + 1).join("/"))) {
|
|
1931
|
+
if (seen.get(ancestor) === "file") fail("path-unsafe", `transfer path collides with file ancestor: ${relative}`);
|
|
1932
|
+
}
|
|
1933
|
+
if (entry.type === "directory") {
|
|
1934
|
+
if (entry.mode !== 0o755 || entry.size !== 0 || entry.sha256 !== null || value.payloads[index] !== null) {
|
|
1935
|
+
fail("schema-invalid", `transfer directory entry is invalid: ${relative}`);
|
|
1936
|
+
}
|
|
1937
|
+
} else if (entry.type === "file") {
|
|
1938
|
+
if (entry.mode !== 0o644 && entry.mode !== 0o755) fail("mode-unsafe", `transfer file mode is not allowed: ${relative}`);
|
|
1939
|
+
integerIn(entry.size, 0, MAX_TRANSFER_FILE_BYTES, `transfer file size for ${relative}`);
|
|
1940
|
+
if (!DIGEST_RE.test(entry.sha256)) fail("schema-invalid", `transfer file digest is invalid: ${relative}`);
|
|
1941
|
+
if (typeof value.payloads[index] !== "string" || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value.payloads[index])) {
|
|
1942
|
+
fail("schema-invalid", `transfer payload is not canonical base64: ${relative}`);
|
|
1943
|
+
}
|
|
1944
|
+
const bytes = Buffer.from(value.payloads[index], "base64");
|
|
1945
|
+
if (bytes.length !== entry.size || digestBytes(bytes) !== entry.sha256) fail("integrity-mismatch", `transfer payload hash or size mismatch: ${relative}`);
|
|
1946
|
+
total += bytes.length;
|
|
1947
|
+
if (total > MAX_TRANSFER_PACKAGE_BYTES) fail("package-oversized", `transferred package exceeds ${MAX_TRANSFER_PACKAGE_BYTES} bytes`);
|
|
1948
|
+
} else {
|
|
1949
|
+
fail("package-invalid", `transfer entry type is not allowed: ${relative}`);
|
|
1950
|
+
}
|
|
1951
|
+
seen.set(relative, entry.type);
|
|
1952
|
+
}
|
|
1953
|
+
if (total !== manifest.total_bytes) fail("integrity-mismatch", "transfer total_bytes does not match payloads");
|
|
1954
|
+
const manifestDigest = digestBytes(Buffer.from(canonicalJson(manifest), "utf8"));
|
|
1955
|
+
if (manifestDigest !== value.manifest_sha256) fail("integrity-mismatch", "transfer manifest hash mismatch");
|
|
1956
|
+
return { manifest, manifestDigest };
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
async function readStdinBounded(maxBytes) {
|
|
1960
|
+
const chunks = [];
|
|
1961
|
+
let total = 0;
|
|
1962
|
+
for await (const chunk of process.stdin) {
|
|
1963
|
+
total += chunk.length;
|
|
1964
|
+
if (total > maxBytes) fail("package-oversized", `package transfer exceeds ${maxBytes} bytes`);
|
|
1965
|
+
chunks.push(chunk);
|
|
1966
|
+
}
|
|
1967
|
+
return Buffer.concat(chunks, total);
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
async function cmdPackTransfer(args) {
|
|
1971
|
+
if (args.length !== 1) fail("usage", "pack-transfer requires <package-root>");
|
|
1972
|
+
const home = await activeHome();
|
|
1973
|
+
const sourceRoot = await validateSourceRoot(home, args[0]);
|
|
1974
|
+
const packageInfo = await validatePackage(sourceRoot, { installed: false });
|
|
1975
|
+
if (packageInfo.tree.entryCount > MAX_TRANSFER_ENTRIES || packageInfo.tree.totalBytes > MAX_TRANSFER_PACKAGE_BYTES) {
|
|
1976
|
+
fail("package-oversized", "package exceeds the remote transfer entry or byte limit");
|
|
1977
|
+
}
|
|
1978
|
+
const entries = [];
|
|
1979
|
+
const payloads = [];
|
|
1980
|
+
for (const entry of packageInfo.tree.entries) {
|
|
1981
|
+
if (entry.type === "directory") {
|
|
1982
|
+
entries.push({ path: entry.relative, type: "directory", mode: 0o755, size: 0, sha256: null });
|
|
1983
|
+
payloads.push(null);
|
|
1984
|
+
} else {
|
|
1985
|
+
if (entry.size > MAX_TRANSFER_FILE_BYTES) fail("package-oversized", `package file exceeds ${MAX_TRANSFER_FILE_BYTES} bytes: ${entry.relative}`);
|
|
1986
|
+
const bytes = await readFile(path.join(sourceRoot, entry.relative));
|
|
1987
|
+
entries.push({ path: entry.relative, type: "file", mode: entry.executable ? 0o755 : 0o644, size: bytes.length, sha256: digestBytes(bytes) });
|
|
1988
|
+
payloads.push(bytes.toString("base64"));
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
const manifest = {
|
|
1992
|
+
schema: TRANSFER_MANIFEST_SCHEMA,
|
|
1993
|
+
extension_id: packageInfo.manifest.id,
|
|
1994
|
+
extension_version: packageInfo.manifest.version,
|
|
1995
|
+
package_digest: packageInfo.tree.digest,
|
|
1996
|
+
entry_count: entries.length,
|
|
1997
|
+
total_bytes: packageInfo.tree.totalBytes,
|
|
1998
|
+
entries,
|
|
1999
|
+
};
|
|
2000
|
+
const envelope = { schema: TRANSFER_SCHEMA, manifest, manifest_sha256: digestBytes(Buffer.from(canonicalJson(manifest))), payloads };
|
|
2001
|
+
const output = Buffer.from(canonicalJson(envelope), "utf8");
|
|
2002
|
+
if (output.length > MAX_TRANSFER_JSON_BYTES) fail("package-oversized", `serialized package transfer exceeds ${MAX_TRANSFER_JSON_BYTES} bytes`);
|
|
2003
|
+
process.stdout.write(output);
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
async function transferRetiredDestination(home, manifest) {
|
|
2007
|
+
const parent = await ensureHomePrivatePath(home, ["data", "extensions", "retired-staging", manifest.extension_id, manifest.extension_version]);
|
|
2008
|
+
return path.join(parent, manifest.transfer_digest.slice("sha256:".length));
|
|
2009
|
+
}
|
|
2010
|
+
|
|
2011
|
+
async function retirePublishedTransfer(home, published, receipt) {
|
|
2012
|
+
const retired = await transferRetiredDestination(home, receipt);
|
|
2013
|
+
if (await maybeLstat(retired)) fail("transfer-exists", "this transfer identity is already retired");
|
|
2014
|
+
await rename(published, retired);
|
|
2015
|
+
return retired;
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
async function assertLifecycleLockOwned() {
|
|
2019
|
+
if (!activeLifecycleLock) fail("lifecycle-lock-invalid", "retirement has no lifecycle lock ownership");
|
|
2020
|
+
const { lockPath, ownerPath, delegatedOwnerPid } = activeLifecycleLock;
|
|
2021
|
+
const lockInfo = await maybeLstat(lockPath);
|
|
2022
|
+
if (!lockInfo?.isSymbolicLink()) fail("lifecycle-lock-lost", "retirement lifecycle lock is no longer held");
|
|
2023
|
+
const target = await readlink(lockPath).catch(() => fail("lifecycle-lock-lost", "retirement lifecycle lock cannot be read"));
|
|
2024
|
+
const resolvedTarget = path.isAbsolute(target) ? target : path.resolve(path.dirname(lockPath), target);
|
|
2025
|
+
if (resolvedTarget !== ownerPath) fail("lifecycle-lock-lost", "retirement lifecycle lock owner changed");
|
|
2026
|
+
const ownerInfo = await maybeLstat(ownerPath);
|
|
2027
|
+
if (!ownerInfo?.isDirectory() || ownerInfo.isSymbolicLink() || ownerInfo.uid !== currentUid()) {
|
|
2028
|
+
fail("lifecycle-lock-invalid", "retirement lifecycle lock owner is unsafe");
|
|
2029
|
+
}
|
|
2030
|
+
const pidPath = path.join(ownerPath, "pid");
|
|
2031
|
+
const pidInfo = await maybeLstat(pidPath);
|
|
2032
|
+
if (!pidInfo?.isFile() || pidInfo.isSymbolicLink() || pidInfo.nlink !== 1 || pidInfo.uid !== currentUid()) {
|
|
2033
|
+
fail("lifecycle-lock-invalid", "retirement lifecycle lock pid is unsafe");
|
|
2034
|
+
}
|
|
2035
|
+
const pid = (await readFile(pidPath, "utf8")).trim();
|
|
2036
|
+
if (pid !== String(delegatedOwnerPid || process.pid)) fail("lifecycle-lock-lost", "retirement process does not own the lifecycle lock");
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
async function claimInheritedLifecycleLock(home) {
|
|
2040
|
+
const mode = process.env.FM_EXTENSION_RETIREMENT_MODE;
|
|
2041
|
+
if (mode !== "binding" && mode !== "transfer" && mode !== "bind" && mode !== "process-event") fail("lifecycle-lock-invalid", "extension lifecycle mode is invalid");
|
|
2042
|
+
const stateRoot = effectiveStateRoot(home);
|
|
2043
|
+
const expectedLock = path.join(stateRoot, "procevent", ".extension-binding-lifecycle.lock");
|
|
2044
|
+
const lockPath = path.resolve(process.env.FM_EXTENSION_LIFECYCLE_LOCK || "");
|
|
2045
|
+
const ownerPath = path.resolve(process.env.FM_EXTENSION_LIFECYCLE_OWNER || "");
|
|
2046
|
+
if (lockPath !== expectedLock || path.dirname(ownerPath) !== path.dirname(lockPath)
|
|
2047
|
+
|| !path.basename(ownerPath).startsWith(`${path.basename(lockPath)}.owner.`)) {
|
|
2048
|
+
fail("lifecycle-lock-invalid", "retirement lifecycle lock identity is invalid");
|
|
2049
|
+
}
|
|
2050
|
+
const captureCapability = mode === "process-event" ? await inheritedCaptureCapability(home) : null;
|
|
2051
|
+
const delegatedOwnerPid = captureCapability?.claimPid || null;
|
|
2052
|
+
activeLifecycleLock = { lockPath, ownerPath, delegatedOwnerPid, captureCapability };
|
|
2053
|
+
await assertLifecycleLockOwned();
|
|
2054
|
+
return mode;
|
|
2055
|
+
}
|
|
2056
|
+
|
|
2057
|
+
async function releaseLifecycleLock() {
|
|
2058
|
+
await assertLifecycleLockOwned();
|
|
2059
|
+
const { lockPath, ownerPath } = activeLifecycleLock;
|
|
2060
|
+
await unlink(lockPath);
|
|
2061
|
+
await unlink(path.join(ownerPath, "pid"));
|
|
2062
|
+
await rmdir(ownerPath);
|
|
2063
|
+
activeLifecycleLock = null;
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
async function cmdReceiveTransferBind(args) {
|
|
2067
|
+
await runLifecycleBinding("receive-transfer-bind", args);
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
async function cmdReceiveTransferBindLocked(args) {
|
|
2071
|
+
const home = await activeHome();
|
|
2072
|
+
const envelope = parseStrictJson(await readStdinBounded(MAX_TRANSFER_JSON_BYTES), "package transfer", MAX_TRANSFER_JSON_BYTES);
|
|
2073
|
+
const { manifest, manifestDigest } = validateTransferEnvelope(envelope);
|
|
2074
|
+
const versionRoot = await ensureHomePrivatePath(home, ["data", "extensions", "staging", manifest.extension_id, manifest.extension_version]);
|
|
2075
|
+
const destination = path.join(versionRoot, manifestDigest.slice("sha256:".length));
|
|
2076
|
+
const receipt = { schema: TRANSFER_MANIFEST_SCHEMA, extension_id: manifest.extension_id, extension_version: manifest.extension_version, package_digest: manifest.package_digest, transfer_digest: manifestDigest };
|
|
2077
|
+
const retired = await transferRetiredDestination(home, receipt);
|
|
2078
|
+
if (await maybeLstat(destination) || await maybeLstat(retired)) fail("transfer-exists", "this transfer identity was already received");
|
|
2079
|
+
const lockPath = `${destination}.lock`;
|
|
2080
|
+
const lock = await open(lockPath, "wx", 0o600).catch((error) => {
|
|
2081
|
+
if (error?.code === "EEXIST") fail("transfer-exists", "this transfer identity is already being received");
|
|
2082
|
+
throw error;
|
|
2083
|
+
});
|
|
2084
|
+
const temporary = path.join(versionRoot, `.receive-${process.pid}-${randomBytes(8).toString("hex")}`);
|
|
2085
|
+
let published = false;
|
|
2086
|
+
try {
|
|
2087
|
+
await mkdir(path.join(temporary, "package"), { recursive: true, mode: 0o700 });
|
|
2088
|
+
for (let index = 0; index < manifest.entries.length; index += 1) {
|
|
2089
|
+
const entry = manifest.entries[index];
|
|
2090
|
+
const target = path.join(temporary, "package", ...entry.path.split("/"));
|
|
2091
|
+
if (entry.type === "directory") {
|
|
2092
|
+
await mkdir(target, { mode: 0o755 });
|
|
2093
|
+
} else {
|
|
2094
|
+
await mkdir(path.dirname(target), { recursive: true, mode: 0o755 });
|
|
2095
|
+
await writeFile(target, Buffer.from(envelope.payloads[index], "base64"), { flag: "wx", mode: entry.mode });
|
|
2096
|
+
await chmod(target, entry.mode);
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
await chmod(path.join(temporary, "package"), 0o755);
|
|
2100
|
+
const packageInfo = await validatePackage(path.join(temporary, "package"), { installed: false });
|
|
2101
|
+
if (packageInfo.tree.entries.length !== manifest.entries.length) fail("package-invalid", "received package contains an entry absent from its transfer manifest");
|
|
2102
|
+
for (let index = 0; index < manifest.entries.length; index += 1) {
|
|
2103
|
+
const declared = manifest.entries[index];
|
|
2104
|
+
const actual = packageInfo.tree.entries[index];
|
|
2105
|
+
const actualMode = actual.type === "directory" || actual.executable ? 0o755 : 0o644;
|
|
2106
|
+
if (actual.relative !== declared.path || actual.type !== declared.type || actualMode !== declared.mode
|
|
2107
|
+
|| (actual.type === "file" && (actual.size !== declared.size || actual.digest !== declared.sha256))) {
|
|
2108
|
+
fail("package-invalid", "received package tree does not exactly match its transfer manifest");
|
|
2109
|
+
}
|
|
2110
|
+
}
|
|
2111
|
+
if (packageInfo.manifest.id !== manifest.extension_id || packageInfo.manifest.version !== manifest.extension_version
|
|
2112
|
+
|| packageInfo.tree.digest !== manifest.package_digest) fail("integrity-mismatch", "received package identity does not match its transfer manifest");
|
|
2113
|
+
await writeFile(path.join(temporary, "receipt.json"), prettyJson(receipt), { flag: "wx", mode: 0o600 });
|
|
2114
|
+
await rename(temporary, destination);
|
|
2115
|
+
published = true;
|
|
2116
|
+
await cmdBindFrom([path.join(destination, "package"), ...args], path.join(destination, "package"));
|
|
2117
|
+
process.stdout.write(`transfer-digest: ${manifestDigest}\n`);
|
|
2118
|
+
process.stdout.write(`staged-package: ${path.join(destination, "package")}\n`);
|
|
2119
|
+
} catch (error) {
|
|
2120
|
+
if (published) await retirePublishedTransfer(home, destination, receipt).catch(() => {});
|
|
2121
|
+
else await rm(temporary, { recursive: true, force: true }).catch(() => {});
|
|
2122
|
+
throw error;
|
|
2123
|
+
} finally {
|
|
2124
|
+
await lock.close().catch(() => {});
|
|
2125
|
+
await unlink(lockPath).catch(() => {});
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
async function cmdRetireTransferLocked(args) {
|
|
2130
|
+
if (args.length !== 5 || args[1] !== "--if-transfer-digest" || args[3] !== "--if-binding-digest") {
|
|
2131
|
+
fail("usage", "retire-transfer requires <extension-id> --if-transfer-digest <sha256:digest> --if-binding-digest <sha256:digest>");
|
|
2132
|
+
}
|
|
2133
|
+
const extensionId = boundedString(args[0], 128, "extension id", ID_RE);
|
|
2134
|
+
const transferDigest = args[2];
|
|
2135
|
+
const bindingDigest = args[4];
|
|
2136
|
+
if (!DIGEST_RE.test(transferDigest)) fail("usage", "--if-transfer-digest must be sha256:<64 lowercase hex>");
|
|
2137
|
+
if (!DIGEST_RE.test(bindingDigest)) fail("usage", "--if-binding-digest must be sha256:<64 lowercase hex>");
|
|
2138
|
+
const home = await activeHome();
|
|
2139
|
+
const idRoot = path.join(home, "data", "extensions", "staging", extensionId);
|
|
2140
|
+
const versions = await readdir(idRoot).catch((error) => error?.code === "ENOENT" ? [] : Promise.reject(error));
|
|
2141
|
+
const matches = [];
|
|
2142
|
+
for (const version of versions) {
|
|
2143
|
+
boundedString(version, 128, "staged extension version", SEMVER_RE);
|
|
2144
|
+
const candidate = path.join(idRoot, version, transferDigest.slice("sha256:".length));
|
|
2145
|
+
if (await maybeLstat(candidate)) matches.push(candidate);
|
|
2146
|
+
}
|
|
2147
|
+
if (matches.length !== 1) fail("transfer-missing", "no unique staged package matches that extension and transfer digest");
|
|
2148
|
+
await assertOwnedSafeDirectory(matches[0], "staged transfer", true);
|
|
2149
|
+
const receiptPath = path.join(matches[0], "receipt.json");
|
|
2150
|
+
const receiptInfo = await maybeLstat(receiptPath);
|
|
2151
|
+
if (!receiptInfo || !receiptInfo.isFile() || receiptInfo.isSymbolicLink() || receiptInfo.nlink !== 1) fail("link-unsafe", "transfer receipt is not one regular file");
|
|
2152
|
+
if (receiptInfo.uid !== currentUid()) fail("owner-mismatch", "transfer receipt is not owned by the active user");
|
|
2153
|
+
if (modeOf(receiptInfo) !== 0o600) fail("mode-unsafe", "transfer receipt must have mode 0600");
|
|
2154
|
+
const receipt = parseStrictJson(await readFile(receiptPath), "transfer receipt");
|
|
2155
|
+
exactKeys(receipt, ["schema", "extension_id", "extension_version", "package_digest", "transfer_digest"], "transfer receipt");
|
|
2156
|
+
if (receipt.schema !== TRANSFER_MANIFEST_SCHEMA || receipt.extension_id !== extensionId || receipt.transfer_digest !== transferDigest
|
|
2157
|
+
|| !SEMVER_RE.test(receipt.extension_version) || !DIGEST_RE.test(receipt.package_digest)) fail("integrity-mismatch", "staged transfer receipt does not match retirement identity");
|
|
2158
|
+
if (path.basename(path.dirname(matches[0])) !== receipt.extension_version) fail("integrity-mismatch", "staged transfer version directory does not match its receipt");
|
|
2159
|
+
const stagedPackage = await validatePackage(path.join(matches[0], "package"), { installed: false });
|
|
2160
|
+
if (stagedPackage.manifest.id !== receipt.extension_id || stagedPackage.manifest.version !== receipt.extension_version
|
|
2161
|
+
|| stagedPackage.tree.digest !== receipt.package_digest) fail("integrity-mismatch", "staged package identity does not match its transfer receipt");
|
|
2162
|
+
const retired = await transferRetiredDestination(home, receipt);
|
|
2163
|
+
if (await maybeLstat(retired)) fail("transfer-exists", "this transfer identity is already retired");
|
|
2164
|
+
const retiredBinding = path.join(matches[0], "binding.json");
|
|
2165
|
+
const partialInfo = await maybeLstat(retiredBinding);
|
|
2166
|
+
const bindings = await loadBindings(home, { packages: true });
|
|
2167
|
+
const record = bindings.find((candidate) => candidate.binding.extension_id === extensionId);
|
|
2168
|
+
if (partialInfo) {
|
|
2169
|
+
if (record) fail("retirement-partial", "enabled and partial binding state coexist for this transfer identity");
|
|
2170
|
+
const partial = await loadBindingRecord(home, retiredBinding, "partial retired binding");
|
|
2171
|
+
if (partial.bindingDigest !== bindingDigest
|
|
2172
|
+
|| partial.binding.extension_id !== receipt.extension_id
|
|
2173
|
+
|| partial.binding.extension_version !== receipt.extension_version
|
|
2174
|
+
|| partial.binding.package_digest !== receipt.package_digest
|
|
2175
|
+
|| partial.binding.source.path !== path.join(matches[0], "package")) {
|
|
2176
|
+
fail("owner-mismatch", "partial binding does not match the exact transfer retirement identity");
|
|
2177
|
+
}
|
|
2178
|
+
await bindingRetirementPreflight(home, bindingDigest);
|
|
2179
|
+
await assertLifecycleLockOwned();
|
|
2180
|
+
await rename(matches[0], retired);
|
|
2181
|
+
process.stdout.write(`retired-transfer: ${extensionId} ${transferDigest}\n`);
|
|
2182
|
+
process.stdout.write(`retired-binding: ${extensionId} ${bindingDigest}\n`);
|
|
2183
|
+
process.stdout.write(`retained-at: ${retired}\n`);
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
if (!record) fail("binding-missing", `no enabled binding exists for extension: ${extensionId}`);
|
|
2187
|
+
if (record.bindingDigest !== bindingDigest) fail("owner-mismatch", "current extension binding does not match the expected binding identity");
|
|
2188
|
+
if (record.binding.extension_version !== receipt.extension_version
|
|
2189
|
+
|| record.binding.package_digest !== receipt.package_digest
|
|
2190
|
+
|| record.binding.source.path !== path.join(matches[0], "package")) {
|
|
2191
|
+
fail("owner-mismatch", "current extension binding is not owned by this staged transfer identity");
|
|
2192
|
+
}
|
|
2193
|
+
await bindingRetirementPreflight(home, bindingDigest);
|
|
2194
|
+
let bindingMoved = false;
|
|
2195
|
+
try {
|
|
2196
|
+
await assertLifecycleLockOwned();
|
|
2197
|
+
await rename(record.bindingPath, retiredBinding);
|
|
2198
|
+
bindingMoved = true;
|
|
2199
|
+
const movedBytes = await readFile(retiredBinding);
|
|
2200
|
+
if (digestBytes(movedBytes) !== bindingDigest || Buffer.compare(movedBytes, record.bytes) !== 0) {
|
|
2201
|
+
fail("owner-mismatch", "binding changed during conditional retirement");
|
|
2202
|
+
}
|
|
2203
|
+
await assertLifecycleLockOwned();
|
|
2204
|
+
await rename(matches[0], retired);
|
|
2205
|
+
bindingMoved = false;
|
|
2206
|
+
} catch (error) {
|
|
2207
|
+
if (bindingMoved) await rename(retiredBinding, record.bindingPath).catch(() => {});
|
|
2208
|
+
throw error;
|
|
2209
|
+
}
|
|
2210
|
+
process.stdout.write(`retired-transfer: ${extensionId} ${transferDigest}\n`);
|
|
2211
|
+
process.stdout.write(`retired-binding: ${extensionId} ${bindingDigest}\n`);
|
|
2212
|
+
process.stdout.write(`retained-at: ${retired}\n`);
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
async function cmdRetireBindingLocked(args) {
|
|
2216
|
+
if (args.length !== 3 || args[1] !== "--if-binding-digest") fail("usage", "retire-binding requires <extension-id> --if-binding-digest <sha256:digest>");
|
|
2217
|
+
const extensionId = boundedString(args[0], 128, "extension id", ID_RE);
|
|
2218
|
+
const bindingDigest = args[2];
|
|
2219
|
+
if (!DIGEST_RE.test(bindingDigest)) fail("usage", "--if-binding-digest must be sha256:<64 lowercase hex>");
|
|
2220
|
+
const home = await activeHome();
|
|
2221
|
+
const bindings = await loadBindings(home, { packages: true });
|
|
2222
|
+
const record = bindings.find((candidate) => candidate.binding.extension_id === extensionId);
|
|
2223
|
+
if (!record) fail("binding-missing", `no enabled binding exists for extension: ${extensionId}`);
|
|
2224
|
+
if (record.bindingDigest !== bindingDigest) fail("owner-mismatch", "current extension binding does not match the expected binding identity");
|
|
2225
|
+
const stagingRoot = path.join(home, "data", "extensions", "staging");
|
|
2226
|
+
if (isInside(stagingRoot, record.binding.source.path)) fail("retirement-incomplete", "a transferred binding must retire with its exact transfer identity");
|
|
2227
|
+
await bindingRetirementPreflight(home, bindingDigest);
|
|
2228
|
+
const parent = await ensureHomePrivatePath(home, ["data", "extensions", "retired-bindings", extensionId]);
|
|
2229
|
+
const destination = path.join(parent, `${bindingDigest.slice("sha256:".length)}.json`);
|
|
2230
|
+
if (await maybeLstat(destination)) fail("binding-exists", "this binding identity is already retired");
|
|
2231
|
+
let moved = false;
|
|
2232
|
+
try {
|
|
2233
|
+
await assertLifecycleLockOwned();
|
|
2234
|
+
await rename(record.bindingPath, destination);
|
|
2235
|
+
moved = true;
|
|
2236
|
+
const retiredBytes = await readFile(destination);
|
|
2237
|
+
if (digestBytes(retiredBytes) !== bindingDigest || Buffer.compare(retiredBytes, record.bytes) !== 0) {
|
|
2238
|
+
fail("owner-mismatch", "binding changed during conditional retirement");
|
|
2239
|
+
}
|
|
2240
|
+
moved = false;
|
|
2241
|
+
} catch (error) {
|
|
2242
|
+
if (moved) await rename(destination, record.bindingPath).catch(() => {});
|
|
2243
|
+
throw error;
|
|
2244
|
+
}
|
|
2245
|
+
process.stdout.write(`retired-binding: ${extensionId} ${bindingDigest}\n`);
|
|
2246
|
+
process.stdout.write(`retained-at: ${destination}\n`);
|
|
2247
|
+
}
|
|
2248
|
+
|
|
2249
|
+
async function runLifecycleRetirement(mode, args) {
|
|
2250
|
+
const command = path.join(CODE_ROOT, "bin", "fm-procevent.sh");
|
|
2251
|
+
const home = await activeHome();
|
|
2252
|
+
const env = { PATH: sanitizedPath(), LANG: "C", LC_ALL: "C", HOME: process.env.HOME || home, FM_HOME: home, FM_ROOT_OVERRIDE: CODE_ROOT };
|
|
2253
|
+
if (process.env.FM_STATE_OVERRIDE) env.FM_STATE_OVERRIDE = process.env.FM_STATE_OVERRIDE;
|
|
2254
|
+
if (process.env.XDG_STATE_HOME) env.XDG_STATE_HOME = process.env.XDG_STATE_HOME;
|
|
2255
|
+
if (process.env.FM_PROCEVENT_CLAIM_ROOT) env.FM_PROCEVENT_CLAIM_ROOT = process.env.FM_PROCEVENT_CLAIM_ROOT;
|
|
2256
|
+
const child = spawn(command, ["extension-retirement", mode, ...args], {
|
|
2257
|
+
cwd: CODE_ROOT,
|
|
2258
|
+
env,
|
|
2259
|
+
shell: false,
|
|
2260
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2261
|
+
});
|
|
2262
|
+
const stdout = [];
|
|
2263
|
+
const stderr = [];
|
|
2264
|
+
let stdoutBytes = 0;
|
|
2265
|
+
let stderrBytes = 0;
|
|
2266
|
+
child.stdout.on("data", (chunk) => {
|
|
2267
|
+
stdoutBytes += chunk.length;
|
|
2268
|
+
if (stdoutBytes <= MAX_JSON_BYTES) stdout.push(chunk);
|
|
2269
|
+
});
|
|
2270
|
+
child.stderr.on("data", (chunk) => {
|
|
2271
|
+
stderrBytes += chunk.length;
|
|
2272
|
+
if (stderrBytes <= MAX_STDERR_BYTES) stderr.push(chunk);
|
|
2273
|
+
});
|
|
2274
|
+
const outcome = await new Promise((resolve, reject) => {
|
|
2275
|
+
child.once("error", reject);
|
|
2276
|
+
child.once("close", (code, signal) => resolve({ code, signal }));
|
|
2277
|
+
}).catch(() => fail("retirement-failed", "extension lifecycle retirement could not start"));
|
|
2278
|
+
if (stdoutBytes > MAX_JSON_BYTES || stderrBytes > MAX_STDERR_BYTES || outcome.code !== 0 || outcome.signal) {
|
|
2279
|
+
const diagnostic = Buffer.concat(stderr).toString("utf8").trim();
|
|
2280
|
+
fail("retirement-failed", diagnostic || "extension lifecycle retirement failed");
|
|
2281
|
+
}
|
|
2282
|
+
process.stdout.write(Buffer.concat(stdout));
|
|
2283
|
+
}
|
|
2284
|
+
|
|
2285
|
+
async function runLifecycleProcessEvent(args) {
|
|
2286
|
+
const command = path.join(CODE_ROOT, "bin", "fm-procevent.sh");
|
|
2287
|
+
const home = await activeHome();
|
|
2288
|
+
const env = { PATH: sanitizedPath(), LANG: "C", LC_ALL: "C", HOME: process.env.HOME || home, FM_HOME: home, FM_ROOT_OVERRIDE: CODE_ROOT };
|
|
2289
|
+
if (process.env.FM_STATE_OVERRIDE) env.FM_STATE_OVERRIDE = process.env.FM_STATE_OVERRIDE;
|
|
2290
|
+
if (process.env.XDG_STATE_HOME) env.XDG_STATE_HOME = process.env.XDG_STATE_HOME;
|
|
2291
|
+
if (process.env.FM_PROCEVENT_CAPTURE_SOURCE_LOCK_HELD === "1") env.FM_PROCEVENT_CAPTURE_SOURCE_LOCK_HELD = "1";
|
|
2292
|
+
const child = spawn(command, ["extension-process-event", ...args], {
|
|
2293
|
+
cwd: CODE_ROOT,
|
|
2294
|
+
env,
|
|
2295
|
+
shell: false,
|
|
2296
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2297
|
+
});
|
|
2298
|
+
const stdout = [];
|
|
2299
|
+
const stderr = [];
|
|
2300
|
+
let stdoutBytes = 0;
|
|
2301
|
+
let stderrBytes = 0;
|
|
2302
|
+
child.stdout.on("data", (chunk) => {
|
|
2303
|
+
stdoutBytes += chunk.length;
|
|
2304
|
+
if (stdoutBytes <= MAX_JSON_BYTES) stdout.push(chunk);
|
|
2305
|
+
});
|
|
2306
|
+
child.stderr.on("data", (chunk) => {
|
|
2307
|
+
stderrBytes += chunk.length;
|
|
2308
|
+
if (stderrBytes <= MAX_STDERR_BYTES) stderr.push(chunk);
|
|
2309
|
+
});
|
|
2310
|
+
const outcome = await new Promise((resolve, reject) => {
|
|
2311
|
+
child.once("error", reject);
|
|
2312
|
+
child.once("close", (code, signal) => resolve({ code, signal }));
|
|
2313
|
+
}).catch(() => fail("process-event-failed", "extension lifecycle process-event could not start"));
|
|
2314
|
+
if (stdoutBytes > MAX_JSON_BYTES || stderrBytes > MAX_STDERR_BYTES || outcome.signal) {
|
|
2315
|
+
const diagnostic = Buffer.concat(stderr).toString("utf8").trim();
|
|
2316
|
+
fail("process-event-failed", diagnostic || "extension lifecycle process-event failed");
|
|
2317
|
+
}
|
|
2318
|
+
process.stdout.write(Buffer.concat(stdout));
|
|
2319
|
+
if (outcome.code !== 0) process.stderr.write(Buffer.concat(stderr));
|
|
2320
|
+
process.exitCode = outcome.code || 0;
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
async function runLifecycleBinding(commandName, args) {
|
|
2324
|
+
const command = path.join(CODE_ROOT, "bin", "fm-procevent.sh");
|
|
2325
|
+
const home = await activeHome();
|
|
2326
|
+
const env = { PATH: sanitizedPath(), LANG: "C", LC_ALL: "C", HOME: process.env.HOME || home, FM_HOME: home, FM_ROOT_OVERRIDE: CODE_ROOT };
|
|
2327
|
+
if (process.env.FM_STATE_OVERRIDE) env.FM_STATE_OVERRIDE = process.env.FM_STATE_OVERRIDE;
|
|
2328
|
+
if (process.env.XDG_STATE_HOME) env.XDG_STATE_HOME = process.env.XDG_STATE_HOME;
|
|
2329
|
+
if (process.env.FM_PROCEVENT_CLAIM_ROOT) env.FM_PROCEVENT_CLAIM_ROOT = process.env.FM_PROCEVENT_CLAIM_ROOT;
|
|
2330
|
+
const child = spawn(command, ["extension-bind", commandName, ...args], {
|
|
2331
|
+
cwd: CODE_ROOT,
|
|
2332
|
+
env,
|
|
2333
|
+
shell: false,
|
|
2334
|
+
stdio: [commandName === "receive-transfer-bind" ? "pipe" : "ignore", "pipe", "pipe"],
|
|
2335
|
+
});
|
|
2336
|
+
if (commandName === "receive-transfer-bind") process.stdin.pipe(child.stdin);
|
|
2337
|
+
const stdout = [];
|
|
2338
|
+
const stderr = [];
|
|
2339
|
+
let stdoutBytes = 0;
|
|
2340
|
+
let stderrBytes = 0;
|
|
2341
|
+
child.stdout.on("data", (chunk) => {
|
|
2342
|
+
stdoutBytes += chunk.length;
|
|
2343
|
+
if (stdoutBytes <= MAX_JSON_BYTES) stdout.push(chunk);
|
|
2344
|
+
});
|
|
2345
|
+
child.stderr.on("data", (chunk) => {
|
|
2346
|
+
stderrBytes += chunk.length;
|
|
2347
|
+
if (stderrBytes <= MAX_STDERR_BYTES) stderr.push(chunk);
|
|
2348
|
+
});
|
|
2349
|
+
const outcome = await new Promise((resolve, reject) => {
|
|
2350
|
+
child.once("error", reject);
|
|
2351
|
+
child.once("close", (code, signal) => resolve({ code, signal }));
|
|
2352
|
+
}).catch(() => fail("binding-failed", "extension lifecycle binding could not start"));
|
|
2353
|
+
if (stdoutBytes > MAX_JSON_BYTES || stderrBytes > MAX_STDERR_BYTES || outcome.code !== 0 || outcome.signal) {
|
|
2354
|
+
const diagnostic = Buffer.concat(stderr).toString("utf8").trim();
|
|
2355
|
+
fail("binding-failed", diagnostic || "extension lifecycle binding failed");
|
|
2356
|
+
}
|
|
2357
|
+
process.stdout.write(Buffer.concat(stdout));
|
|
2358
|
+
}
|
|
2359
|
+
|
|
2360
|
+
async function cmdRetireBinding(args) {
|
|
2361
|
+
await runLifecycleRetirement("binding", args);
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
async function cmdRetireTransfer(args) {
|
|
2365
|
+
await runLifecycleRetirement("transfer", args);
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
async function runInheritedLifecycleRetirement(args) {
|
|
2369
|
+
const home = await activeHome();
|
|
2370
|
+
const mode = await claimInheritedLifecycleLock(home);
|
|
2371
|
+
try {
|
|
2372
|
+
if (mode === "process-event") {
|
|
2373
|
+
const [command, ...commandArgs] = args;
|
|
2374
|
+
if (command !== "process-event") fail("lifecycle-lock-invalid", "extension lifecycle process-event command is invalid");
|
|
2375
|
+
await cmdProcessEventLocked(commandArgs);
|
|
2376
|
+
} else if (mode === "binding") await cmdRetireBindingLocked(args);
|
|
2377
|
+
else if (mode === "transfer") await cmdRetireTransferLocked(args);
|
|
2378
|
+
else {
|
|
2379
|
+
const [command, ...commandArgs] = args;
|
|
2380
|
+
if (command === "bind") await cmdBindFrom(commandArgs, null);
|
|
2381
|
+
else if (command === "receive-transfer-bind") await cmdReceiveTransferBindLocked(commandArgs);
|
|
2382
|
+
else fail("lifecycle-lock-invalid", "extension lifecycle binding command is invalid");
|
|
2383
|
+
}
|
|
2384
|
+
} finally {
|
|
2385
|
+
await releaseLifecycleLock();
|
|
2386
|
+
}
|
|
2387
|
+
}
|
|
2388
|
+
|
|
2389
|
+
async function bindingRetirementPreflight(home, bindingDigest) {
|
|
2390
|
+
await cleanupRecordedInvocations(home, { bindingDigest });
|
|
2391
|
+
const command = path.join(CODE_ROOT, "bin", "fm-procevent.sh");
|
|
2392
|
+
const env = { PATH: sanitizedPath(), LANG: "C", LC_ALL: "C", HOME: process.env.HOME || home, FM_HOME: home, FM_ROOT_OVERRIDE: CODE_ROOT };
|
|
2393
|
+
if (process.env.FM_STATE_OVERRIDE) env.FM_STATE_OVERRIDE = process.env.FM_STATE_OVERRIDE;
|
|
2394
|
+
if (process.env.XDG_STATE_HOME) env.XDG_STATE_HOME = process.env.XDG_STATE_HOME;
|
|
2395
|
+
if (process.env.FM_PROCEVENT_CLAIM_ROOT) env.FM_PROCEVENT_CLAIM_ROOT = process.env.FM_PROCEVENT_CLAIM_ROOT;
|
|
2396
|
+
const child = spawn(command, ["binding-retirement-preflight", bindingDigest], {
|
|
2397
|
+
cwd: CODE_ROOT,
|
|
2398
|
+
env,
|
|
2399
|
+
shell: false,
|
|
2400
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
2401
|
+
});
|
|
2402
|
+
const stderr = [];
|
|
2403
|
+
let stderrBytes = 0;
|
|
2404
|
+
child.stderr.on("data", (chunk) => {
|
|
2405
|
+
stderrBytes += chunk.length;
|
|
2406
|
+
if (stderrBytes <= MAX_STDERR_BYTES) stderr.push(chunk);
|
|
2407
|
+
});
|
|
2408
|
+
const outcome = await new Promise((resolve, reject) => {
|
|
2409
|
+
child.once("error", reject);
|
|
2410
|
+
child.once("close", (code, signal) => resolve({ code, signal }));
|
|
2411
|
+
}).catch(() => fail("retirement-preflight-failed", "process-event retirement preflight could not start"));
|
|
2412
|
+
if (stderrBytes > MAX_STDERR_BYTES || outcome.code !== 0 || outcome.signal) {
|
|
2413
|
+
const diagnostic = Buffer.concat(stderr).toString("utf8").trim();
|
|
2414
|
+
fail("binding-in-use", diagnostic || "binding retirement process-event preflight refused");
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
async function cmdCleanupInvocations(args) {
|
|
2419
|
+
let sourceId = null;
|
|
2420
|
+
let bindingDigest = null;
|
|
2421
|
+
if (args.length !== 0) {
|
|
2422
|
+
if (args.length !== 2) fail("usage", "cleanup-invocations accepts one optional identity selector");
|
|
2423
|
+
if (args[0] === "--source-id") sourceId = boundedString(args[1], 64, "source id", /^[A-Za-z0-9._-]+$/u);
|
|
2424
|
+
else if (args[0] === "--binding-digest" && DIGEST_RE.test(args[1])) bindingDigest = args[1];
|
|
2425
|
+
else fail("usage", "cleanup-invocations requires --source-id <id> or --binding-digest <sha256:digest>");
|
|
2426
|
+
}
|
|
2427
|
+
const home = await activeHome();
|
|
2428
|
+
const cleaned = await cleanupRecordedInvocations(home, { sourceId, bindingDigest });
|
|
2429
|
+
process.stdout.write(`cleaned-invocations: ${cleaned}\n`);
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
async function cmdList(args) {
|
|
2433
|
+
if (args.length) fail("usage", "list takes no arguments");
|
|
2434
|
+
const home = await activeHome();
|
|
2435
|
+
const bindings = await loadBindings(home, { packages: false });
|
|
2436
|
+
if (bindings.length === 0) {
|
|
2437
|
+
process.stdout.write("no extension bindings\n");
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2440
|
+
process.stdout.write("EXTENSION VERSION CAPABILITY ADAPTERS PACKAGE_DIGEST\n");
|
|
2441
|
+
for (const record of bindings) {
|
|
2442
|
+
const binding = record.binding;
|
|
2443
|
+
process.stdout.write(`${binding.extension_id} ${binding.extension_version} process-event-adapter/1 ${binding.capabilities[0].adapter_names.join(",")} ${binding.package_digest}\n`);
|
|
2444
|
+
}
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
async function cmdInspect(args) {
|
|
2448
|
+
if (args.length !== 1) fail("usage", "inspect requires <extension-id>");
|
|
2449
|
+
const id = boundedString(args[0], 128, "extension id", ID_RE);
|
|
2450
|
+
const home = await activeHome();
|
|
2451
|
+
const bindings = await loadBindings(home, { packages: true });
|
|
2452
|
+
const record = bindings.find((candidate) => candidate.binding.extension_id === id);
|
|
2453
|
+
if (!record) fail("binding-missing", `no binding exists for extension: ${id}`);
|
|
2454
|
+
process.stdout.write(prettyJson(record.binding));
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
async function cmdVerify(args) {
|
|
2458
|
+
if (args.length > 1) fail("usage", "verify accepts at most one extension id");
|
|
2459
|
+
const wanted = args[0] ? boundedString(args[0], 128, "extension id", ID_RE) : "";
|
|
2460
|
+
const home = await activeHome();
|
|
2461
|
+
let bindings = await loadBindings(home, { packages: true });
|
|
2462
|
+
if (wanted) bindings = bindings.filter((record) => record.binding.extension_id === wanted);
|
|
2463
|
+
if (bindings.length === 0) {
|
|
2464
|
+
if (wanted) fail("binding-missing", `no binding exists for extension: ${wanted}`);
|
|
2465
|
+
process.stdout.write("no extension bindings\n");
|
|
2466
|
+
return;
|
|
2467
|
+
}
|
|
2468
|
+
for (const record of bindings) {
|
|
2469
|
+
const statePath = await ensureExtensionState(home, record.binding);
|
|
2470
|
+
await handshake(home, record, statePath);
|
|
2471
|
+
process.stdout.write(`verified: ${record.binding.extension_id}@${record.binding.extension_version} ${record.binding.package_digest}\n`);
|
|
2472
|
+
}
|
|
2473
|
+
}
|
|
2474
|
+
|
|
2475
|
+
async function cmdResolveProcessEvent(args) {
|
|
2476
|
+
if (args.length !== 1) fail("usage", "resolve-process-event requires <adapter>");
|
|
2477
|
+
const adapter = boundedString(args[0], 32, "adapter", ADAPTER_RE);
|
|
2478
|
+
const home = await activeHome();
|
|
2479
|
+
const bindings = await loadBindings(home, { packages: true });
|
|
2480
|
+
const record = selectAdapter(bindings, adapter);
|
|
2481
|
+
const statePath = await ensureExtensionState(home, record.binding);
|
|
2482
|
+
await handshake(home, record, statePath);
|
|
2483
|
+
const fields = [
|
|
2484
|
+
RESOLUTION_SCHEMA,
|
|
2485
|
+
record.binding.extension_id,
|
|
2486
|
+
record.binding.extension_version,
|
|
2487
|
+
"1",
|
|
2488
|
+
record.binding.package_digest,
|
|
2489
|
+
record.bindingDigest,
|
|
2490
|
+
];
|
|
2491
|
+
process.stdout.write(`${fields.join("\t")}\n`);
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
async function cmdProcessEventLocked(args) {
|
|
2495
|
+
if (args.length < 2) fail("usage", "process-event requires <adapter> <operation>");
|
|
2496
|
+
const [adapter, operation, ...optionArgs] = args;
|
|
2497
|
+
const options = parseExpectedOptions(optionArgs);
|
|
2498
|
+
const home = await activeHome();
|
|
2499
|
+
let extensionId = options["--expect-extension"] || "unknown";
|
|
2500
|
+
try {
|
|
2501
|
+
const result = await invokeProcessEvent(home, adapter, operation, options);
|
|
2502
|
+
if (operation === "source.poll") {
|
|
2503
|
+
if (result.status === "no-result") process.exitCode = 75;
|
|
2504
|
+
else process.stdout.write(result.output);
|
|
2505
|
+
} else if (operation === "result.classify") {
|
|
2506
|
+
process.stdout.write(`${result.classification}\n`);
|
|
2507
|
+
} else {
|
|
2508
|
+
process.exitCode = result.value ? 0 : 1;
|
|
2509
|
+
}
|
|
2510
|
+
} catch (error) {
|
|
2511
|
+
if (operation === "source.poll") {
|
|
2512
|
+
process.stdout.write(errorEvidence(error, extensionId, operation));
|
|
2513
|
+
process.exitCode = 70;
|
|
2514
|
+
return;
|
|
2515
|
+
}
|
|
2516
|
+
throw error;
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
async function cmdProcessEvent(args) {
|
|
2521
|
+
if (process.env.FM_EXTENSION_RETIREMENT_MODE === "process-event") {
|
|
2522
|
+
await cmdProcessEventLocked(args);
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
await runLifecycleProcessEvent(args);
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
function usage() {
|
|
2529
|
+
process.stderr.write(`Trusted external Firstmate extension binding host.
|
|
2530
|
+
|
|
2531
|
+
Usage:
|
|
2532
|
+
bin/fm-extension.mjs bind <package-root> --adapter <name> [--adapter <name> ...] --trust-same-user-code [--consent <fact> ...] [--timeout-ms <milliseconds>]
|
|
2533
|
+
bin/fm-extension.sh remote-bind <secondmate-id> <package-root> --adapter <name> --trust-same-user-code [bind options]
|
|
2534
|
+
bin/fm-extension.mjs retire-binding <extension-id> --if-binding-digest <sha256:digest>
|
|
2535
|
+
bin/fm-extension.mjs retire-transfer <extension-id> --if-transfer-digest <sha256:digest> --if-binding-digest <sha256:digest>
|
|
2536
|
+
bin/fm-extension.mjs list
|
|
2537
|
+
bin/fm-extension.mjs inspect <extension-id>
|
|
2538
|
+
bin/fm-extension.mjs verify [extension-id]
|
|
2539
|
+
|
|
2540
|
+
The manifest file is firstmate-extension.json. Supported consent facts are network, credential-store, task-metadata, and artifact-references. The host supports only process-event-adapter/1; see docs/extension-bindings.md for its manifest, binding, handshake, and invocation contracts.
|
|
2541
|
+
`);
|
|
2542
|
+
process.exitCode = 2;
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
async function main() {
|
|
2546
|
+
if (process.env.FM_EXTENSION_RETIREMENT_MODE) {
|
|
2547
|
+
await runInheritedLifecycleRetirement(process.argv.slice(2));
|
|
2548
|
+
return;
|
|
2549
|
+
}
|
|
2550
|
+
const [command, ...args] = process.argv.slice(2);
|
|
2551
|
+
switch (command) {
|
|
2552
|
+
case "bind": await cmdBind(args); break;
|
|
2553
|
+
case "pack-transfer": await cmdPackTransfer(args); break;
|
|
2554
|
+
case "receive-transfer-bind": await cmdReceiveTransferBind(args); break;
|
|
2555
|
+
case "retire-binding": await cmdRetireBinding(args); break;
|
|
2556
|
+
case "retire-transfer": await cmdRetireTransfer(args); break;
|
|
2557
|
+
case "list": await cmdList(args); break;
|
|
2558
|
+
case "inspect": await cmdInspect(args); break;
|
|
2559
|
+
case "verify": await cmdVerify(args); break;
|
|
2560
|
+
case "resolve-process-event": await cmdResolveProcessEvent(args); break;
|
|
2561
|
+
case "process-event": await cmdProcessEvent(args); break;
|
|
2562
|
+
case "cleanup-invocations": await cmdCleanupInvocations(args); break;
|
|
2563
|
+
case "":
|
|
2564
|
+
case undefined:
|
|
2565
|
+
case "help":
|
|
2566
|
+
case "-h":
|
|
2567
|
+
case "--help": usage(); break;
|
|
2568
|
+
default: fail("usage", `unknown command: ${command}`);
|
|
2569
|
+
}
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
main().catch((error) => {
|
|
2573
|
+
const code = error instanceof HostError ? error.code : "internal";
|
|
2574
|
+
const message = error instanceof Error ? error.message : "unexpected extension host failure";
|
|
2575
|
+
process.stderr.write(`error[${code}]: ${message}\n`);
|
|
2576
|
+
process.exitCode = 1;
|
|
2577
|
+
});
|