diff --git a/docs/BABYSITTER-CATALOG-HANDOFF.md b/docs/BABYSITTER-CATALOG-HANDOFF.md index d074e7691..ad1381c8c 100644 --- a/docs/BABYSITTER-CATALOG-HANDOFF.md +++ b/docs/BABYSITTER-CATALOG-HANDOFF.md @@ -19,11 +19,75 @@ exact live `babysit` label and the bound session/head. Permission declarations are not enforcement. Export success is byte verification, not execution approval. The native package source is `extensions/babysitter` (see its README for the -turn contract). It is unreleased and cannot execute: #549 still refuses it, -the SDK context has no `capabilities.cloud.babysitterTurn`, and its -`compat` requires a surface release after 2.0.25 that routes `labeled`, -`unlabeled`, and `ready_for_review`. Export it only from a reviewed, merged -commit. +turn contract). Flows 2.0.26 is published, but the package cannot execute +through the generic executor: #549 still refuses it. The SDK now has a separate Linux-only +capability sandbox that injects exactly +`capabilities.cloud.babysitterTurn.queue` without exposing the base context, +workspace, environment credentials, network, helpers, MCP, or harnesses. It is +not wired to hosted dispatch and must not be treated as enablement. The package's +`compat` requires the published 2.0.26 Surface/SDK release that routes +`labeled`, `unlabeled`, and `ready_for_review`. Export it only from the reviewed +release commit pinned below. The Software Factory flow's own independently +versioned header remains `2.0.22`; a regression requires the hosted identity to +equal the identity obtained from that exact reviewed source. + +The sandbox contract is deliberately narrower than #442. It re-verifies the +complete lock-backed installation and every manifest, binds it to the exact +reviewed Software Factory source in the same `loadHostedExtensionRuntime` generation, detects +cross-extension route ambiguity, accepts only the exact published Babysitter +ref/digest/manifest and native permission profile, imports the entry only +inside Linux bubblewrap plus Node's +permission model under inherited 16 GiB address-space and 3 GiB +data/anonymous-memory hard limits, mounts a +minimal trusted Surface facade (`flow`, `github`, +and `getFlowDefinition`) from six integrity-pinned private runtime files instead +of the general helper runtime, checks +normalized input against non-serializable verified dispatch authority, and +permits one queue call. The parent capability adapter +receives that original authority plus immutable extension provenance; the +capability request never carries workspace, activation, listener, session, +lineage, label, head, prompt, merge, route, or config authority. Cloud PR #3942 owns the +lineage/authority core and must inject workspace, activation, and listener from +persisted dispatch context, re-read live PR/label/head state, and return only +`{ receiptId, status: 'queued' | 'duplicate' }`. Refusal or in-doubt transport +rejects once with no fallback. + +Only the parent validator is a security boundary. The isolated entry can write +its inherited protocol descriptor directly and bypass child-side routing, +context, call-count, and completion checks. Parent validation therefore treats +every frame as hostile, permits at most the one exact delivery already bound to +the branded dispatch, and waits for the adapter's authoritative outcome before +settling any premature child terminal frame. An authoritative adapter rejection +settles immediately with its original typed error even if the child hangs. + +Before replacing #549's refusal, the hosted caller must obtain an opaque base +and installation as one generation with `loadHostedExtensionRuntime`, then call +`runHostedCapabilityExtension` with both values. Every dispatch rechecks the +current extension declarations and complete project source tree against that +generation. Directory entries are streamed beneath a shared entry bound; +nonblocking no-follow descriptors and explicitly bounded reads enforce the +cumulative-byte limit before source contents are buffered. The loader never imports +tenant base code to derive authority. It +requires the exact reviewed Software Factory flow-file SHA-256 and assigns its +pinned name/version in the parent; project `node_modules`, relative imports, +stdout, process termination, globals, and module caches therefore cannot forge +that identity. The sandbox separately requires exact SHA-256 pins for every +Surface runtime file it needs, copies those bytes into its private runtime, and +mounts only the copies. A final generation check runs after both private +snapshots exist and immediately before launch; +using the ordinary compose loader would import extension top-level JavaScript +in the host before the sandbox exists. Cross-project, cross-redeploy, stale, +and structural pairings fail before import. The selected store bytes are copied into a +private snapshot whose digest is recomputed before bubblewrap mounts it, so a +later live-store replacement cannot alter imported code. Independent review +must prove this path at the exact release head. Broader per-agent-step +file/network/access-preset enforcement remains open in #442 and is not claimed +by this slice. + +The reviewed native source ref is +`github:AgentWorkforce/flows@8b33ebab8347514f80d9da5a81206a087f641714#extensions/babysitter`, +the commit included in the published 2.0.26 install. The earlier byte-identical +pre-release commit is not accepted as authority. ## Export reviewed bytes diff --git a/docs/CLOUD.md b/docs/CLOUD.md index eec520930..8b96554af 100644 --- a/docs/CLOUD.md +++ b/docs/CLOUD.md @@ -389,8 +389,26 @@ handler keep the base flow's default body; overlapping or malformed matches fail closed. This release also refuses a matching handler with `plugin_unsupported` before either body starts because schema-2 entries are ordinary JavaScript and their manifest permissions are not yet isolated by the -runtime (gate 8 / #442). A handler's trigger is therefore a requirement, but -the handler cannot execute until that boundary exists. The deploy body carries +runtime (gate 8 / #442). The SDK now contains a Linux-only, capability-only +isolation primitive for the native Babysitter profile. Hosted callers must load +the base and complete installation together with +`loadHostedExtensionRuntime` (which internally sets `extensions: 'none'` and +does not import extension JavaScript), then pass its two opaque, +same-generation results to `runHostedCapabilityExtension`. Every dispatch +rechecks the current declarations and complete project source against that +generation. The hosted loader never executes tenant base code to establish +authority: it hashes a private snapshot and accepts only the exact reviewed, +host-pinned Software Factory source and identity. The runner then verifies the +complete lock-backed set, actual base compatibility, route uniqueness, and +the exact reviewed Babysitter ref/digest/manifest before the artifact is imported inside a +bubblewrap mount/PID/network/user namespace with an empty credential +environment and a context exposing only +`capabilities.cloud.babysitterTurn.queue` plus `done`. This is a prerequisite, +not enablement: the generic executor refusal remains until the Cloud adapter, +Relay route, end-to-end canary, and independent security review are complete. +A handler's +trigger is therefore a requirement, but the handler cannot execute through the +generic executor yet. The deploy body carries the same list as `requirements` for Cloud to cross-check, and a declared harness Cloud cannot run yet (`gemini`) refuses the deploy unless `--agents` overrides it. diff --git a/evidence/babysitter-gate8-isolation/README.md b/evidence/babysitter-gate8-isolation/README.md new file mode 100644 index 000000000..d0e6b777d --- /dev/null +++ b/evidence/babysitter-gate8-isolation/README.md @@ -0,0 +1,74 @@ +# Native Babysitter gate-8 isolation evidence + +This slice adds a Linux-only, capability-only execution primitive for the +immutable native Babysitter extension. It does not wire hosted dispatch, change +the generic executor refusal introduced by #549, publish a package, or enable a +deployment. + +The enforced boundary is: + +- require the exact reviewed Software Factory base source hash without + importing tenant base code, and resolve the complete + installation as one opaque generation without importing extension + JavaScript; cross-project and same-path cross-redeploy pairing refuse, and + every dispatch rechecks the current declarations plus complete project + source; base stdout, globals, process termination, relative/package imports, + and an earlier module cache cannot forge the parent-pinned identity; source + directory entries are streamed beneath a shared entry bound; `flows.json` + and `flows.lock.json` plus source files use nonblocking no-follow descriptors + and explicitly bounded reads before contents are buffered or parsed; +- reverify every content-addressed artifact, lock metadata, manifest hash, + base/runtime compatibility, and route uniqueness using bounded descriptor + reads that enforce the fetched plugin's 500-file, 256-KB-per-file, and 2-MB + total limits before allocation, then require the exact + immutable native ref/digest/manifest and narrow permission profile; copy the + selected bytes into a private snapshot, recompute its digest, and mount only + that snapshot so later store replacement cannot change executed code; +- clone the delivery descriptor into behavior-free, frozen JSON with captured + parent intrinsics before validation or serialization, rejecting proxies, + accessors, inherited `toJSON`, cycles, holes, and extra JSON-visible fields + while stripping symbol and non-enumerable metadata; enforce depth, + property/node-count, and encoded-byte limits incrementally while traversing + (including repeatedly shared subtrees), count string escaping without + materializing an unbounded encoded copy, and enumerate JSON-visible keys + without first allocating an attacker-sized key array or using ambient array + methods; then validate that snapshot against the + symbol-branded verified dispatch before import; +- import and execute the matching handler only inside a bubblewrap namespace + plus Node's permission model, with no network, writable filesystem, inherited + environment, child process, workspace mount, MCP, helpers, harnesses, or base + flow context; a generated facade mounts only `flow`, `github`, + `getFlowDefinition`, and private copies of six SHA-256-pinned Surface runtime + files, followed by one final runtime-generation check before launch; an + inherited hard limits of 16 GiB address space and 3 GiB data/anonymous + memory cover heap, Buffer/native memory, mappings, and descendants in + addition to the 64 MiB V8 old-space setting; the wider address-space ceiling + admits Node's virtual V8/Wasm reservations while the tighter data limit + refuses two hostile 2 GiB Buffers; +- expose one `capabilities.cloud.babysitterTurn.queue({ delivery })` call and + `done`, validate the exact request and `{ receiptId, status }` response in the + parent, and pass the original non-serializable authority to the host adapter; +- treat descriptor 3 as hostile transport: clone and validate every frame and + both boundary payloads with captured parent intrinsics, so direct writes can + consume only the same exact one-shot delivery capability already granted and + cannot claim premature success; +- fail closed on unknown frames, repeated/omitted calls, premature completion, + timeouts, adapter rejection, incompatible bytes, route ambiguity, forged + loader results, an unverified/composed base, and broader permissions. + +The parent validations are the security boundary. Child-side handler routing, +the context proxy, its once-only counter, and `done('success')` are correctness +checks only: hostile code may bypass all of them by writing descriptor 3. The +parent therefore enforces the exact delivery, one-call limit, terminal ordering, +and authoritative adapter outcome independently, including when a child emits +an error immediately after its request or hangs after a typed adapter rejection. + +Cloud PR #3942 remains responsible for persisted dispatch context, live PR and +label/head revalidation, authorized existing-session resolution, lineage-based +deduplication, and Relay native-turn delivery. The broader per-step permission +enforcement requested by #442 is not implemented here and remains a separate +gate. The committed extension declares SDK/Surface `^2.0.26`; an injected +2.0.25 runtime is refused before import, while the exact published 2.0.26 bytes +are exercised inside the sandbox. + +See `verification.txt` for the final local verification record. diff --git a/evidence/babysitter-gate8-isolation/verification.txt b/evidence/babysitter-gate8-isolation/verification.txt new file mode 100644 index 000000000..2a4d2edd6 --- /dev/null +++ b/evidence/babysitter-gate8-isolation/verification.txt @@ -0,0 +1,1914 @@ +Native Babysitter gate-8 isolation — literal local verification +Host: Linux sf-frame +Date: 2026-09-22 + +$ /usr/bin/bwrap --version && /usr/bin/prlimit --version | head -1 && node --version && git diff --check +bubblewrap 0.12.0 +prlimit from util-linux 2.42.3 +v26.8.1 + +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ cd packages/sdk && npm run typecheck && npm run build && npm run typecheck:tests && ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/plugin-store-bounds.test.ts (3 tests) 15ms + ✓ tests/hosted-base-snapshot.test.ts (8 tests) 650ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 627ms + ✓ tests/hosted-extension-routing.test.ts (2 tests) 4ms + ✓ tests/preflight.test.ts (59 tests) 107ms + ✓ tests/babysitter-native-extension.test.ts (38 tests) 431ms + ✓ tests/authored-flow.test.ts (34 tests) 762ms + ✓ tests/hosted-extension-protocol.test.ts (16 tests) 627ms + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 811ms + + Test Files 8 passed (8) + Tests 173 passed (173) + Start at 10:01:55 + Duration 2.08s (transform 1.41s, setup 0ms, collect 6.28s, tests 3.41s, environment 1ms, prepare 418ms) + +$ mise exec node@22 -- node --version +v22.23.2 +$ mise exec node@22 -- ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 788ms + + Test Files 1 passed (1) + Tests 13 passed (13) + Start at 08:41:32 + Duration 2.38s (transform 879ms, setup 0ms, collect 1.30s, tests 788ms, environment 0ms, prepare 103ms) + +$ mise exec node@23 -- node --version +v23.11.1 +$ mise exec node@23 -- ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 953ms + + Test Files 1 passed (1) + Tests 13 passed (13) + Start at 08:41:32 + Duration 2.56s (transform 661ms, setup 0ms, collect 1.16s, tests 953ms, environment 0ms, prepare 121ms) + +$ mise exec node@24 -- node --version +v24.21.0 +$ mise exec node@24 -- ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 694ms + + Test Files 1 passed (1) + Tests 13 passed (13) + Start at 08:41:32 + Duration 2.36s (transform 762ms, setup 0ms, collect 1.27s, tests 694ms, environment 0ms, prepare 76ms) + +$ node --version +v26.8.1 +$ ./node_modules/.bin/vitest run tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 632ms + + Test Files 1 passed (1) + Tests 13 passed (13) + Start at 08:41:35 + Duration 1.91s (transform 728ms, setup 0ms, collect 1.12s, tests 632ms, environment 0ms, prepare 59ms) + +$ cd packages/surface && npm run typecheck && npm run typecheck:regressions && npm test && sha256sum dist/flow.js dist/helpers/providers.js dist/provider-trigger.js dist/schedule.js dist/triggers.js dist/triggers/github.js + +> @relayflows/surface@2.0.26 typecheck +> tsc --noEmit + +> @relayflows/surface@2.0.26 typecheck:regressions +> tsc -p ../../regressions/tsconfig.json && tsc -p tsconfig.test.json && node scripts/check-generated-helpers.mjs + +HELPERS_GENERATED_OK airtable.ts, asana.ts, azure-blob.ts, box.ts, calendly.ts, clickup.ts, clients.ts, cloudflare.ts, confluence.ts, daytona.ts, docker-hub.ts, dropbox.ts, fathom.ts, gcp.ts, gcs.ts, github.ts, gitlab.ts, gmail.ts, google-calendar.ts, google-drive.ts, granola.ts, hubspot.ts, index.ts, intercom.ts, jira.ts, linear.ts, mailgun.ts, mixpanel.ts, neon.ts, notion.ts, onedrive.ts, pipedrive.ts, postgres.ts, posthog.ts, providers.ts, ramp.ts, recall.ts, reddit.ts, redis.ts, s3.ts, salesforce.ts, segment.ts, sendgrid.ts, sharepoint.ts, shopify.ts, shortcut.ts, slack.ts, stripe.ts, teams.ts, telegram.ts, webhook-server.ts, x.ts, zendesk.ts + +> @relayflows/surface@2.0.26 test +> bun run build && tsc -p tsconfig.test.json && vitest run + +$ tsc + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/surface + + ✓ tests/declined.test.ts (1 test) 2ms + ✓ tests/triggers-github-events.test.ts (2 tests) 3ms + ✓ tests/helpers.snapshot.test.ts (1 test) 168ms + ✓ tests/triggers-all-providers.test.ts (4 tests) 41ms + ✓ tests/slack-block-kit.test.ts (5 tests) 4ms + ✓ tests/provider-triggers.test.ts (3 tests) 5ms + ✓ tests/triggers.test.ts (4 tests) 6ms + ✓ tests/flow.test.ts (24 tests) 9ms + ✓ tests/schedule.test.ts (7 tests) 7643ms + ✓ schedule.cron > measures a cron's longest quiet period so a silence budget can be declared honestly 7618ms + + Test Files 9 passed (9) + Tests 51 passed (51) + Start at 07:42:17 + Duration 8.24s (transform 722ms, setup 0ms, collect 3.09s, tests 7.88s, environment 1ms, prepare 533ms) + +4aaeacc55de3074f4d121ce7253c3be50a93757e6540ba8159889a9450d1c05c dist/flow.js +7bc62eccaa3a9e786ae0a689bf74160585149e91feef8208e17ef8eca51eed7f dist/helpers/providers.js +e2664c65397f93fb486eb6f1e756c7cec3f88b3851d79c23567cad986f80f1ff dist/provider-trigger.js +8fe72f176a75ec0b5f26e12db7a597575c259a2e2cbb59690f9dc20a5e63940b dist/schedule.js +4a3515b571a318f6c7a5661f9310bc9af43e3faf20ea39903a9b363c51258e4c dist/triggers.js +e312994320f82aad0af00d09c504175d929cc6c601dfe1bc522632462ad9a48b dist/triggers/github.js + +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ cd packages/sdk +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/plugin-store-bounds.test.ts (3 tests) 10ms + ✓ tests/hosted-extension-routing.test.ts (2 tests) 3ms + ✓ tests/preflight.test.ts (59 tests) 103ms + ✓ tests/babysitter-native-extension.test.ts (38 tests) 450ms + ✓ tests/hosted-base-snapshot.test.ts (8 tests) 1127ms + ✓ tests/authored-flow.test.ts (34 tests) 752ms + ✓ tests/hosted-extension-protocol.test.ts (22 tests) 672ms + ✓ tests/hosted-extension-isolation.test.ts (13 tests) 794ms + + Test Files 8 passed (8) + Tests 179 passed (179) + Start at 10:43:35 + Duration 1.98s (transform 1.12s, setup 0ms, collect 5.73s, tests 3.91s, environment 1ms, prepare 444ms) + +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ cd packages/sdk +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/plugin-store-bounds.test.ts (3 tests) 13ms + ✓ tests/hosted-extension-routing.test.ts (3 tests) 5ms + ✓ tests/preflight.test.ts (59 tests) 105ms + ✓ tests/babysitter-native-extension.test.ts (39 tests) 356ms + ✓ tests/hosted-base-snapshot.test.ts (9 tests) 1109ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 1066ms + ✓ tests/authored-flow.test.ts (34 tests) 725ms + ✓ tests/hosted-extension-protocol.test.ts (22 tests) 706ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 814ms + + Test Files 8 passed (8) + Tests 183 passed (183) + Start at 11:03:58 + Duration 2.00s (transform 1.26s, setup 0ms, collect 5.76s, tests 3.83s, environment 2ms, prepare 458ms) + +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ cd packages/sdk +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/plugin-store-bounds.test.ts (3 tests) 15ms + ✓ tests/hosted-base-snapshot.test.ts (9 tests) 678ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 649ms + ✓ tests/hosted-extension-routing.test.ts (3 tests) 5ms + ✓ tests/preflight.test.ts (59 tests) 105ms + ✓ tests/babysitter-native-extension.test.ts (39 tests) 434ms + ✓ tests/authored-flow.test.ts (34 tests) 763ms + ✓ tests/hosted-extension-protocol.test.ts (22 tests) 718ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 746ms + + Test Files 8 passed (8) + Tests 183 passed (183) + Start at 11:05:11 + Duration 2.03s (transform 1.18s, setup 0ms, collect 6.22s, tests 3.46s, environment 1ms, prepare 492ms) + +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ cd packages/sdk +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/plugin-store-bounds.test.ts (3 tests) 9ms + ✓ tests/hosted-extension-routing.test.ts (3 tests) 3ms + ✓ tests/preflight.test.ts (59 tests) 114ms + ✓ tests/hosted-base-snapshot.test.ts (9 tests) 1021ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 982ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 520ms + ✓ tests/authored-flow.test.ts (34 tests) 768ms + ✓ tests/hosted-extension-protocol.test.ts (22 tests) 654ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 795ms + + Test Files 8 passed (8) + Tests 184 passed (184) + Start at 11:16:16 + Duration 2.00s (transform 1.19s, setup 0ms, collect 5.81s, tests 3.88s, environment 1ms, prepare 407ms) + +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ cd packages/sdk +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/plugin-store-bounds.test.ts (3 tests) 9ms + ✓ tests/hosted-extension-routing.test.ts (3 tests) 5ms + ✓ tests/preflight.test.ts (59 tests) 104ms + ✓ tests/hosted-base-snapshot.test.ts (9 tests) 1051ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 1012ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 517ms + ✓ tests/authored-flow.test.ts (34 tests) 746ms + ✓ tests/hosted-extension-protocol.test.ts (22 tests) 642ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 787ms + + Test Files 8 passed (8) + Tests 184 passed (184) + Start at 11:28:02 + Duration 1.99s (transform 1.22s, setup 0ms, collect 5.83s, tests 3.86s, environment 1ms, prepare 484ms) + +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ cd packages/sdk +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/plugin-store-bounds.test.ts (3 tests) 11ms + ✓ tests/hosted-extension-routing.test.ts (3 tests) 5ms + ✓ tests/preflight.test.ts (59 tests) 104ms + ✓ tests/hosted-base-snapshot.test.ts (9 tests) 1059ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 1022ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 424ms + ✓ tests/authored-flow.test.ts (34 tests) 759ms + ✓ tests/hosted-extension-protocol.test.ts (22 tests) 600ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 830ms + + Test Files 8 passed (8) + Tests 184 passed (184) + Start at 11:38:51 + Duration 2.06s (transform 1.25s, setup 0ms, collect 6.00s, tests 3.79s, environment 1ms, prepare 442ms) + +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ cd packages/sdk +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/plugin-store-bounds.test.ts (4 tests) 8ms + ✓ tests/hosted-base-snapshot.test.ts (9 tests) 666ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 626ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 8ms + ✓ tests/preflight.test.ts (59 tests) 103ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 509ms + ✓ tests/authored-flow.test.ts (34 tests) 778ms + ✓ tests/hosted-extension-protocol.test.ts (23 tests) 631ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 797ms + + Test Files 8 passed (8) + Tests 187 passed (187) + Start at 11:53:48 + Duration 2.01s (transform 1.14s, setup 0ms, collect 5.85s, tests 3.50s, environment 1ms, prepare 506ms) + +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ cd packages/sdk +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/plugin-store-bounds.test.ts (4 tests) 12ms + ✓ tests/hosted-base-snapshot.test.ts (9 tests) 615ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 582ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (59 tests) 99ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 403ms + ✓ tests/authored-flow.test.ts (34 tests) 764ms + ✓ tests/hosted-extension-protocol.test.ts (23 tests) 663ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 794ms + + Test Files 8 passed (8) + Tests 187 passed (187) + Start at 11:56:31 + Duration 2.02s (transform 1.15s, setup 0ms, collect 5.93s, tests 3.36s, environment 1ms, prepare 520ms) + +$ cd packages/sdk +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (1 test) 3ms + ✓ tests/plugin-store-bounds.test.ts (4 tests) 12ms + ✓ tests/hosted-base-snapshot.test.ts (9 tests) 379ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 358ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (59 tests) 102ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 458ms + ✓ tests/authored-flow.test.ts (34 tests) 761ms + ✓ tests/hosted-extension-protocol.test.ts (23 tests) 646ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 800ms + + Test Files 9 passed (9) + Tests 188 passed (188) + Start at 12:14:23 + Duration 2.07s (transform 1.21s, setup 0ms, collect 6.34s, tests 3.17s, environment 2ms, prepare 555ms) + +$ cd packages/sdk +$ npm run typecheck && npm run build && npm run typecheck:tests && ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (2 tests) 11ms + ✓ tests/plugin-store-bounds.test.ts (4 tests) 12ms + ✓ tests/hosted-base-snapshot.test.ts (9 tests) 616ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 582ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (59 tests) 99ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 464ms + ✓ tests/authored-flow.test.ts (34 tests) 718ms + ✓ tests/hosted-extension-protocol.test.ts (23 tests) 618ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 810ms + + Test Files 9 passed (9) + Tests 189 passed (189) + Start at 12:29:00 + Duration 2.04s (transform 1.32s, setup 0ms, collect 6.12s, tests 3.35s, environment 1ms, prepare 543ms) + +$ cd packages/sdk +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (3 tests) 14ms + ✓ tests/plugin-store-bounds.test.ts (5 tests) 16ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 8ms + ✓ tests/preflight.test.ts (59 tests) 110ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 439ms + ✓ tests/hosted-base-snapshot.test.ts (10 tests) 1073ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 1029ms + ✓ tests/authored-flow.test.ts (34 tests) 751ms + ✓ tests/hosted-extension-protocol.test.ts (23 tests) 648ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 841ms + + Test Files 9 passed (9) + Tests 192 passed (192) + Start at 12:48:17 + Duration 2.03s (transform 1.23s, setup 0ms, collect 6.01s, tests 3.90s, environment 1ms, prepare 507ms) + +$ cd packages/sdk +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (3 tests) 11ms + ✓ tests/plugin-store-bounds.test.ts (5 tests) 20ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 9ms + ✓ tests/preflight.test.ts (59 tests) 84ms + ✓ tests/hosted-base-snapshot.test.ts (10 tests) 1082ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 1036ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 495ms + ✓ tests/authored-flow.test.ts (34 tests) 761ms + ✓ tests/hosted-extension-protocol.test.ts (23 tests) 631ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 848ms + + Test Files 9 passed (9) + Tests 192 passed (192) + Start at 13:00:46 + Duration 2.05s (transform 1.23s, setup 0ms, collect 5.95s, tests 3.94s, environment 1ms, prepare 596ms) + +$ cd packages/sdk +$ export TMPDIR=/home/khaliqgant/Projects/AgentWorkforce/gate8-test-tmp +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (3 tests) 12ms + ✓ tests/plugin-store-bounds.test.ts (5 tests) 16ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 8ms + ✓ tests/preflight.test.ts (59 tests) 106ms + ✓ tests/hosted-base-snapshot.test.ts (11 tests) 949ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 895ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 410ms + ✓ tests/authored-flow.test.ts (34 tests) 773ms + ✓ tests/hosted-extension-protocol.test.ts (23 tests) 623ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 829ms + + Test Files 9 passed (9) + Tests 193 passed (193) + Start at 13:09:12 + Duration 2.08s (transform 1.31s, setup 0ms, collect 6.15s, tests 3.73s, environment 2ms, prepare 659ms) + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (3 tests) 14ms + ✓ tests/plugin-store-bounds.test.ts (5 tests) 15ms + ✓ tests/hosted-base-snapshot.test.ts (12 tests) 339ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 310ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (59 tests) 102ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 470ms + ✓ tests/authored-flow.test.ts (34 tests) 744ms + ✓ tests/hosted-extension-protocol.test.ts (23 tests) 607ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 782ms + + Test Files 9 passed (9) + Tests 194 passed (194) + Start at 13:29:45 + Duration 2.06s (transform 1.26s, setup 0ms, collect 6.37s, tests 3.08s, environment 2ms, prepare 635ms) + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.26 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.26 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.26 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (3 tests) 15ms + ✓ tests/plugin-store-bounds.test.ts (5 tests) 16ms + ✓ tests/hosted-base-snapshot.test.ts (12 tests) 364ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 334ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (59 tests) 103ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 366ms + ✓ tests/authored-flow.test.ts (34 tests) 714ms + ✓ tests/hosted-extension-protocol.test.ts (23 tests) 692ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 829ms + + Test Files 9 passed (9) + Tests 194 passed (194) + Start at 13:31:40 + Duration 2.14s (transform 1.40s, setup 0ms, collect 6.71s, tests 3.11s, environment 1ms, prepare 510ms) + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (3 tests) 14ms + ✓ tests/plugin-store-bounds.test.ts (5 tests) 10ms + ✓ tests/hosted-base-snapshot.test.ts (12 tests) 562ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 500ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 6ms + ✓ tests/preflight.test.ts (59 tests) 97ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 363ms + ✓ tests/authored-flow.test.ts (34 tests) 714ms + ✓ tests/hosted-extension-protocol.test.ts (23 tests) 623ms + ✓ tests/hosted-extension-isolation.test.ts (14 tests) 783ms + + Test Files 9 passed (9) + Tests 194 passed (194) + Start at 13:40:02 + Duration 1.97s (transform 1.25s, setup 0ms, collect 5.97s, tests 3.17s, environment 1ms, prepare 572ms) + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (3 tests) 14ms + ✓ tests/plugin-store-bounds.test.ts (5 tests) 17ms + ✓ tests/hosted-base-snapshot.test.ts (12 tests) 410ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 365ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 8ms + ✓ tests/preflight.test.ts (59 tests) 100ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 495ms + ✓ tests/authored-flow.test.ts (34 tests) 762ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 661ms + ✓ tests/hosted-extension-isolation.test.ts (15 tests) 881ms + + Test Files 9 passed (9) + Tests 196 passed (196) + Start at 14:04:23 + Duration 2.25s (transform 1.32s, setup 0ms, collect 6.81s, tests 3.35s, environment 1ms, prepare 609ms) + +$ git merge-base HEAD origin/main +83addb00923adb02041f640d4e724ffaabcdd781 +$ git rev-parse origin/main +83addb00923adb02041f640d4e724ffaabcdd781 +$ git diff --exit-code origin/main -- extensions/babysitter +$ git diff --exit-code origin/main -- packages/sdk/src/authored-flow-executor.ts +$ cd packages/sdk +$ sha256sum ../../examples/software-factory/software-factory.flow.ts +49c993220b9c34fab2d4b0e51911656f62b8b657f534d988691960d45bb9d9b6 ../../examples/software-factory/software-factory.flow.ts +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (3 tests) 14ms + ✓ tests/plugin-store-bounds.test.ts (5 tests) 10ms + ✓ tests/hosted-base-snapshot.test.ts (12 tests) 567ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 513ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 6ms + ✓ tests/preflight.test.ts (59 tests) 100ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 475ms + ✓ tests/authored-flow.test.ts (34 tests) 765ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 648ms + ✓ tests/hosted-extension-isolation.test.ts (15 tests) 892ms + + Test Files 9 passed (9) + Tests 196 passed (196) + Start at 14:06:25 + Duration 2.12s (transform 1.18s, setup 0ms, collect 6.21s, tests 3.48s, environment 2ms, prepare 501ms) + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (3 tests) 14ms + ✓ tests/plugin-store-bounds.test.ts (6 tests) 44ms + ✓ tests/hosted-base-snapshot.test.ts (12 tests) 362ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 326ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (59 tests) 85ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 482ms + ✓ tests/authored-flow.test.ts (34 tests) 746ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 690ms + ✓ tests/hosted-extension-isolation.test.ts (15 tests) 909ms + + Test Files 9 passed (9) + Tests 197 passed (197) + Start at 14:17:06 + Duration 2.21s (transform 1.27s, setup 0ms, collect 6.52s, tests 3.34s, environment 1ms, prepare 551ms) + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (3 tests) 15ms + ✓ tests/plugin-store-bounds.test.ts (6 tests) 50ms + ✓ tests/hosted-base-snapshot.test.ts (12 tests) 544ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 486ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 4ms + ✓ tests/preflight.test.ts (59 tests) 59ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 471ms + ✓ tests/authored-flow.test.ts (34 tests) 724ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 675ms + ✓ tests/hosted-extension-isolation.test.ts (15 tests) 909ms + + Test Files 9 passed (9) + Tests 197 passed (197) + Start at 14:18:47 + Duration 2.15s (transform 1.28s, setup 0ms, collect 6.28s, tests 3.45s, environment 2ms, prepare 585ms) + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (4 tests) 12ms + ✓ tests/plugin-store-bounds.test.ts (6 tests) 27ms + ✓ tests/hosted-base-snapshot.test.ts (12 tests) 561ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 501ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (59 tests) 100ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 452ms + ✓ tests/authored-flow.test.ts (34 tests) 766ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 637ms + ✓ tests/hosted-extension-isolation.test.ts (16 tests) 937ms + + Test Files 9 passed (9) + Tests 199 passed (199) + Start at 14:31:40 + Duration 2.14s (transform 1.24s, setup 0ms, collect 5.98s, tests 3.50s, environment 2ms, prepare 588ms) + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 16ms + ✓ tests/plugin-store-bounds.test.ts (7 tests) 51ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 569ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 513ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (59 tests) 93ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 463ms + ✓ tests/authored-flow.test.ts (34 tests) 742ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 646ms + ✓ tests/hosted-extension-isolation.test.ts (16 tests) 968ms + + Test Files 9 passed (9) + Tests 202 passed (202) + Start at 14:43:25 + Duration 2.16s (transform 1.18s, setup 0ms, collect 5.87s, tests 3.55s, environment 1ms, prepare 609ms) + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 16ms + ✓ tests/plugin-store-bounds.test.ts (7 tests) 64ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 562ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 501ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (59 tests) 94ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 391ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 570ms + ✓ tests/authored-flow.test.ts (34 tests) 747ms + ✓ tests/hosted-extension-isolation.test.ts (17 tests) 940ms + + Test Files 9 passed (9) + Tests 203 passed (203) + Start at 15:03:28 + Duration 2.19s (transform 1.38s, setup 0ms, collect 6.38s, tests 3.39s, environment 2ms, prepare 565ms) + +$ git rebase --autostash origin/main +Created autostash: 33c0e99c +Applied autostash. +Successfully rebased and updated refs/heads/feat/babysitter-gate8-isolation. + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 15ms + ✓ tests/plugin-store-bounds.test.ts (7 tests) 44ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 566ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 505ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 6ms + ✓ tests/preflight.test.ts (59 tests) 85ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 471ms + ✓ tests/authored-flow.test.ts (34 tests) 703ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 619ms + ✓ tests/hosted-extension-isolation.test.ts (17 tests) 956ms + + Test Files 9 passed (9) + Tests 203 passed (203) + Start at 15:04:44 + Duration 2.24s (transform 1.32s, setup 0ms, collect 6.54s, tests 3.47s, environment 2ms, prepare 582ms) + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 16ms + ✓ tests/plugin-store-bounds.test.ts (7 tests) 53ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 639ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 576ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 6ms + ✓ tests/preflight.test.ts (59 tests) 100ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 454ms + ✓ tests/authored-flow.test.ts (34 tests) 752ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 608ms + ✓ tests/hosted-extension-isolation.test.ts (18 tests) 1055ms + + Test Files 9 passed (9) + Tests 204 passed (204) + Start at 15:12:20 + Duration 2.46s (transform 1.51s, setup 0ms, collect 7.22s, tests 3.68s, environment 2ms, prepare 629ms) + +$ git rebase origin/main +Successfully rebased and updated refs/heads/feat/babysitter-gate8-isolation. + +$ git rev-parse HEAD +ba79ad5360dff49a0cc9f2d1cdf528f984d8b444 + +$ git rev-parse origin/main +9551018167e5bfcf248b7d2533401cf935ceac77 + +$ git merge-base HEAD origin/main +9551018167e5bfcf248b7d2533401cf935ceac77 + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 14ms + ✓ tests/plugin-store-bounds.test.ts (7 tests) 58ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 411ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 373ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 4ms + ✓ tests/preflight.test.ts (67 tests) 102ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 398ms + ✓ tests/authored-flow.test.ts (34 tests) 736ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 640ms + ✓ tests/hosted-extension-isolation.test.ts (18 tests) 1012ms + + Test Files 9 passed (9) + Tests 212 passed (212) + Start at 15:14:56 + Duration 2.24s (transform 1.24s, setup 0ms, collect 5.99s, tests 3.38s, environment 1ms, prepare 542ms) + +$ git rebase origin/main +Successfully rebased and updated refs/heads/feat/babysitter-gate8-isolation. + +$ git rev-parse HEAD +f25d5f7fa06c5b6b95e71ac107862bf40a7dcaa1 + +$ git rev-parse origin/main +62015ce5d2dbb7563616fba112a83fec89030a4e + +$ git merge-base HEAD origin/main +62015ce5d2dbb7563616fba112a83fec89030a4e + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 11ms + ✓ tests/plugin-store-bounds.test.ts (7 tests) 49ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 593ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 534ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (67 tests) 106ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 377ms + ✓ tests/authored-flow.test.ts (34 tests) 773ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 659ms + ✓ tests/hosted-extension-isolation.test.ts (18 tests) 1068ms + + Test Files 9 passed (9) + Tests 212 passed (212) + Start at 15:16:23 + Duration 2.41s (transform 1.29s, setup 0ms, collect 6.81s, tests 3.64s, environment 2ms, prepare 660ms) + +$ git rev-parse HEAD +9d34380af04a843bac8187b0c4aaad3fc0498e53 + +$ git rev-parse origin/main +62015ce5d2dbb7563616fba112a83fec89030a4e + +$ git merge-base HEAD origin/main +62015ce5d2dbb7563616fba112a83fec89030a4e + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 16ms + ✓ tests/plugin-store-bounds.test.ts (8 tests) 53ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (67 tests) 131ms + ✓ tests/authored-flow.test.ts (34 tests) 813ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 888ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 2209ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 2131ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 897ms + ✓ tests/hosted-extension-isolation.test.ts (18 tests) 1440ms + + Test Files 9 passed (9) + Tests 213 passed (213) + Start at 15:20:04 + Duration 3.58s (transform 2.46s, setup 0ms, collect 11.20s, tests 6.45s, environment 2ms, prepare 848ms) + +$ gh run view 35791890938 --repo AgentWorkforce/flows --log-failed +linux-x64-artifact Test SDK and type-level authoring contracts +ENOENT: no such file or directory, open '/$bunfs/package.json' +at packages/sdk/src/flow-extension-compat.ts (module-initialization package manifest read) +Process completed with exit code 1. + +$ git rev-parse HEAD +e4dadd131e1eac8997deb2dbb6a6ea96a3a37b3f + +$ git rev-parse origin/main +62015ce5d2dbb7563616fba112a83fec89030a4e + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.27 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.27 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.27 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 15ms + ✓ tests/plugin-store-bounds.test.ts (8 tests) 58ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 383ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 324ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 6ms + ✓ tests/preflight.test.ts (67 tests) 102ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 463ms + ✓ tests/authored-flow.test.ts (34 tests) 739ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 659ms + ✓ tests/hosted-extension-isolation.test.ts (19 tests) 992ms + + Test Files 9 passed (9) + Tests 214 passed (214) + Start at 15:31:47 + Duration 2.32s (transform 1.43s, setup 0ms, collect 6.73s, tests 3.42s, environment 2ms, prepare 519ms) + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.taZTPK/flows +Bundled 747 modules in 29ms +authored-node.mjs 2.89 MB (entry point) +[49ms] bundle 680 modules +[91ms] compile /tmp/flows-pr552-standalone.taZTPK/flows + +$ /tmp/flows-pr552-standalone.taZTPK/flows --help +Usage: +flows add +[complete CLI usage emitted; exit 0] + +$ git rebase origin/main +Successfully rebased and updated refs/heads/feat/babysitter-gate8-isolation. + +$ git rev-parse HEAD +db86507419f201565c9033b043404f897cbc4c9c + +$ git rev-parse origin/main +5546c4b3029938ed9d9eb1f9bfdbed81c88b9224 + +$ git merge-base HEAD origin/main +5546c4b3029938ed9d9eb1f9bfdbed81c88b9224 + +$ cd packages/surface +$ bun run build +$ tsc + +$ cd ../sdk +$ npm install ../surface --no-save --ignore-scripts +changed 1 package, and audited 196 packages in 747ms + +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 15ms + ✓ tests/plugin-store-bounds.test.ts (8 tests) 42ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 558ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 508ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 6ms + ✓ tests/preflight.test.ts (67 tests) 99ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 470ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 604ms + ✓ tests/authored-flow.test.ts (34 tests) 774ms + ✓ tests/hosted-extension-isolation.test.ts (19 tests) 946ms + + Test Files 9 passed (9) + Tests 214 passed (214) + Start at 15:35:08 + Duration 2.48s (transform 1.63s, setup 0ms, collect 8.09s, tests 3.51s, environment 1ms, prepare 537ms) + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.t9SSsQ/flows +Bundled 779 modules in 33ms +authored-node.mjs 3.1 MB (entry point) +[50ms] bundle 712 modules +[88ms] compile /tmp/flows-pr552-standalone.t9SSsQ/flows + +$ /tmp/flows-pr552-standalone.t9SSsQ/flows --help >/dev/null +$ printf 'standalone-smoke-pass %s\n' /tmp/flows-pr552-standalone.t9SSsQ/flows +standalone-smoke-pass /tmp/flows-pr552-standalone.t9SSsQ/flows + +$ git cherry-pick d125e673f19918ad339559602ff29183d93cbf2b +[feat/babysitter-gate8-isolation 7cf156fc] fix(sdk): embed hosted runtime versions + +$ git rev-parse HEAD +7cf156fcccc20ed51f3d75be6ec6c311b7061c96 + +$ git rev-parse origin/main +5546c4b3029938ed9d9eb1f9bfdbed81c88b9224 + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 15ms + ✓ tests/plugin-store-bounds.test.ts (8 tests) 47ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 580ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 527ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 7ms + ✓ tests/preflight.test.ts (67 tests) 103ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 485ms + ✓ tests/authored-flow.test.ts (34 tests) 748ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 619ms + ✓ tests/hosted-extension-isolation.test.ts (19 tests) 973ms + + Test Files 9 passed (9) + Tests 214 passed (214) + Start at 15:37:57 + Duration 2.61s (transform 1.70s, setup 0ms, collect 8.57s, tests 3.58s, environment 2ms, prepare 614ms) + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.tPG8GP/flows +Bundled 780 modules in 47ms +authored-node.mjs 3.1 MB (entry point) +[81ms] bundle 713 modules +[134ms] compile /tmp/flows-pr552-standalone.tPG8GP/flows + +$ /tmp/flows-pr552-standalone.tPG8GP/flows --help >/dev/null +$ printf 'standalone-static-json-pass %s\n' /tmp/flows-pr552-standalone.tPG8GP/flows +standalone-static-json-pass /tmp/flows-pr552-standalone.tPG8GP/flows + +$ git rev-parse HEAD +aa26c50eb7ae00c5916084e6c1075712ac90fce3 + +$ git rev-parse origin/main +5546c4b3029938ed9d9eb1f9bfdbed81c88b9224 + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + RUN v2.1.9 /home/khaliqgant/Projects/AgentWorkforce/flows-worktrees/babysitter-gate8-isolation/packages/sdk + + ✓ tests/hosted-extension-protocol-intrinsics.test.ts (5 tests) 15ms + ✓ tests/plugin-store-bounds.test.ts (8 tests) 55ms + ✓ tests/hosted-base-snapshot.test.ts (13 tests) 647ms + ✓ hosted base private snapshot > stops streaming project entries at the shared count limit 587ms + ✓ tests/hosted-extension-routing.test.ts (4 tests) 6ms + ✓ tests/preflight.test.ts (67 tests) 101ms + ✓ tests/babysitter-native-extension.test.ts (40 tests) 470ms + ✓ tests/hosted-extension-protocol.test.ts (24 tests) 585ms + ✓ tests/authored-flow.test.ts (34 tests) 782ms + ✓ tests/hosted-extension-isolation.test.ts (20 tests) 1052ms + + Test Files 9 passed (9) + Tests 215 passed (215) + Start at 15:50:08 + Duration 2.66s (transform 1.58s, setup 0ms, collect 8.33s, tests 3.71s, environment 2ms, prepare 524ms) + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.UflORW/flows +Bundled 780 modules in 32ms +authored-node.mjs 3.1 MB (entry point) +[56ms] bundle 713 modules +[102ms] compile /tmp/flows-pr552-standalone.UflORW/flows + +$ /tmp/flows-pr552-standalone.UflORW/flows --help >/dev/null +$ printf 'standalone-hosted-parser-pass %s\n' /tmp/flows-pr552-standalone.UflORW/flows +standalone-hosted-parser-pass /tmp/flows-pr552-standalone.UflORW/flows + +$ git rev-parse HEAD +70295e43 + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + Test Files 9 passed (9) + Tests 216 passed (216) + Start at 15:54:51 + Duration 2.63s + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.0rAzuk/flows +Bundled 780 modules in 30ms +authored-node.mjs 3.1 MB (entry point) +[50ms] bundle 713 modules +[88ms] compile /tmp/flows-pr552-standalone.0rAzuk/flows + +$ /tmp/flows-pr552-standalone.0rAzuk/flows --help >/dev/null +$ printf 'standalone-hash-factory-pass %s\n' /tmp/flows-pr552-standalone.0rAzuk/flows +standalone-hash-factory-pass /tmp/flows-pr552-standalone.0rAzuk/flows + +$ git rev-parse HEAD +a57bdb5f + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + Test Files 9 passed (9) + Tests 218 passed (218) + Start at 16:12:53 + Duration 2.62s + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.cEFYKP/flows +Bundled 780 modules in 31ms +authored-node.mjs 3.1 MB (entry point) +[49ms] bundle 713 modules +[91ms] compile /tmp/flows-pr552-standalone.cEFYKP/flows + +$ /tmp/flows-pr552-standalone.cEFYKP/flows --help >/dev/null +$ printf 'standalone-promise-safety-pass %s\n' /tmp/flows-pr552-standalone.cEFYKP/flows +standalone-promise-safety-pass /tmp/flows-pr552-standalone.cEFYKP/flows + +$ git rev-parse HEAD +6382e7fb + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + Test Files 9 passed (9) + Tests 222 passed (222) + Start at 16:31:00 + Duration 2.48s + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.s5kEqR/flows +Bundled 780 modules in 31ms +authored-node.mjs 3.1 MB (entry point) +[54ms] bundle 713 modules +[95ms] compile /tmp/flows-pr552-standalone.s5kEqR/flows + +$ /tmp/flows-pr552-standalone.s5kEqR/flows --help >/dev/null +$ printf 'standalone-hosted-invariants-pass %s\n' /tmp/flows-pr552-standalone.s5kEqR/flows +standalone-hosted-invariants-pass /tmp/flows-pr552-standalone.s5kEqR/flows + +$ git rev-parse HEAD +ebf2f280 + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/fs-descriptor.test.ts tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + Test Files 10 passed (10) + Tests 225 passed (225) + Start at 16:58:18 + Duration 2.54s + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.yoEPLj/flows +Bundled 781 modules in 27ms +authored-node.mjs 3.1 MB (entry point) +[58ms] bundle 714 modules +[101ms] compile /tmp/flows-pr552-standalone.yoEPLj/flows + +$ /tmp/flows-pr552-standalone.yoEPLj/flows --help >/dev/null +$ printf 'standalone-native-promise-pass %s\n' /tmp/flows-pr552-standalone.yoEPLj/flows +standalone-native-promise-pass /tmp/flows-pr552-standalone.yoEPLj/flows + +$ git rev-parse HEAD +e209de64 + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/fs-descriptor.test.ts tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + Test Files 10 passed (10) + Tests 226 passed (226) + Start at 17:20:40 + Duration 2.43s + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.Zkkb5P/flows +Bundled 782 modules in 34ms +authored-node.mjs 3.1 MB (entry point) +[49ms] bundle 715 modules +[86ms] compile /tmp/flows-pr552-standalone.Zkkb5P/flows + +$ /tmp/flows-pr552-standalone.Zkkb5P/flows --help >/dev/null +$ printf 'standalone-array-setter-pass %s\n' /tmp/flows-pr552-standalone.Zkkb5P/flows +standalone-array-setter-pass /tmp/flows-pr552-standalone.Zkkb5P/flows + +$ git rev-parse HEAD +ce108703 + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts tests/fs-descriptor.test.ts + + Test Files 10 passed (10) + Tests 227 passed (227) + Start at 17:32:35 + Duration 2.82s + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.olHfJA/flows +Bundled 782 modules in 28ms +authored-node.mjs 3.1 MB (entry point) +[52ms] bundle 715 modules +[87ms] compile /tmp/flows-pr552-standalone.olHfJA/flows + +$ /tmp/flows-pr552-standalone.olHfJA/flows --help >/dev/null +$ printf 'standalone-manifest-snapshot-pass %s\n' /tmp/flows-pr552-standalone.olHfJA/flows +standalone-manifest-snapshot-pass /tmp/flows-pr552-standalone.olHfJA/flows + +$ git rev-parse HEAD +edd83625 + +$ git merge-base HEAD origin/main +c2ac5be989ac595b4056c84a52ca1c819dffbdff + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts tests/fs-descriptor.test.ts + + Test Files 10 passed (10) + Tests 227 passed (227) + Start at 17:34:02 + Duration 2.74s + +$ git rev-parse HEAD +37cdd771bb800d0b5cc111d66decb024e16cd202 + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ ./node_modules/.bin/vitest run tests/hosted-extension-routing.test.ts + + Test Files 1 passed (1) + Tests 6 passed (6) + Start at 02:43:18 + Duration 4.45s + +$ git rev-parse HEAD +f4f9ff42 + +$ git diff origin/main..HEAD -- .github/workflows/cloud-runtime-artifact.yml + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.28 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.28 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.28 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/fs-descriptor.test.ts tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + Test Files 10 passed (10) + Tests 229 passed (229) + Start at 17:45:33 + Duration 2.60s + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.Txn3LL/flows +Bundled 782 modules in 29ms +authored-node.mjs 3.1 MB (entry point) +[51ms] bundle 715 modules +[100ms] compile /tmp/flows-pr552-standalone.Txn3LL/flows + +$ /tmp/flows-pr552-standalone.Txn3LL/flows --help >/dev/null +$ printf 'standalone-bounded-surface-pass %s\n' /tmp/flows-pr552-standalone.Txn3LL/flows +standalone-bounded-surface-pass /tmp/flows-pr552-standalone.Txn3LL/flows + +$ git rev-parse HEAD +b7dc537b + +$ git merge-base HEAD origin/main +a963b25e2a030e337ae3c0382c1d029861070d8e + +$ git diff origin/main..HEAD -- .github/workflows/cloud-runtime-artifact.yml + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.29 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.29 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.29 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/fs-descriptor.test.ts tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + Test Files 10 passed (10) + Tests 229 passed (229) + Start at 17:49:24 + Duration 2.27s + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.Gv1Jjf/flows +Bundled 782 modules in 32ms +authored-node.mjs 3.1 MB (entry point) +[51ms] bundle 715 modules +[102ms] compile /tmp/flows-pr552-standalone.Gv1Jjf/flows + +$ /tmp/flows-pr552-standalone.Gv1Jjf/flows --help >/dev/null +$ printf 'standalone-rebased-surface-pass %s\n' /tmp/flows-pr552-standalone.Gv1Jjf/flows +standalone-rebased-surface-pass /tmp/flows-pr552-standalone.Gv1Jjf/flows + +$ git rev-parse HEAD +c0f9d7cf + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.29 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.29 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.29 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/fs-descriptor.test.ts tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + Test Files 10 passed (10) + Tests 230 passed (230) + Start at 18:00:29 + Duration 2.50s + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-standalone.0BB8Xx/flows +Bundled 782 modules in 26ms +authored-node.mjs 3.1 MB (entry point) +[57ms] bundle 715 modules +[99ms] compile /tmp/flows-pr552-standalone.0BB8Xx/flows + +$ /tmp/flows-pr552-standalone.0BB8Xx/flows --help >/dev/null +$ printf 'standalone-surface-promise-pass %s\n' /tmp/flows-pr552-standalone.0BB8Xx/flows +standalone-surface-promise-pass /tmp/flows-pr552-standalone.0BB8Xx/flows + +$ git rev-parse HEAD +4858f6ce46ad14e3e52a6f3fdeb602b2ef64a805 + +$ cd packages/sdk +$ git diff --check +$ npm run typecheck + +> @relayflows/sdk@2.0.29 typecheck +> tsc --noEmit && tsc -p tsconfig.type-tests.json + +$ npm run build + +> @relayflows/sdk@2.0.29 build +> tsc && node scripts/make-cli-executable.mjs + +$ npm run typecheck:tests + +> @relayflows/sdk@2.0.29 typecheck:tests +> tsc -p tsconfig.tests.json + +$ ./node_modules/.bin/vitest run tests/fs-descriptor.test.ts tests/plugin-store-bounds.test.ts tests/hosted-base-snapshot.test.ts tests/hosted-extension-routing.test.ts tests/preflight.test.ts tests/babysitter-native-extension.test.ts tests/hosted-extension-protocol-intrinsics.test.ts tests/hosted-extension-protocol.test.ts tests/authored-flow.test.ts tests/hosted-extension-isolation.test.ts + + Test Files 10 passed (10) + Tests 231 passed (231) + Start at 18:20:18 + Duration 5.58s + +$ node scripts/build-standalone-cli.mjs bun-linux-x64 /tmp/flows-pr552-node-pin.YK9Mv1/flows +Bundled 782 modules in 38ms +authored-node.mjs 3.1 MB (entry point) +[59ms] bundle 715 modules +[112ms] compile /tmp/flows-pr552-node-pin.YK9Mv1/flows + +$ /tmp/flows-pr552-node-pin.YK9Mv1/flows --help >/dev/null +$ printf 'standalone-node-pin-pass %s\n' /tmp/flows-pr552-node-pin.YK9Mv1/flows +standalone-node-pin-pass /tmp/flows-pr552-node-pin.YK9Mv1/flows diff --git a/packages/sdk/src/bundle-extensions.ts b/packages/sdk/src/bundle-extensions.ts index d008141fc..15340173a 100644 --- a/packages/sdk/src/bundle-extensions.ts +++ b/packages/sdk/src/bundle-extensions.ts @@ -5,7 +5,7 @@ import { safePath, sha256 } from './bundle.js'; import { findPluginProject } from './plugin-loader.js'; import { parsePluginLock, readPluginLock, reconcileDeclaredExtensions, type PluginLock } from './plugin-lock.js'; import { PluginError } from './plugin-manifest.js'; -import { pluginStoreDirectory, readStoredPluginFiles, verifyStoredPlugin } from './plugin-store.js'; +import { pluginStoreDirectory, readStoredPluginFiles } from './plugin-store.js'; const EMPTY_LOCK: PluginLock = Object.freeze({ version: 2, plugins: Object.freeze([]) }); @@ -50,10 +50,11 @@ export async function verifyBundlePluginLock(bundle: string): Promise { throw new Error(`lockfile.json: plugin name ${entry.name} is not a safe path component`); } const directory = join(bundle, 'plugins', entry.name); - await verifyStoredPlugin(directory, entry.digest); - let pluginManifest: Buffer; - try { pluginManifest = await readFile(join(directory, 'flows-plugin.json')); } - catch { throw new Error(`lockfile.json: plugin ${entry.name} is missing plugins/${entry.name}/flows-plugin.json`); } + const stored = await readStoredPluginFiles(directory, entry.digest); + const pluginManifest = stored.find(file => file.path === 'flows-plugin.json')?.data; + if (pluginManifest === undefined) { + throw new Error(`lockfile.json: plugin ${entry.name} is missing plugins/${entry.name}/flows-plugin.json`); + } if (sha256(pluginManifest) !== entry.manifestSha256) { throw new Error(`lockfile.json: plugin ${entry.name} flows-plugin.json does not match the lockfile manifest hash`); } diff --git a/packages/sdk/src/bundle.ts b/packages/sdk/src/bundle.ts index bbf986783..f6d193936 100644 --- a/packages/sdk/src/bundle.ts +++ b/packages/sdk/src/bundle.ts @@ -2,6 +2,27 @@ import { createHash, createPrivateKey, createPublicKey, randomBytes, sign, verif import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'; import { basename, dirname, join, resolve } from 'node:path'; import { canonicalize } from './canonical.js'; +import { appendIntrinsicArray } from './intrinsic-array.js'; + +type Hash = ReturnType; +const CREATE_HASH = createHash; +const HASH_UPDATE = Function.prototype.call.bind(CREATE_HASH('sha256').update) as ( + hash: Hash, + data: Uint8Array | string, +) => Hash; +const HASH_DIGEST = Function.prototype.call.bind(CREATE_HASH('sha256').digest) as ( + hash: Hash, + encoding: 'hex', +) => string; +const STRING_INCLUDES = Function.prototype.call.bind(String.prototype.includes) as ( + value: string, search: string, +) => boolean; +const STRING_SPLIT = Function.prototype.call.bind(String.prototype.split) as ( + value: string, separator: string, +) => string[]; +const TYPED_ARRAY_LENGTH = Function.prototype.call.bind( + Object.getOwnPropertyDescriptor(Object.getPrototypeOf(Uint8Array.prototype) as object, 'length')!.get!, +) as (value: Uint8Array) => number; export interface BundleEntry { path: string; sha256: string; bytes: number } export interface BundleFile { path: string; data: Uint8Array | string; executable?: boolean } @@ -15,14 +36,21 @@ export interface BundleOptions { } export function sha256(data: Uint8Array | string): string { - return createHash('sha256').update(data).digest('hex'); + const hash = CREATE_HASH('sha256'); + HASH_UPDATE(hash, data); + return HASH_DIGEST(hash, 'hex'); } /** A bundle-relative path: no empty, `.`, or `..` component, no backslash, NUL, or drive colon. */ export function safePath(path: string): boolean { - return path.length > 0 && !path.includes('\\') && !path.includes('\0') - && path.split('/').every(part => part !== '' && part !== '.' && part !== '..') - && !path.includes(':'); + if (path.length === 0 || STRING_INCLUDES(path, '\\') || STRING_INCLUDES(path, '\0') + || STRING_INCLUDES(path, ':')) return false; + const parts = STRING_SPLIT(path, '/'); + for (let index = 0; index < parts.length; index += 1) { + const part = parts[index]!; + if (part === '' || part === '.' || part === '..') return false; + } + return true; } /** @@ -31,9 +59,26 @@ export function safePath(path: string): boolean { * thing everywhere: the digest of `[{bytes,path,sha256}]`, sorted by path. */ export function payloadManifest(files: readonly { path: string; data: Uint8Array }[]): string { - return canonicalize([...files] - .sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0) - .map(file => ({ path: file.path, sha256: sha256(file.data), bytes: file.data.length }))); + const sorted: { path: string; data: Uint8Array }[] = []; + for (let index = 0; index < files.length; index += 1) appendIntrinsicArray(sorted, files[index]!); + // Do not consult mutable Array prototype methods here. Authored base code + // executes in this process before hosted artifact staging, so a live + // sort/map lookup would let it substitute bytes or manifest records. + for (let index = 1; index < sorted.length; index += 1) { + const current = sorted[index]!; + let position = index; + while (position > 0 && sorted[position - 1]!.path > current.path) { + sorted[position] = sorted[position - 1]!; + position -= 1; + } + sorted[position] = current; + } + const entries: { path: string; sha256: string; bytes: number }[] = []; + for (let index = 0; index < sorted.length; index += 1) { + const file = sorted[index]!; + appendIntrinsicArray(entries, { path: file.path, sha256: sha256(file.data), bytes: TYPED_ARRAY_LENGTH(file.data) }); + } + return canonicalize(entries); } /** Manifest and identity are envelopes, excluded to avoid circular hashing. */ diff --git a/packages/sdk/src/canonical.ts b/packages/sdk/src/canonical.ts index e78e70e89..3397704bb 100644 --- a/packages/sdk/src/canonical.ts +++ b/packages/sdk/src/canonical.ts @@ -9,6 +9,15 @@ import { createHash } from 'node:crypto'; import { snapshotJsonValue, type JsonValue } from './json-value.js'; +import { appendIntrinsicArray } from './intrinsic-array.js'; + +const ARRAY_IS_ARRAY = Array.isArray; +const ARRAY_JOIN = Function.prototype.call.bind(Array.prototype.join) as ( + array: readonly string[], separator?: string, +) => string; +const ARRAY_SORT = Function.prototype.call.bind(Array.prototype.sort) as (array: T[]) => T[]; +const JSON_STRINGIFY = JSON.stringify; +const OBJECT_KEYS = Object.keys; /** * Serialize a value as canonical JSON: object keys sorted recursively, @@ -42,16 +51,21 @@ export function specHash(spec: unknown): string { */ function serialize(value: JsonValue): string { if (value === null || typeof value !== 'object') { - return JSON.stringify(value); + return JSON_STRINGIFY(value); } - if (Array.isArray(value)) { - return '[' + value.map(serialize).join(',') + ']'; + if (ARRAY_IS_ARRAY(value)) { + const items: string[] = []; + for (let index = 0; index < value.length; index += 1) appendIntrinsicArray(items, serialize(value[index]!)); + return '[' + ARRAY_JOIN(items, ',') + ']'; } const parts: string[] = []; - for (const key of Object.keys(value).sort()) { + const keys = OBJECT_KEYS(value); + ARRAY_SORT(keys); + for (let index = 0; index < keys.length; index += 1) { + const key = keys[index]!; const child = value[key]; if (child === undefined) continue; - parts.push(JSON.stringify(key) + ':' + serialize(child)); + appendIntrinsicArray(parts, JSON_STRINGIFY(key) + ':' + serialize(child)); } - return '{' + parts.join(',') + '}'; + return '{' + ARRAY_JOIN(parts, ',') + '}'; } diff --git a/packages/sdk/src/flow-extension-compat.ts b/packages/sdk/src/flow-extension-compat.ts index 6c3101ccc..ad38f3db6 100644 --- a/packages/sdk/src/flow-extension-compat.ts +++ b/packages/sdk/src/flow-extension-compat.ts @@ -1,20 +1,38 @@ -import { readFileSync } from 'node:fs'; +import packageManifest from '../package.json' with { type: 'json' }; import type { FlowExtensionManifest } from './flow-extension-manifest.js'; import { PluginError } from './plugin-manifest.js'; import { satisfiesRange } from './semver-range.js'; +const OBJECT_FREEZE = Object.freeze; +// This static import is resolved before module evaluation and embedded by the +// standalone Bun build. It neither consults mutable fs exports after authored +// code nor depends on import.meta.url naming a real package directory. +const RUNTIME_VERSIONS = OBJECT_FREEZE({ + sdk: packageManifest.version, + surface: packageManifest.dependencies['@relayflows/surface']!, +}); + export interface RuntimeVersions { readonly sdk: string; readonly surface: string } /** The versions a plugin's `compat` is checked against: this SDK and the surface it pins. */ export function runtimeVersions(): RuntimeVersions { - const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string; dependencies: Record }; - return { sdk: pkg.version, surface: pkg.dependencies['@relayflows/surface']! }; + return RUNTIME_VERSIONS; } /** `compat.surface` / `compat.sdk` against the runtime: a miss is a refusal, never a warning. */ export function assertCompatible(manifest: FlowExtensionManifest, versions: RuntimeVersions): void { - for (const [what, range, actual] of [['surface', manifest.compat.surface, versions.surface], ['sdk', manifest.compat.sdk, versions.sdk]] as const) { - if (!satisfiesRange(actual, range)) throw new PluginError('plugin_incompatible', `${manifest.name} requires ${what} ${range}; this runtime has ${actual}.`); + assertRuntimeRange(manifest, 'surface', manifest.compat.surface, versions.surface); + assertRuntimeRange(manifest, 'sdk', manifest.compat.sdk, versions.sdk); +} + +function assertRuntimeRange( + manifest: FlowExtensionManifest, + what: 'surface' | 'sdk', + range: string, + actual: string, +): void { + if (!satisfiesRange(actual, range)) { + throw new PluginError('plugin_incompatible', `${manifest.name} requires ${what} ${range}; this runtime has ${actual}.`); } } @@ -23,9 +41,16 @@ export function assertCompatible(manifest: FlowExtensionManifest, versions: Runt * optional: a base without it matches only `"*"`. */ export function assertBaseCompatible(manifest: FlowExtensionManifest, base: { readonly name: string; readonly version?: string }): void { - const entry = manifest.compat.base.find(b => b.name === base.name); + let entry: FlowExtensionManifest['compat']['base'][number] | undefined; + for (let index = 0; index < manifest.compat.base.length; index += 1) { + if (manifest.compat.base[index]!.name === base.name) entry = manifest.compat.base[index]; + } if (entry === undefined) { - throw new PluginError('plugin_incompatible', `${manifest.name} extends ${manifest.compat.base.map(b => b.name).join(', ')}, not "${base.name}".`); + let names = ''; + for (let index = 0; index < manifest.compat.base.length; index += 1) { + names += `${index === 0 ? '' : ', '}${manifest.compat.base[index]!.name}`; + } + throw new PluginError('plugin_incompatible', `${manifest.name} extends ${names}, not "${base.name}".`); } if (base.version === undefined) { if (entry.version !== '*') throw new PluginError('plugin_incompatible', `${manifest.name} requires ${base.name} ${entry.version}, but the base flow declares no version; only "*" can be satisfied.`); diff --git a/packages/sdk/src/flow-extension-loader.ts b/packages/sdk/src/flow-extension-loader.ts index be1d90110..af02dd919 100644 --- a/packages/sdk/src/flow-extension-loader.ts +++ b/packages/sdk/src/flow-extension-loader.ts @@ -1,4 +1,3 @@ -import { readFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import type { Ctx } from '@relayflows/surface'; import type { AuthoredFlowDefinition, FlowHandle } from './authored-flow.js'; @@ -6,9 +5,22 @@ import { sha256 } from './bundle.js'; import { assertBaseCompatible, assertCompatible, runtimeVersions, type RuntimeVersions } from './flow-extension-compat.js'; import { validateFlowExtensionManifest, type FlowExtensionManifest } from './flow-extension-manifest.js'; import { findPluginProject } from './plugin-loader.js'; +import { appendIntrinsicArray } from './intrinsic-array.js'; import { reconcileDeclaredExtensions, type PluginLockEntry } from './plugin-lock.js'; import { PluginError } from './plugin-manifest.js'; -import { pluginStoreDirectory, verifyStoredPlugin } from './plugin-store.js'; +import { pluginStoreDirectory, readStoredPluginFiles } from './plugin-store.js'; + +const JSON_PARSE = JSON.parse; +const ARRAY_IS_ARRAY = Array.isArray; +const JSON_STRINGIFY = JSON.stringify; +const OBJECT_FREEZE = Object.freeze; +const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as ( + regexp: RegExp, value: string, +) => boolean; +const STRING_SPLIT = Function.prototype.call.bind(String.prototype.split) as ( + value: string, + separator: string | RegExp, +) => string[]; /** * Compose schema-2 flow extensions onto a base authored flow. @@ -94,7 +106,7 @@ function subscriptionOf(handler: TriggerHandler): { provider: string; event: str if (trigger.kind !== 'webhook' || trigger.filter === undefined) return undefined; const { provider, type, payload } = trigger.filter as { provider?: unknown; type?: unknown; payload?: unknown }; if (typeof provider !== 'string' || provider !== trigger.name || typeof type !== 'string') return undefined; - const action = typeof payload === 'object' && payload !== null && !Array.isArray(payload) ? (payload as { action?: unknown }).action : undefined; + const action = typeof payload === 'object' && payload !== null && !ARRAY_IS_ARRAY(payload) ? (payload as { action?: unknown }).action : undefined; if (action !== undefined && typeof action !== 'string') return undefined; return action === undefined ? { provider, event: type } : { provider, event: type, action }; } @@ -115,32 +127,41 @@ export type HostedExtensionDispatch = { readonly deliveryId: string; }; -type HostedEventIdentity = { readonly provider: string; readonly event: string; readonly action?: string }; +export type HostedEventIdentity = { readonly provider: string; readonly event: string; readonly action?: string }; const DISPATCH_PROVIDER = /^[a-z0-9][a-z0-9-]{0,63}$/; const DISPATCH_EVENT = /^[A-Za-z_][A-Za-z0-9_]*$/; const DISPATCH_DELIVERY = /^[A-Za-z0-9_.:-]{1,200}$/; function hostedEventIdentity(dispatch: unknown): HostedEventIdentity { - if (typeof dispatch !== 'object' || dispatch === null || Array.isArray(dispatch)) { + if (typeof dispatch !== 'object' || dispatch === null || ARRAY_IS_ARRAY(dispatch)) { throw new PluginError('plugin_event_unroutable', 'Hosted extension dispatch authority is malformed.'); } const { provenance, provider, eventType, deliveryId } = dispatch as Partial; if ((dispatch as Partial)[HOSTED_EXTENSION_DISPATCH_AUTHORITY] !== true || provenance !== 'integration-watch' - || typeof provider !== 'string' || !DISPATCH_PROVIDER.test(provider) - || typeof deliveryId !== 'string' || !DISPATCH_DELIVERY.test(deliveryId) + || typeof provider !== 'string' || !REGEXP_TEST(DISPATCH_PROVIDER, provider) + || typeof deliveryId !== 'string' || !REGEXP_TEST(DISPATCH_DELIVERY, deliveryId) || typeof eventType !== 'string') { throw new PluginError('plugin_event_unroutable', 'Hosted extension dispatch authority is malformed.'); } - const parts = eventType.split('.'); - if ((parts.length !== 1 && parts.length !== 2) || parts.some(part => !DISPATCH_EVENT.test(part))) { - throw new PluginError('plugin_event_unroutable', `Hosted extension event ${JSON.stringify(eventType)} is malformed.`); + const parts = STRING_SPLIT(eventType, '.'); + let valid = parts.length === 1 || parts.length === 2; + for (let index = 0; valid && index < parts.length; index += 1) { + valid = REGEXP_TEST(DISPATCH_EVENT, parts[index]!); + } + if (!valid) { + throw new PluginError('plugin_event_unroutable', `Hosted extension event ${JSON_STRINGIFY(eventType)} is malformed.`); } return parts.length === 1 ? { provider, event: parts[0]! } : { provider, event: parts[0]!, action: parts[1]! }; } +/** Validate and project branded host authority without making it serializable. */ +export function hostedExtensionDispatchIdentity(dispatch: unknown): HostedEventIdentity { + return OBJECT_FREEZE(hostedEventIdentity(dispatch)); +} + /** * Brand metadata only after the host has authenticated the integration * delivery. The symbol is deliberately not serializable, so copying a direct @@ -149,7 +170,7 @@ function hostedEventIdentity(dispatch: unknown): HostedEventIdentity { export function hostedExtensionDispatchFromVerifiedDelivery( delivery: Omit, ): HostedExtensionDispatch { - const dispatch = Object.freeze({ + const dispatch = OBJECT_FREEZE({ [HOSTED_EXTENSION_DISPATCH_AUTHORITY]: true as const, provenance: 'integration-watch' as const, ...delivery, @@ -171,16 +192,29 @@ export function extensionHandlerForHostedDispatch( ): { readonly extension: Pick; readonly handler: TriggerHandler } | undefined { if (dispatch === undefined) return undefined; const identity = hostedEventIdentity(dispatch); - const matches = extensions.flatMap(extension => extension.handlers.flatMap(handler => { - const subscription = subscriptionOf(handler); - if (subscription === undefined || subscription.provider !== identity.provider || subscription.event !== identity.event) return []; - if (subscription.action !== undefined && subscription.action !== identity.action) return []; - return [{ extension, handler }]; - })); + const matches: Array<{ + extension: Pick; + handler: TriggerHandler; + }> = []; + for (let extensionIndex = 0; extensionIndex < extensions.length; extensionIndex += 1) { + const extension = extensions[extensionIndex]!; + for (let handlerIndex = 0; handlerIndex < extension.handlers.length; handlerIndex += 1) { + const handler = extension.handlers[handlerIndex]!; + const subscription = subscriptionOf(handler); + if (subscription === undefined || subscription.provider !== identity.provider + || subscription.event !== identity.event) continue; + if (subscription.action !== undefined && subscription.action !== identity.action) continue; + appendIntrinsicArray(matches, { extension, handler }); + } + } if (matches.length > 1) { + let matchingNames = ''; + for (let index = 0; index < matches.length; index += 1) { + matchingNames += `${index === 0 ? '' : ', '}${matches[index]!.extension.name}`; + } throw new PluginError( 'plugin_event_ambiguous', - `Hosted event ${identity.provider}.${identity.event}${identity.action === undefined ? '' : `.${identity.action}`} matches multiple extension handlers (${matches.map(match => match.extension.name).join(', ')}).`, + `Hosted event ${identity.provider}.${identity.event}${identity.action === undefined ? '' : `.${identity.action}`} matches multiple extension handlers (${matchingNames}).`, ); } return matches[0]; @@ -206,11 +240,12 @@ async function loadOne( options: LoadFlowExtensionsOptions, ): Promise { const directory = pluginStoreDirectory(root, lock.name, lock.digest); - await verifyStoredPlugin(directory, lock.digest); - const manifestBytes = readFileSync(join(directory, 'flows-plugin.json')); + const stored = await readStoredPluginFiles(directory, lock.digest); + const manifestBytes = stored.find(file => file.path === 'flows-plugin.json')?.data; + if (manifestBytes === undefined) throw new PluginError('plugin_source_drift', `${ref}: flows-plugin.json is missing.`); if (sha256(manifestBytes) !== lock.manifestSha256) throw new PluginError('plugin_source_drift', `${ref}: flows-plugin.json differs from the lockfile's manifest hash.`); let input: unknown; - try { input = JSON.parse(manifestBytes.toString('utf8')); } + try { input = JSON_PARSE(manifestBytes.toString('utf8')); } catch { throw new PluginError('plugin_manifest_invalid', `${ref}: flows-plugin.json is not valid JSON.`); } const manifest = validateFlowExtensionManifest(input); if (manifest.name !== lock.name || manifest.version !== lock.version) throw new PluginError('plugin_source_drift', `${ref}: manifest names ${manifest.name}@${manifest.version}, lockfile has ${lock.name}@${lock.version}.`); diff --git a/packages/sdk/src/fs-descriptor.ts b/packages/sdk/src/fs-descriptor.ts new file mode 100644 index 000000000..72ef02937 --- /dev/null +++ b/packages/sdk/src/fs-descriptor.ts @@ -0,0 +1,127 @@ +import { + Dir, + Dirent, + Stats, + close, + fstat, + lstat, + open, + opendir, + read, + readdir, + type BigIntStats, + type PathLike, +} from 'node:fs'; +import { hostedPromiseValue } from './hosted-promise-safety.js'; + +const CLOSE = close; +const FSTAT = fstat; +const LSTAT = lstat; +const OPEN = open; +const OPENDIR = opendir; +const READ = read; +const READDIR = readdir; +const PROMISE = Promise; +const DIRENT_IS_DIRECTORY = Function.prototype.call.bind(Dirent.prototype.isDirectory) as ( + entry: Dirent, +) => boolean; +const DIRENT_IS_FILE = Function.prototype.call.bind(Dirent.prototype.isFile) as ( + entry: Dirent, +) => boolean; +const DIR_CLOSE = Function.prototype.call.bind(Dir.prototype.close) as ( + directory: Dir, + callback: (error?: NodeJS.ErrnoException | null) => void, +) => void; +const DIR_READ = Function.prototype.call.bind(Dir.prototype.read) as ( + directory: Dir, + callback: (error: NodeJS.ErrnoException | null, entry: Dirent | null) => void, +) => void; +const STATS_IS_DIRECTORY = Function.prototype.call.bind(Stats.prototype.isDirectory) as ( + stats: Stats | BigIntStats, +) => boolean; +const STATS_IS_FILE = Function.prototype.call.bind(Stats.prototype.isFile) as ( + stats: Stats | BigIntStats, +) => boolean; + +export const descriptorIsDirectory = STATS_IS_DIRECTORY; +export const descriptorIsFile = STATS_IS_FILE; +export const directoryEntryIsDirectory = DIRENT_IS_DIRECTORY; +export const directoryEntryIsFile = DIRENT_IS_FILE; + +export function closeDescriptor(descriptor: number): Promise { + return new PROMISE((resolvePromise, rejectPromise) => { + CLOSE(descriptor, error => error === null ? resolvePromise() : rejectPromise(error)); + }); +} + +export function openDescriptor(path: PathLike, flags: number): Promise { + return new PROMISE((resolvePromise, rejectPromise) => { + OPEN(path, flags, (error, descriptor) => error === null ? resolvePromise(descriptor) : rejectPromise(error)); + }); +} + +export function statDescriptor(descriptor: number, options: { bigint: true }): Promise { + return new PROMISE((resolvePromise, rejectPromise) => { + FSTAT(descriptor, options, (error, stats) => { + if (error !== null) rejectPromise(error); + else resolvePromise(hostedPromiseValue(stats)); + }); + }); +} + +export function lstatPath(path: PathLike): Promise { + return new PROMISE((resolvePromise, rejectPromise) => { + LSTAT(path, (error, stats) => { + if (error !== null) rejectPromise(error); + else resolvePromise(hostedPromiseValue(stats)); + }); + }); +} + +export function openDirectory(path: PathLike): Promise { + return new PROMISE((resolvePromise, rejectPromise) => { + OPENDIR(path, (error, directory) => { + if (error !== null) rejectPromise(error); + else resolvePromise(hostedPromiseValue(directory)); + }); + }); +} + +export function readDirectory(path: PathLike): Promise { + return new PROMISE((resolvePromise, rejectPromise) => { + READDIR(path, { withFileTypes: true }, (error, entries) => { + if (error !== null) rejectPromise(error); + else resolvePromise(hostedPromiseValue(entries)); + }); + }); +} + +export function readDirectoryEntry(directory: Dir): Promise { + return new PROMISE((resolvePromise, rejectPromise) => { + DIR_READ(directory, (error, entry) => { + if (error !== null) rejectPromise(error); + else resolvePromise(entry === null ? null : hostedPromiseValue(entry)); + }); + }); +} + +export function closeDirectory(directory: Dir): Promise { + return new PROMISE((resolvePromise, rejectPromise) => { + DIR_CLOSE(directory, error => error == null ? resolvePromise() : rejectPromise(error)); + }); +} + +export async function readDescriptor( + descriptor: number, + buffer: Buffer, + offset: number, + length: number, + position: number, +): Promise { + return await new PROMISE((resolvePromise, rejectPromise) => { + READ(descriptor, buffer, offset, length, position, (error, bytesRead) => { + if (error !== null) rejectPromise(error); + else resolvePromise(bytesRead); + }); + }); +} diff --git a/packages/sdk/src/hosted-base-snapshot.ts b/packages/sdk/src/hosted-base-snapshot.ts new file mode 100644 index 000000000..284d7f4a2 --- /dev/null +++ b/packages/sdk/src/hosted-base-snapshot.ts @@ -0,0 +1,351 @@ +import { constants } from 'node:fs'; +import { chmod, mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { canonicalize } from './canonical.js'; +import { sha256 } from './bundle.js'; +import { + closeDescriptor, + closeDirectory, + descriptorIsDirectory, + descriptorIsFile, + directoryEntryIsDirectory, + directoryEntryIsFile, + openDirectory, + openDescriptor, + readDescriptor, + readDirectory, + readDirectoryEntry, + statDescriptor, +} from './fs-descriptor.js'; +import { findHostedProject } from './hosted-project.js'; +import { appendIntrinsicArray } from './intrinsic-array.js'; +import { + assertHostedPromiseSafety, + frozenHostedPromiseValue, + hostedPromiseValue, +} from './hosted-promise-safety.js'; +import { PluginError } from './plugin-manifest.js'; + +const EXCLUDED_DIRECTORIES = new Set(['.flows', '.git', 'node_modules']); +const CHMOD = chmod; +const MKDIR = mkdir; +const MKDTEMP = mkdtemp; +const REALPATH = realpath; +const RM = rm; +const WRITE_FILE = writeFile; +const TMPDIR = tmpdir; +const PATH_DIRNAME = dirname; +const PATH_IS_ABSOLUTE = isAbsolute; +const PATH_JOIN = join; +const PATH_RELATIVE = relative; +const PATH_RESOLVE = resolve; +const PATH_SEPARATOR = sep; +const PROCESS_PLATFORM = process.platform; +const ARRAY_SORT = Function.prototype.call.bind(Array.prototype.sort) as ( + array: T[], + compare?: (left: T, right: T) => number, +) => T[]; +const OBJECT_CREATE = Object.create; +const OBJECT_FREEZE = Object.freeze; +const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor; +const BIG_INT = BigInt; +const BUFFER_ALLOC_UNSAFE = Buffer.allocUnsafe; +const NUMBER = Number; +const SET_HAS = Function.prototype.call.bind(Set.prototype.has) as (set: Set, value: T) => boolean; +const STRING_LOCALE_COMPARE = Function.prototype.call.bind(String.prototype.localeCompare) as ( + value: string, + other: string, +) => number; +const STRING_STARTS_WITH = Function.prototype.call.bind(String.prototype.startsWith) as ( + value: string, + search: string, +) => boolean; +const MAX_ENTRIES = 10_000; +const MAX_BYTES = 64 * 1024 * 1024; +const MAX_DEPTH = 64; +const READ_FLAGS = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; + +interface SourceFile { + readonly path: string; + readonly bytes: Uint8Array; + readonly sha256: string; +} + +interface SourceBudget { + entries: number; + bytes: number; +} + +/** @internal Deterministic race seams for this security boundary's tests. */ +export interface HostedBaseSnapshotTestHooks { + readonly beforeOpen?: (path: string) => Promise; + readonly afterStat?: (path: string) => Promise; +} + +const EMPTY_HOSTED_BASE_HOOKS = OBJECT_FREEZE( + OBJECT_CREATE(null), +) as HostedBaseSnapshotTestHooks; + +export interface HostedBaseSourceRoot { + readonly root: string; + readonly prefix: string; +} + +export interface HostedBaseSnapshot { + readonly liveSources: readonly HostedBaseSourceRoot[]; + readonly liveDigest: string; + readonly snapshotRoot: string; + readonly snapshotFlowPath: string; + readonly snapshotFlowSha256: string; +} + +/** + * Materialize project source without importing tenant code. Project + * node_modules is never linked or read. Every admitted source byte is + * represented in liveDigest and copied to the private identity directory. + */ +export async function createHostedBaseSnapshot( + flowPath: string, + projectRootOverride?: string, +): Promise { + assertHostedPromiseSafety('plugin_source_invalid'); + const origin = await REALPATH(PATH_RESOLVE(flowPath)); + const discovered = projectRootOverride ?? findHostedProject(PATH_DIRNAME(origin)) ?? PATH_DIRNAME(origin); + const projectRoot = await REALPATH(discovered); + const flowRelative = PATH_RELATIVE(projectRoot, origin); + if ( + flowRelative === '' || + PATH_IS_ABSOLUTE(flowRelative) || + flowRelative === '..' || + STRING_STARTS_WITH(flowRelative, `..${PATH_SEPARATOR}`) + ) { + throw invalid('Hosted base flow must be a file inside its project root.'); + } + const liveSources = frozenHostedPromiseValue([OBJECT_FREEZE({ root: projectRoot, prefix: '' })]); + const files = await readAuthorityFiles(liveSources); + const liveDigest = sourceDigest(files); + const liveFlow = sourceFileAt(files, flowRelative); + if (liveFlow === undefined) throw invalid('Hosted base flow is missing from its admitted source.'); + const snapshotRoot = await MKDTEMP(PATH_JOIN(TMPDIR(), 'flows-hosted-base-')); + await CHMOD(snapshotRoot, 0o700); + try { + for (let index = 0; index < files.length; index += 1) { + const file = files[index]!; + const target = PATH_JOIN(snapshotRoot, file.path); + await MKDIR(PATH_DIRNAME(target), { recursive: true, mode: 0o700 }); + await WRITE_FILE(target, file.bytes, { flag: 'wx', mode: 0o400 }); + } + const snapshotFiles = await readSnapshotTree(snapshotRoot); + const snapshotDigest = sourceDigest(snapshotFiles); + if (snapshotDigest !== liveDigest) { + throw invalid('Hosted base private snapshot does not match its buffered source.'); + } + const snapshotFlow = sourceFileAt(snapshotFiles, flowRelative); + if (snapshotFlow === undefined || snapshotFlow.sha256 !== liveFlow.sha256) { + throw invalid('Hosted base private snapshot does not contain its buffered flow source.'); + } + return frozenHostedPromiseValue({ + liveSources, + liveDigest, + snapshotRoot, + snapshotFlowPath: PATH_JOIN(snapshotRoot, flowRelative), + snapshotFlowSha256: snapshotFlow.sha256, + }); + } catch (error) { + await RM(snapshotRoot, { recursive: true, force: true }); + throw error; + } +} + +export async function hostedBaseSourceDigest( + sources: readonly HostedBaseSourceRoot[], + hooks: HostedBaseSnapshotTestHooks = EMPTY_HOSTED_BASE_HOOKS, +): Promise { + assertHostedPromiseSafety('plugin_source_invalid'); + return sourceDigest(await readAuthorityFiles(sources, hooks)); +} + +export async function removeHostedBaseSnapshot(snapshot: HostedBaseSnapshot): Promise { + await RM(snapshot.snapshotRoot, { recursive: true, force: true }); +} + +async function readAuthorityFiles( + sources: readonly HostedBaseSourceRoot[], + hooks: HostedBaseSnapshotTestHooks = EMPTY_HOSTED_BASE_HOOKS, +): Promise { + const budget: SourceBudget = { entries: 0, bytes: 0 }; + const files: SourceFile[] = []; + for (let sourceIndex = 0; sourceIndex < sources.length; sourceIndex += 1) { + const source = sources[sourceIndex]!; + const sourceFiles = await readTree(source.root, source.prefix, budget, hooks); + for (let fileIndex = 0; fileIndex < sourceFiles.length; fileIndex += 1) { + appendIntrinsicArray(files, sourceFiles[fileIndex]!); + } + } + ARRAY_SORT(files, (left, right) => STRING_LOCALE_COMPARE(left.path, right.path)); + return frozenHostedPromiseValue(files); +} + +async function readSnapshotTree(root: string): Promise { + const files: SourceFile[] = []; + const budget: SourceBudget = { entries: 0, bytes: 0 }; + async function visit(directory: string, prefix: string): Promise { + const entries = await readDirectory(directory); + ARRAY_SORT(entries, (left, right) => STRING_LOCALE_COMPARE(left.name, right.name)); + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]!; + budget.entries += 1; + if (budget.entries > MAX_ENTRIES) throw tooLarge(); + const path = prefix === '' ? entry.name : PATH_JOIN(prefix, entry.name); + const absolute = PATH_JOIN(directory, entry.name); + if (directoryEntryIsDirectory(entry)) await visit(absolute, path); + else if (directoryEntryIsFile(entry)) { + const descriptor = await openDescriptor(absolute, READ_FLAGS); + try { + const before = await statDescriptor(descriptor, { bigint: true }); + if (!descriptorIsFile(before) || before.size < 0n || before.size > BIG_INT(MAX_BYTES - budget.bytes)) + throw tooLarge(); + const expectedBytes = NUMBER(before.size); + const bytes = await readBounded(descriptor, expectedBytes, path); + const after = await statDescriptor(descriptor, { bigint: true }); + if (after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { + throw invalid(`Hosted base snapshot changed while reading "${path}".`); + } + budget.bytes += expectedBytes; + appendIntrinsicArray(files, OBJECT_FREEZE({ path, bytes, sha256: sha256(bytes) })); + } finally { + await closeDescriptor(descriptor); + } + } else throw invalid(`Hosted base snapshot contains unsupported entry "${path}".`); + } + } + await visit(root, ''); + ARRAY_SORT(files, (left, right) => STRING_LOCALE_COMPARE(left.path, right.path)); + return frozenHostedPromiseValue(files); +} + +async function readTree( + root: string, + prefix: string, + budget: SourceBudget, + hooks: HostedBaseSnapshotTestHooks, +): Promise { + if (PROCESS_PLATFORM !== 'linux') { + throw invalid('Hosted base source snapshotting requires Linux.'); + } + const files: SourceFile[] = []; + async function visit(directory: number, relativeDirectory: string, depth: number): Promise { + if (depth > MAX_DEPTH) throw tooLarge(); + const entries = await openDirectory(`/proc/self/fd/${directory}`); + try { + for (;;) { + const entry = await readDirectoryEntry(entries); + if (entry === null) break; + if (SET_HAS(EXCLUDED_DIRECTORIES, entry.name)) continue; + budget.entries += 1; + if (budget.entries > MAX_ENTRIES) throw tooLarge(); + const relativePath = relativeDirectory === '' ? entry.name : PATH_JOIN(relativeDirectory, entry.name); + const absolutePath = PATH_JOIN(root, relativePath); + await ownHook(hooks, 'beforeOpen')?.(absolutePath); + const descriptor = await openDescriptor(`/proc/self/fd/${directory}/${entry.name}`, READ_FLAGS); + try { + const before = await statDescriptor(descriptor, { bigint: true }); + if (descriptorIsDirectory(before)) { + await visit(descriptor, relativePath, depth + 1); + } else if (descriptorIsFile(before)) { + if (before.size < 0n || before.size > BIG_INT(MAX_BYTES - budget.bytes)) throw tooLarge(); + const expectedBytes = NUMBER(before.size); + await ownHook(hooks, 'afterStat')?.(absolutePath); + const bytes = await readBounded(descriptor, expectedBytes, relativePath); + const after = await statDescriptor(descriptor, { bigint: true }); + if (after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { + throw invalid(`Hosted base source changed while reading "${relativePath}".`); + } + budget.bytes += expectedBytes; + appendIntrinsicArray( + files, + OBJECT_FREEZE({ + path: prefix === '' ? relativePath : PATH_JOIN(prefix, relativePath), + bytes, + sha256: sha256(bytes), + }), + ); + } else { + throw invalid(`Hosted base authority contains unsupported entry "${relativePath}".`); + } + } finally { + await closeDescriptor(descriptor); + } + } + } finally { + await closeDirectory(entries); + } + } + try { + const rootDescriptor = await openDescriptor(root, READ_FLAGS); + try { + if (!descriptorIsDirectory(await statDescriptor(rootDescriptor, { bigint: true }))) { + throw invalid('Hosted base source root is not a directory.'); + } + await visit(rootDescriptor, '', 0); + } finally { + await closeDescriptor(rootDescriptor); + } + } catch (error) { + if (error instanceof PluginError) throw error; + throw invalid('Hosted base source or trusted dependency is unreadable.'); + } + return frozenHostedPromiseValue(files); +} + +function ownHook( + hooks: HostedBaseSnapshotTestHooks, + name: keyof HostedBaseSnapshotTestHooks, +): ((path: string) => Promise) | undefined { + const descriptor = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(hooks, name); + return descriptor !== undefined && 'value' in descriptor && typeof descriptor.value === 'function' + ? descriptor.value as (path: string) => Promise + : undefined; +} + +async function readBounded(descriptor: number, expectedBytes: number, relativePath: string): Promise { + const bytes = BUFFER_ALLOC_UNSAFE(expectedBytes); + let offset = 0; + while (offset < expectedBytes) { + const bytesRead = await readDescriptor(descriptor, bytes, offset, expectedBytes - offset, offset); + if (bytesRead === 0) { + throw invalid(`Hosted base source changed while reading "${relativePath}".`); + } + offset += bytesRead; + } + const extra = BUFFER_ALLOC_UNSAFE(1); + if ((await readDescriptor(descriptor, extra, 0, 1, expectedBytes)) !== 0) { + throw invalid(`Hosted base source changed while reading "${relativePath}".`); + } + return hostedPromiseValue(bytes); +} + +function tooLarge(): PluginError { + return invalid('Hosted base source exceeds the snapshot entry or byte limit.'); +} + +function sourceDigest(files: readonly SourceFile[]): string { + const records: Array<{ path: string; sha256: string }> = []; + for (let index = 0; index < files.length; index += 1) { + const file = files[index]!; + appendIntrinsicArray(records, { path: file.path, sha256: file.sha256 }); + } + return sha256(canonicalize(records)); +} + +function sourceFileAt(files: readonly SourceFile[], path: string): SourceFile | undefined { + for (let index = 0; index < files.length; index += 1) { + if (files[index]!.path === path) return files[index]; + } + return undefined; +} + +function invalid(message: string): PluginError { + return new PluginError('plugin_source_invalid', message); +} diff --git a/packages/sdk/src/hosted-extension-declarations.ts b/packages/sdk/src/hosted-extension-declarations.ts new file mode 100644 index 000000000..d3571407a --- /dev/null +++ b/packages/sdk/src/hosted-extension-declarations.ts @@ -0,0 +1,387 @@ +import { + constants, + closeSync, + existsSync, + fstatSync, + openSync, + readSync, +} from "node:fs"; +import { join } from "node:path"; +import { safePath } from "./bundle.js"; +import { canonicalize } from "./canonical.js"; +import { descriptorIsFile } from "./fs-descriptor.js"; +import { snapshotJsonValue } from "./json-value.js"; +import { PluginError } from "./plugin-manifest.js"; +import { appendIntrinsicArray } from "./intrinsic-array.js"; +import { + PLUGIN_LOCK_FILE, + PLUGIN_LOCK_VERSION, + type PluginLockEntry, +} from "./plugin-lock.js"; +import { canonicalPluginRef, type PluginSourceRef } from "./plugin-source.js"; + +const CLOSE_SYNC = closeSync; +const EXISTS_SYNC = existsSync; +const FSTAT_SYNC = fstatSync; +const OPEN_SYNC = openSync; +const READ_SYNC = readSync; +const PATH_JOIN = join; +const ERROR = Error; +const PLUGIN_ERROR_IS_INSTANCE = Function.prototype.call.bind( + Function.prototype[Symbol.hasInstance], + PluginError, +) as (value: unknown) => boolean; +const MAX_DECLARATION_BYTES = 1024 * 1024; +const BIG_INT = BigInt; +const BUFFER_ALLOC_UNSAFE = Buffer.allocUnsafe; +const BUFFER_TO_STRING = Function.prototype.call.bind( + Buffer.prototype.toString, +) as (value: Buffer, encoding: BufferEncoding) => string; +const NUMBER = Number; +const DECLARATION_READ_FLAGS = + constants.O_RDONLY | + (constants.O_NOFOLLOW ?? 0) | + (constants.O_NONBLOCK ?? 0); +const JSON_PARSE = JSON.parse; +const ARRAY_IS_ARRAY = Array.isArray; +const DATE_PARSE = Date.parse; +const NUMBER_IS_NAN = Number.isNaN; +const OBJECT_FREEZE = Object.freeze; +const OBJECT_HAS_OWN = Object.hasOwn; +const OBJECT_KEYS = Object.keys; +const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as ( + regexp: RegExp, + value: string, +) => boolean; +const SET = Set; +const SET_ADD = Function.prototype.call.bind(Set.prototype.add) as ( + set: Set, + value: T, +) => Set; +const SET_HAS = Function.prototype.call.bind(Set.prototype.has) as ( + set: Set, + value: T, +) => boolean; +const STRING_STARTS_WITH = Function.prototype.call.bind( + String.prototype.startsWith, +) as (value: string, search: string) => boolean; +const LOCK_HEX64 = /^[0-9a-f]{64}$/; +const LOCK_SHA = /^[0-9a-f]{40}$/; +const LOCK_OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; +const LOCK_REPO = /^[A-Za-z0-9_.-]{1,100}$/; +const HTTPS_GITHUB = /^https:\/\/github\.com\//; + +export interface HostedDeclaredExtension { + readonly ref: string; + readonly entry: PluginLockEntry; + readonly source: PluginSourceRef; +} + +export function declarationSignature( + declared: readonly HostedDeclaredExtension[], +): string { + const records: Array<{ + ref: string; + name: string; + version: string; + digest: string; + manifestSha256: string; + }> = []; + for (let index = 0; index < declared.length; index += 1) { + const { ref, entry } = declared[index]!; + appendIntrinsicArray(records, { + ref, + name: entry.name, + version: entry.version, + digest: entry.digest, + manifestSha256: entry.manifestSha256, + }); + } + return canonicalize(records); +} + +export function hostedDeclaredExtensions( + root: string, +): readonly HostedDeclaredExtension[] { + if ( + EXISTS_SYNC(PATH_JOIN(root, "flows.json.tmp")) || + EXISTS_SYNC(PATH_JOIN(root, `${PLUGIN_LOCK_FILE}.tmp`)) + ) { + throw new PluginError( + "plugin_lock_invalid", + "Hosted extension declarations have a pending transaction.", + ); + } + let config: unknown; + try { + config = JSON_PARSE( + BUFFER_TO_STRING( + readBoundedDeclaration(PATH_JOIN(root, "flows.json")), + "utf8", + ), + ); + } catch (error) { + if (PLUGIN_ERROR_IS_INSTANCE(error)) throw error; + throw new PluginError( + "plugin_manifest_invalid", + "Invalid or oversized flows.json.", + ); + } + if (typeof config !== "object" || config === null || ARRAY_IS_ARRAY(config)) { + throw new PluginError( + "plugin_manifest_invalid", + "flows.json plugins must be strings.", + ); + } + const plugins = OBJECT_HAS_OWN(config, "plugins") + ? (config as { plugins?: unknown }).plugins + : undefined; + if (plugins !== undefined && !ARRAY_IS_ARRAY(plugins)) { + throw new PluginError( + "plugin_manifest_invalid", + "flows.json plugins must be strings.", + ); + } + const declared: string[] = []; + if (plugins !== undefined) { + for (let index = 0; index < plugins.length; index += 1) { + const ref = plugins[index]; + if (typeof ref !== "string") { + throw new PluginError( + "plugin_manifest_invalid", + "flows.json plugins must be strings.", + ); + } + if (isHostedGithubPluginRef(ref)) appendIntrinsicArray(declared, ref); + } + } + let lock: { + readonly version: 2; + readonly plugins: readonly PluginLockEntry[]; + } = OBJECT_FREEZE({ + version: PLUGIN_LOCK_VERSION, + plugins: OBJECT_FREEZE([]), + }); + const lockPath = PATH_JOIN(root, PLUGIN_LOCK_FILE); + if (EXISTS_SYNC(lockPath)) { + let value: unknown; + try { + value = JSON_PARSE( + BUFFER_TO_STRING(readBoundedDeclaration(lockPath), "utf8"), + ); + } catch (error) { + if (PLUGIN_ERROR_IS_INSTANCE(error)) throw error; + throw new PluginError( + "plugin_lock_invalid", + `${PLUGIN_LOCK_FILE}: not valid or exceeds the hosted size limit.`, + ); + } + lock = parseHostedPluginLock(value); + } + if (declared.length !== lock.plugins.length) { + throw new PluginError( + "plugin_lock_invalid", + "Hosted flows.json and flows.lock.json declarations differ.", + ); + } + const result: HostedDeclaredExtension[] = []; + for (let index = 0; index < lock.plugins.length; index += 1) { + const entry = lock.plugins[index]!; + const source = OBJECT_FREEZE({ ...entry.source, ref: entry.source.sha }); + const ref = canonicalPluginRef(source); + if (declared[index] !== ref) { + throw new PluginError( + "plugin_lock_invalid", + "Hosted flows.lock.json order differs from flows.json.plugins.", + ); + } + appendIntrinsicArray(result, OBJECT_FREEZE({ ref, entry, source })); + } + return OBJECT_FREEZE(result); +} + +function parseHostedPluginLock(input: unknown): { + readonly version: 2; + readonly plugins: readonly PluginLockEntry[]; +} { + let value: unknown; + try { + value = snapshotJsonValue(input, "hosted plugin lock"); + } catch { + return invalidHostedLock( + `expected { version: ${PLUGIN_LOCK_VERSION}, plugins: [] }.`, + ); + } + if ( + !hostedRecord(value) || + value.version !== PLUGIN_LOCK_VERSION || + !ARRAY_IS_ARRAY(value.plugins) || + !hostedHasOnlyKeys(value, ["version", "plugins"]) + ) { + return invalidHostedLock( + `expected { version: ${PLUGIN_LOCK_VERSION}, plugins: [] }.`, + ); + } + const names = new SET(); + const plugins: PluginLockEntry[] = []; + for (let index = 0; index < value.plugins.length; index += 1) { + const entry = value.plugins[index]; + if ( + !hostedRecord(entry) || + !hostedHasOnlyKeys(entry, [ + "digest", + "kind", + "manifestSha256", + "name", + "order", + "resolvedAt", + "source", + "version", + ]) || + entry.kind !== "flow-extension" || + typeof entry.name !== "string" || + typeof entry.version !== "string" || + typeof entry.digest !== "string" || + !REGEXP_TEST(LOCK_HEX64, entry.digest) || + typeof entry.manifestSha256 !== "string" || + !REGEXP_TEST(LOCK_HEX64, entry.manifestSha256) || + entry.order !== index + 1 || + typeof entry.resolvedAt !== "string" || + NUMBER_IS_NAN(DATE_PARSE(entry.resolvedAt)) || + !hostedRecord(entry.source) || + !hostedHasOnlyKeys(entry.source, [ + "host", + "owner", + "repo", + "sha", + "path", + ]) || + entry.source.host !== "github" || + typeof entry.source.owner !== "string" || + !REGEXP_TEST(LOCK_OWNER, entry.source.owner) || + typeof entry.source.repo !== "string" || + !REGEXP_TEST(LOCK_REPO, entry.source.repo) || + entry.source.repo === "." || + entry.source.repo === ".." || + typeof entry.source.sha !== "string" || + !REGEXP_TEST(LOCK_SHA, entry.source.sha) || + typeof entry.source.path !== "string" || + (entry.source.path !== "" && !safePath(entry.source.path)) + ) { + return invalidHostedLock(`plugins[${index}] is malformed.`); + } + if (SET_HAS(names, entry.name)) + return invalidHostedLock(`plugin ${entry.name} is listed twice.`); + SET_ADD(names, entry.name); + appendIntrinsicArray(plugins, OBJECT_FREEZE({ + name: entry.name, + kind: "flow-extension", + version: entry.version, + source: OBJECT_FREEZE({ + host: "github", + owner: entry.source.owner, + repo: entry.source.repo, + sha: entry.source.sha, + path: entry.source.path, + }), + digest: entry.digest, + manifestSha256: entry.manifestSha256, + order: entry.order, + resolvedAt: entry.resolvedAt, + })); + } + return OBJECT_FREEZE({ + version: PLUGIN_LOCK_VERSION, + plugins: OBJECT_FREEZE(plugins), + }); +} + +function isHostedGithubPluginRef(value: string): boolean { + return ( + STRING_STARTS_WITH(value, "github:") || REGEXP_TEST(HTTPS_GITHUB, value) + ); +} + +function hostedRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !ARRAY_IS_ARRAY(value); +} + +function hostedHasOnlyKeys( + value: Record, + allowed: readonly string[], +): boolean { + const keys = OBJECT_KEYS(value); + for (let index = 0; index < keys.length; index += 1) { + let found = false; + for ( + let allowedIndex = 0; + allowedIndex < allowed.length; + allowedIndex += 1 + ) { + if (keys[index] === allowed[allowedIndex]) { + found = true; + break; + } + } + if (!found) return false; + } + return true; +} + +function invalidHostedLock(message: string): never { + throw new PluginError( + "plugin_lock_invalid", + `${PLUGIN_LOCK_FILE}: ${message}`, + ); +} + +function readBoundedDeclaration(path: string): Buffer { + let descriptor: number | undefined; + try { + descriptor = OPEN_SYNC(path, DECLARATION_READ_FLAGS); + const before = FSTAT_SYNC(descriptor, { bigint: true }); + if ( + !descriptorIsFile(before) || + before.size < 0n || + before.size > BIG_INT(MAX_DECLARATION_BYTES) + ) { + throw new PluginError( + "plugin_source_invalid", + "Hosted extension declaration is not a bounded regular file.", + ); + } + const expected = NUMBER(before.size); + const bytes = BUFFER_ALLOC_UNSAFE(expected); + let offset = 0; + while (offset < expected) { + const count = READ_SYNC( + descriptor, + bytes, + offset, + expected - offset, + offset, + ); + if (count === 0) throw new ERROR("short read"); + offset += count; + } + if (READ_SYNC(descriptor, BUFFER_ALLOC_UNSAFE(1), 0, 1, expected) !== 0) + throw new ERROR("grew"); + const after = FSTAT_SYNC(descriptor, { bigint: true }); + if ( + after.size !== before.size || + after.mtimeNs !== before.mtimeNs || + after.ctimeNs !== before.ctimeNs + ) { + throw new ERROR("changed"); + } + return bytes; + } catch (error) { + if (PLUGIN_ERROR_IS_INSTANCE(error)) throw error; + throw new PluginError( + "plugin_source_invalid", + "Hosted extension declaration is unreadable or changed while reading.", + ); + } finally { + if (descriptor !== undefined) CLOSE_SYNC(descriptor); + } +} diff --git a/packages/sdk/src/hosted-extension-isolation.ts b/packages/sdk/src/hosted-extension-isolation.ts new file mode 100644 index 000000000..5e48a2f41 --- /dev/null +++ b/packages/sdk/src/hosted-extension-isolation.ts @@ -0,0 +1,457 @@ +import { canonicalize } from './canonical.js'; +import { sha256 } from './bundle.js'; +import type { FlowExtensionManifest } from './flow-extension-manifest.js'; +import { validateHostedFlowExtensionManifest } from './hosted-extension-manifest.js'; +import { assertBaseCompatible, assertCompatible, runtimeVersions } from './flow-extension-compat.js'; +import { + hostedExtensionDispatchIdentity, + type HostedExtensionDispatch, +} from './flow-extension-loader.js'; +import { + assertHostedInstallationAuthority, + assertHostedRuntimeAuthority, + type HostedExtensionArtifact, + type HostedExtensionBase, + type HostedExtensionInstallation, +} from './hosted-extension-runtime.js'; +import { PluginError } from './plugin-manifest.js'; +import { + assertHostedPromiseSafety, + frozenHostedPromiseValue, +} from './hosted-promise-safety.js'; +import { appendIntrinsicArray } from './intrinsic-array.js'; +import { readStoredPluginFiles } from './plugin-store.js'; +import { + boundedJsonSnapshot, + type HostedExtensionProtocolResult, +} from './hosted-extension-protocol.js'; +import { runHostedExtensionSandbox } from './hosted-extension-sandbox.js'; + +export { + loadHostedExtensionArtifacts, + loadHostedExtensionBase, + loadHostedExtensionRuntime, +} from './hosted-extension-runtime.js'; +export type { + HostedExtensionArtifact, + HostedExtensionBase, + HostedExtensionInstallation, + HostedExtensionRuntime, +} from './hosted-extension-runtime.js'; + +const HOSTED_WRITE = 'cloud:babysitter-turn'; +const ARRAY_IS_ARRAY = Array.isArray; +const BUFFER_TO_STRING = Function.prototype.call.bind(Buffer.prototype.toString) as ( + value: Buffer, + encoding: BufferEncoding, +) => string; +const JSON_PARSE = JSON.parse; +const NUMBER_IS_SAFE_INTEGER = Number.isSafeInteger; +const OBJECT_HAS_OWN = Object.hasOwn; +const OBJECT_FREEZE = Object.freeze; +const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor; +const OBJECT_KEYS = Object.keys; +const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as ( + regexp: RegExp, value: string, +) => boolean; +const STRING_STARTS_WITH = Function.prototype.call.bind(String.prototype.startsWith) as ( + value: string, search: string, +) => boolean; +const BABYSITTER_REF = 'github:AgentWorkforce/flows@8b33ebab8347514f80d9da5a81206a087f641714#extensions/babysitter'; +const BABYSITTER_DIGEST = 'bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee'; +const BABYSITTER_MANIFEST_SHA256 = '5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226'; +const DELIVERY_ID = /^[A-Za-z0-9_.:-]{1,200}$/; +const RECEIPT_ID = /^[A-Za-z0-9_.:-]{1,200}$/; +const OWNER = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/; +const REPOSITORY = /^[A-Za-z0-9._-]{1,100}$/; +const HEAD_SHA = /^[a-f0-9]{40}$/; +const NATIVE_TRIGGERS = [ + { provider: 'github', event: 'pull_request', actions: ['opened', 'synchronize', 'reopened', 'ready_for_review', 'closed', 'labeled', 'unlabeled'] }, + { provider: 'github', event: 'pull_request_review', actions: ['submitted', 'dismissed'] }, + { provider: 'github', event: 'check_run', actions: ['completed'] }, + { provider: 'github', event: 'issue_comment', actions: ['created'] }, +]; + +export interface HostedCapabilityAuthority { + readonly dispatch: HostedExtensionDispatch; + readonly extension: Readonly<{ + name: string; + version: string; + ref: string; + digest: string; + }>; +} + +export interface HostedBabysitterCapability { + queue(request: unknown, authority: HostedCapabilityAuthority): Promise; +} + +export interface RunHostedExtensionOptions { + /** Complete, lock-ordered set returned by loadHostedExtensionRuntime. */ + readonly installation: HostedExtensionInstallation; + /** Same-generation base returned by loadHostedExtensionRuntime. */ + readonly base: HostedExtensionBase; + readonly dispatch: HostedExtensionDispatch; + /** Host-normalized delivery descriptor. Its event identity must equal dispatch. */ + readonly input: unknown; + readonly babysitterTurn: HostedBabysitterCapability; + readonly timeoutMs?: number; + /** Test/packaging override. Production resolves /usr/bin/bwrap. */ + readonly bubblewrapPath?: string; + /** Test/packaging override. Defaults to the current, fingerprinted Node executable. */ + readonly nodePath?: string; + /** Test/packaging override. Production resolves /usr/bin/prlimit. */ + readonly prlimitPath?: string; +} + +export type HostedExtensionResult = HostedExtensionProtocolResult; + +/** + * Execute a capability-only hosted extension in a Linux mount/PID/network/user + * namespace. The extension is first imported inside that namespace. It sees + * no host workspace, environment credentials, network, child process, MCP, + * helpers, harnesses, or base-flow context. The parent exposes exactly one + * authenticated capability adapter and passes the original branded dispatch + * authority to that adapter out of band. + * + * This is deliberately not wired into executeAuthoredFlow yet. #549's refusal + * remains the rollout gate until the Cloud adapter and independent review land. + */ +export async function runHostedCapabilityExtension( + options: RunHostedExtensionOptions, +): Promise { + assertHostedPromiseSafety('plugin_unsupported'); + const identity = hostedExtensionDispatchIdentity(options.dispatch); + await assertHostedRuntimeAuthority(options.installation, options.base); + const { artifact, manifest } = await selectHostedExtensionForRuntime( + options.installation, + options.base, + identity, + runtimeVersions(), + ); + return await runVerifiedNativeExtensionSandbox({ + ...options, + artifact, + manifest, + beforeLaunch: () => assertHostedRuntimeAuthority(options.installation, options.base), + }); +} + +interface RunVerifiedNativeExtensionOptions { + readonly artifact: HostedExtensionArtifact; + readonly manifest: FlowExtensionManifest; + readonly dispatch: HostedExtensionDispatch; + readonly input: unknown; + readonly babysitterTurn: HostedBabysitterCapability; + readonly timeoutMs?: number; + readonly bubblewrapPath?: string; + readonly nodePath?: string; + readonly prlimitPath?: string; + readonly surfaceRoot?: string; + readonly beforeLaunch?: () => Promise; +} + +/** @internal Security-harness seam; not exported from the SDK package root. */ +export async function runVerifiedNativeExtensionSandbox( + options: RunVerifiedNativeExtensionOptions, +): Promise { + assertHostedPromiseSafety('plugin_unsupported'); + assertCapabilityOnlyManifest(options.manifest); + const identity = hostedExtensionDispatchIdentity(options.dispatch); + assertManifestRoutes(options.manifest, identity); + const normalizedInput = babysitterInput(options.input, options.dispatch); + const versions = runtimeVersions(); + const authority: HostedCapabilityAuthority = OBJECT_FREEZE({ + dispatch: options.dispatch, + extension: OBJECT_FREEZE({ + name: options.manifest.name, + version: options.manifest.version, + ref: options.artifact.ref, + digest: options.artifact.digest, + }), + }); + return await runHostedExtensionSandbox({ + artifactDirectory: options.artifact.directory, + artifactDigest: options.artifact.digest, + entry: options.manifest.entry, + surfaceVersion: versions.surface, + identity, + input: normalizedInput, + timeoutMs: ownOption(options, 'timeoutMs'), + bubblewrapPath: ownOption(options, 'bubblewrapPath'), + nodePath: ownOption(options, 'nodePath'), + prlimitPath: ownOption(options, 'prlimitPath'), + surfaceRoot: ownOption(options, 'surfaceRoot'), + beforeLaunch: ownOption<() => Promise>(options, 'beforeLaunch'), + invoke: async request => babysitterReceipt(await options.babysitterTurn.queue( + babysitterRequest(request, normalizedInput, options.dispatch), + authority, + )), + }); +} + +/** @internal Selection seam used by compatibility/security regression tests. */ +export async function selectHostedExtensionForRuntime( + value: HostedExtensionInstallation, + base: HostedExtensionBase, + identity: { readonly provider: string; readonly event: string; readonly action?: string }, + versions: Readonly<{ sdk: string; surface: string }>, +): Promise<{ artifact: HostedExtensionArtifact; manifest: FlowExtensionManifest }> { + assertHostedPromiseSafety('plugin_source_invalid'); + assertHostedInstallationAuthority(value); + if (typeof base !== 'object' || base === null || typeof base.name !== 'string' + || (base.version !== undefined && typeof base.version !== 'string')) { + throw new PluginError('plugin_incompatible', 'Hosted base identity is malformed.'); + } + const matches: Array<{ artifact: HostedExtensionArtifact; manifest: FlowExtensionManifest }> = []; + for (let index = 0; index < value.artifacts.length; index += 1) { + const artifact = value.artifacts[index]!; + const manifest = await verifiedManifest(artifact); + assertCompatible(manifest, versions); + assertBaseCompatible(manifest, base); + if (hostedManifestRoutes(manifest, identity)) appendIntrinsicArray(matches, { artifact, manifest }); + } + if (matches.length > 1) { + throw new PluginError( + 'plugin_event_ambiguous', + `Hosted event matches multiple extension manifests (${manifestNames(matches)}).`, + ); + } + const selected = matches[0]; + if (selected === undefined || selected.artifact.ref !== BABYSITTER_REF) { + throw new PluginError('plugin_event_unroutable', 'Hosted event does not route to the pinned native Babysitter extension.'); + } + await assertPinnedBabysitter(selected.artifact); + assertCapabilityOnlyManifest(selected.manifest); + return frozenHostedPromiseValue(selected); +} + +async function verifiedManifest(artifact: HostedExtensionArtifact): Promise { + if (!matches(/^[a-f0-9]{64}$/, artifact.digest) || !matches(/^[a-f0-9]{64}$/, artifact.manifestSha256)) { + throw new PluginError('plugin_source_drift', `${artifact.ref}: hosted extension digests are malformed.`); + } + const stored = await readStoredPluginFiles(artifact.directory, artifact.digest); + let bytes: Buffer | undefined; + for (let index = 0; index < stored.length; index += 1) { + if (stored[index]!.path === 'flows-plugin.json') bytes = stored[index]!.data; + } + if (bytes === undefined) { + throw new PluginError('plugin_source_drift', `${artifact.ref}: flows-plugin.json is missing.`); + } + if (sha256(bytes) !== artifact.manifestSha256) { + throw new PluginError('plugin_source_drift', `${artifact.ref}: flows-plugin.json differs from the lockfile's manifest hash.`); + } + let input: unknown; + try { input = JSON_PARSE(BUFFER_TO_STRING(bytes, 'utf8')); } + catch { throw new PluginError('plugin_manifest_invalid', `${artifact.ref}: flows-plugin.json is not valid JSON.`); } + const manifest = validateHostedFlowExtensionManifest(input); + if (manifest.name !== artifact.name || manifest.version !== artifact.version) { + throw new PluginError( + 'plugin_source_drift', + `${artifact.ref}: manifest names ${manifest.name}@${manifest.version}, lockfile has ${artifact.name}@${artifact.version}.`, + ); + } + return manifest; +} + +async function assertPinnedBabysitter(artifact: HostedExtensionArtifact): Promise { + if (artifact.ref !== BABYSITTER_REF || artifact.name !== 'babysitter' || artifact.version !== '0.2.0' + || artifact.digest !== BABYSITTER_DIGEST || artifact.manifestSha256 !== BABYSITTER_MANIFEST_SHA256) { + throw new PluginError( + 'plugin_source_invalid', + 'Hosted capability isolation accepts only the reviewed native Babysitter artifact.', + ); + } + const stored = await readStoredPluginFiles(artifact.directory, artifact.digest); + for (let index = 0; index < stored.length; index += 1) { + const path = stored[index]!.path; + if (path === 'node_modules' || STRING_STARTS_WITH(path, 'node_modules/')) { + throw new PluginError('plugin_source_drift', `${artifact.ref}: hosted extensions cannot carry node_modules.`); + } + } +} + +function assertCapabilityOnlyManifest(manifest: FlowExtensionManifest): void { + const expected = { + integrations: ['github'], harnesses: ['codex'], mcp: [], writes: [HOSTED_WRITE], + base: [{ name: 'software-factory', version: '*' }], + budget: { dollars: 1, wallclock: '5m' }, + triggers: NATIVE_TRIGGERS, + }; + const actual = { + integrations: manifest.permissions.integrations, + harnesses: manifest.permissions.harnesses, + mcp: manifest.permissions.mcp, + writes: manifest.permissions.writes, + base: manifest.compat.base, + budget: manifest.permissions.budget, + triggers: manifest.triggers, + }; + if (canonicalize(actual) !== canonicalize(expected) + || manifest.name !== 'babysitter' + || manifest.entry !== 'babysitter.flow.ts' + || !manifest.extends.handlers || manifest.extends.hooks.length !== 0 + || manifest.preflight.credentials.length !== 0 || manifest.preflight.servers.length !== 0) { + throw new PluginError( + 'plugin_unsupported', + `${manifest.name}: hosted capability isolation accepts only the native Babysitter permission profile.`, + ); + } +} + +function assertManifestRoutes( + manifest: FlowExtensionManifest, + identity: { readonly provider: string; readonly event: string; readonly action?: string }, +): void { + if (!hostedManifestRoutes(manifest, identity)) { + throw new PluginError('plugin_event_unroutable', `${manifest.name}: hosted event is not declared exactly once.`); + } +} + +/** Generic handlers overlap action-specific deliveries, matching the SDK handler router. */ +export function hostedManifestRoutes( + manifest: Pick, + identity: { readonly provider: string; readonly event: string; readonly action?: string }, +): boolean { + for (let triggerIndex = 0; triggerIndex < manifest.triggers.length; triggerIndex += 1) { + const trigger = manifest.triggers[triggerIndex]!; + if (trigger.provider !== identity.provider || trigger.event !== identity.event) continue; + if (trigger.actions.length === 0) return true; + if (identity.action === undefined) continue; + for (let actionIndex = 0; actionIndex < trigger.actions.length; actionIndex += 1) { + if (trigger.actions[actionIndex] === identity.action) return true; + } + } + return false; +} + +function babysitterInput(input: unknown, dispatch: HostedExtensionDispatch): unknown { + const snapshot = boundedJsonSnapshot(input, 'hosted extension input'); + const top = exactRecord(snapshot, 'Hosted extension input', ['event', 'pullRequest']); + const event = exactRecord(top.event, 'Hosted extension event', ['provider', 'eventType', 'deliveryId']); + const pullRequest = optionalRecord( + top.pullRequest, + 'Hosted extension pull request', + ['host', 'owner', 'repo', 'number', 'headSha'], + ['owner', 'repo', 'number'], + ); + if (event.provider !== dispatch.provider || event.eventType !== dispatch.eventType + || event.deliveryId !== dispatch.deliveryId || event.provider !== 'github' + || typeof event.deliveryId !== 'string' || !matches(DELIVERY_ID, event.deliveryId) + || (pullRequest.host !== undefined && pullRequest.host !== 'github') + || typeof pullRequest.owner !== 'string' || !matches(OWNER, pullRequest.owner) + || typeof pullRequest.repo !== 'string' || !matches(REPOSITORY, pullRequest.repo) + || pullRequest.repo === '.' || pullRequest.repo === '..' + || typeof pullRequest.number !== 'number' || !NUMBER_IS_SAFE_INTEGER(pullRequest.number) || pullRequest.number <= 0 + || (pullRequest.headSha !== undefined + && (typeof pullRequest.headSha !== 'string' || !matches(HEAD_SHA, pullRequest.headSha)))) { + throw new PluginError('plugin_event_unroutable', 'Hosted extension input does not match the verified GitHub delivery.'); + } + return snapshot; +} + +function babysitterRequest(value: unknown, input: unknown, dispatch: HostedExtensionDispatch): unknown { + const snapshot = boundedJsonSnapshot(value, 'Babysitter capability request'); + const request = exactRecord(snapshot, 'Babysitter capability request', ['delivery']); + const delivery = exactRecord(request.delivery, 'Babysitter delivery', ['deliveryId', 'provider', 'eventType', 'pullRequest']); + const pullRequest = exactRecord(delivery.pullRequest, 'Babysitter pull request', ['owner', 'repository', 'number']); + const normalized = exactRecord(input, 'Hosted extension input', ['event', 'pullRequest']); + const inputPullRequest = record(normalized.pullRequest, 'Hosted extension pull request'); + if (delivery.deliveryId !== dispatch.deliveryId + || typeof delivery.deliveryId !== 'string' || !matches(DELIVERY_ID, delivery.deliveryId) + || delivery.provider !== 'github' || delivery.eventType !== dispatch.eventType + || typeof pullRequest.owner !== 'string' || !matches(OWNER, pullRequest.owner) + || typeof pullRequest.repository !== 'string' || !matches(REPOSITORY, pullRequest.repository) + || pullRequest.repository === '.' || pullRequest.repository === '..' + || typeof pullRequest.number !== 'number' || !NUMBER_IS_SAFE_INTEGER(pullRequest.number) || pullRequest.number <= 0 + || pullRequest.owner !== inputPullRequest.owner + || pullRequest.repository !== inputPullRequest.repo + || pullRequest.number !== inputPullRequest.number) { + throw new PluginError('plugin_event_unroutable', 'Babysitter capability request does not match verified delivery input.'); + } + return snapshot; +} + +function babysitterReceipt(value: unknown): unknown { + const snapshot = boundedJsonSnapshot(value, 'Babysitter capability receipt'); + if (typeof snapshot !== 'object' || snapshot === null || ARRAY_IS_ARRAY(snapshot)) { + throw new PluginError('plugin_unsupported', 'Babysitter capability returned an invalid receipt.'); + } + const receipt = snapshot as Record; + if (!hasExactKeys(receipt, ['receiptId', 'status']) + || typeof receipt.receiptId !== 'string' || !matches(RECEIPT_ID, receipt.receiptId) + || (receipt.status !== 'queued' && receipt.status !== 'duplicate')) { + throw new PluginError('plugin_unsupported', 'Babysitter capability returned an invalid receipt.'); + } + return OBJECT_FREEZE({ receiptId: receipt.receiptId, status: receipt.status }); +} + +function matches(pattern: RegExp, value: string): boolean { + return REGEXP_TEST(pattern, value); +} + +function ownOption(options: object, name: string): T | undefined { + const descriptor = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(options, name); + return descriptor !== undefined && 'value' in descriptor ? descriptor.value as T : undefined; +} + +function record(value: unknown, what: string): Record { + if (typeof value !== 'object' || value === null || ARRAY_IS_ARRAY(value)) { + throw new PluginError('plugin_event_unroutable', `${what} must be an object.`); + } + return value as Record; +} + +function exactRecord(value: unknown, what: string, keys: readonly string[]): Record { + const object = record(value, what); + if (!hasExactKeys(object, keys)) { + throw new PluginError('plugin_event_unroutable', `${what} has an invalid field set.`); + } + return object; +} + +function optionalRecord( + value: unknown, + what: string, + allowed: readonly string[], + required: readonly string[], +): Record { + const object = record(value, what); + const actual = OBJECT_KEYS(object); + for (let index = 0; index < actual.length; index += 1) { + if (!contains(allowed, actual[index]!)) { + throw new PluginError('plugin_event_unroutable', `${what} has an invalid field set.`); + } + } + for (let index = 0; index < required.length; index += 1) { + if (!OBJECT_HAS_OWN(object, required[index]!)) { + throw new PluginError('plugin_event_unroutable', `${what} has an invalid field set.`); + } + } + return object; +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + if (OBJECT_KEYS(value).length !== keys.length) return false; + for (let index = 0; index < keys.length; index += 1) { + if (!OBJECT_HAS_OWN(value, keys[index]!)) return false; + } + return true; +} + +function contains(values: readonly string[], expected: string): boolean { + for (let index = 0; index < values.length; index += 1) { + if (values[index] === expected) return true; + } + return false; +} + +function manifestNames( + matches: readonly { readonly manifest: FlowExtensionManifest }[], +): string { + let value = ''; + for (let index = 0; index < matches.length; index += 1) { + value += `${index === 0 ? '' : ', '}${matches[index]!.manifest.name}`; + } + return value; +} diff --git a/packages/sdk/src/hosted-extension-manifest.ts b/packages/sdk/src/hosted-extension-manifest.ts new file mode 100644 index 000000000..519587f30 --- /dev/null +++ b/packages/sdk/src/hosted-extension-manifest.ts @@ -0,0 +1,249 @@ +import { safePath } from './bundle.js'; +import type { + FlowExtensionCompat, + FlowExtensionManifest, + FlowExtensionPermissions, + FlowExtensionTrigger, +} from './flow-extension-manifest.js'; +import { snapshotJsonValue } from './json-value.js'; +import { frozenHostedPromiseValue } from './hosted-promise-safety.js'; +import { appendIntrinsicArray } from './intrinsic-array.js'; +import { PluginError } from './plugin-manifest.js'; + +const ARRAY_IS_ARRAY = Array.isArray; +const NUMBER_IS_FINITE = Number.isFinite; +const OBJECT_CREATE = Object.create; +const OBJECT_FREEZE = Object.freeze; +const OBJECT_HAS_OWN = Object.hasOwn; +const OBJECT_KEYS = Object.keys; +const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as ( + regexp: RegExp, + value: string, +) => boolean; +const STRING_ENDS_WITH = Function.prototype.call.bind(String.prototype.endsWith) as ( + value: string, + search: string, +) => boolean; +const STRING_STARTS_WITH = Function.prototype.call.bind(String.prototype.startsWith) as ( + value: string, + search: string, +) => boolean; +const STRING_TRIM = Function.prototype.call.bind(String.prototype.trim) as (value: string) => string; + +const TOP_LEVEL = [ + 'schema', 'kind', 'name', 'version', 'description', 'source', 'compat', 'entry', + 'extends', 'triggers', 'gates', 'verbs', 'permissions', 'preflight', 'config', +] as const; +const NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/; +const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/; +const PROVIDER = /^[a-z0-9][a-z0-9-]{0,63}$/; +const WRITE_CLASS = /^[a-z0-9-]+(?::[a-z0-9_-]+)+$/; +const WALLCLOCK = /^\d+(?:ms|s|m|h|d)$/; +const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; +const VERSION_RANGE = /^(?:\*|(?:[\^~]|>=)?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?: <\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)?)$/; +const MAX_DESCRIPTION = 500; +const MAX_LIST = 64; + +/** + * Hosted manifests are parsed after authored code may have executed. Keep this + * projection deliberately narrower than the authoring validator and use only + * module-captured intrinsics. The only manifest that can launch is separately + * pinned by exact ref, payload digest, and manifest digest. + */ +export function validateHostedFlowExtensionManifest(input: unknown): FlowExtensionManifest { + let value: unknown; + try { + value = snapshotJsonValue(input, 'hosted plugin manifest'); + } catch { + return invalid('Hosted plugin manifest must be JSON data.'); + } + if (!record(value) || !hasOnlyKeys(value, TOP_LEVEL)) return invalid('Expected a hosted flow-extension manifest object.'); + if (value.schema !== 2 || value.kind !== 'flow-extension') return invalid('Hosted flow extensions require schema 2.'); + if (typeof value.name !== 'string' || !matches(NAME, value.name) || STRING_STARTS_WITH(value.name, 'helper-')) { + return invalid('name must be lowercase kebab-case and must not start with helper-.'); + } + if (typeof value.version !== 'string' || !matches(VERSION, value.version)) return invalid('version must be semver x.y.z.'); + if (value.description !== undefined + && (typeof value.description !== 'string' || value.description.length > MAX_DESCRIPTION)) { + return invalid(`description must be a string of at most ${MAX_DESCRIPTION} characters.`); + } + if (value.source !== undefined || value.config !== undefined) { + throw new PluginError('plugin_unsupported', 'Hosted capability extensions cannot declare source or config metadata.'); + } + if (typeof value.entry !== 'string' || !STRING_ENDS_WITH(value.entry, '.flow.ts') || !safePath(value.entry)) { + return invalid('entry must be a plugin-relative .flow.ts path.'); + } + const extension = extensionShape(value.extends); + if (!extension.handlers && extension.hooks.length === 0) { + return invalid('A flow extension must contribute handlers or at least one hook.'); + } + for (let index = 0; index < 2; index += 1) { + const field = index === 0 ? 'verbs' : 'gates'; + const at = value[field]; + if (at !== undefined && (!ARRAY_IS_ARRAY(at) || at.length !== 0)) { + return invalid(`A flow extension declares no ${field}; ship a helper plugin beside it.`); + } + } + const preflight = preflightShape(value.preflight); + const output = OBJECT_CREATE(null) as Record; + output.schema = 2; + output.kind = 'flow-extension'; + output.name = value.name; + output.version = value.version; + if (value.description !== undefined) output.description = value.description; + output.compat = compatShape(value.compat); + output.entry = value.entry; + output.extends = extension; + output.triggers = triggerShapes(value.triggers ?? []); + output.permissions = permissionsShape(value.permissions); + output.preflight = preflight; + return frozenHostedPromiseValue(output) as unknown as FlowExtensionManifest; +} + +function extensionShape(value: unknown): FlowExtensionManifest['extends'] { + if (!record(value) || !hasOnlyKeys(value, ['handlers', 'hooks', 'verbs', 'gates']) + || typeof value.handlers !== 'boolean') { + return invalid('extends expects { handlers: boolean, hooks: [] }.'); + } + for (let index = 0; index < 2; index += 1) { + const field = index === 0 ? 'verbs' : 'gates'; + const at = value[field]; + if (at !== undefined && (!ARRAY_IS_ARRAY(at) || at.length !== 0)) { + return invalid(`A flow extension declares no ${field}; ship a helper plugin beside it.`); + } + } + return OBJECT_FREEZE({ handlers: value.handlers, hooks: stringList(value.hooks ?? [], 'extends.hooks', NAME) }); +} + +function compatShape(value: unknown): FlowExtensionCompat { + if (!record(value) || !hasOnlyKeys(value, ['surface', 'sdk', 'base']) + || typeof value.surface !== 'string' || !matches(VERSION_RANGE, value.surface) + || typeof value.sdk !== 'string' || !matches(VERSION_RANGE, value.sdk) + || !ARRAY_IS_ARRAY(value.base) || value.base.length === 0 || value.base.length > MAX_LIST) { + return invalid('compat expects surface, sdk, and at least one base.'); + } + const base: Array<{ readonly name: string; readonly version: string }> = []; + for (let index = 0; index < value.base.length; index += 1) { + const entry = value.base[index]; + if (!record(entry) || !hasOnlyKeys(entry, ['name', 'version']) + || typeof entry.name !== 'string' || STRING_TRIM(entry.name).length === 0 || entry.name.length > 100 + || typeof entry.version !== 'string' || !matches(VERSION_RANGE, entry.version)) { + return invalid('compat.base entries are { name, version range }.'); + } + for (let prior = 0; prior < base.length; prior += 1) { + if (base[prior]!.name === entry.name) return invalid('compat.base names a base flow twice.'); + } + appendIntrinsicArray(base, OBJECT_FREEZE({ name: entry.name, version: entry.version })); + } + return OBJECT_FREEZE({ surface: value.surface, sdk: value.sdk, base: OBJECT_FREEZE(base) }); +} + +function triggerShapes(value: unknown): readonly FlowExtensionTrigger[] { + if (!ARRAY_IS_ARRAY(value) || value.length > MAX_LIST) return invalid('triggers must be a list.'); + const output: FlowExtensionTrigger[] = []; + for (let index = 0; index < value.length; index += 1) { + const entry = value[index]; + if (!record(entry) || !hasOnlyKeys(entry, ['provider', 'event', 'actions']) + || typeof entry.provider !== 'string' || !matches(PROVIDER, entry.provider) + || typeof entry.event !== 'string' || !matches(IDENTIFIER, entry.event)) { + return invalid('triggers entries are { provider, event, actions }.'); + } + for (let prior = 0; prior < output.length; prior += 1) { + if (output[prior]!.provider === entry.provider && output[prior]!.event === entry.event) { + return invalid(`Trigger ${entry.provider}:${entry.event} is declared twice.`); + } + } + appendIntrinsicArray(output, OBJECT_FREEZE({ + provider: entry.provider, + event: entry.event, + actions: stringList(entry.actions, `triggers ${entry.provider}.${entry.event} actions`, IDENTIFIER), + })); + } + return OBJECT_FREEZE(output); +} + +function permissionsShape(value: unknown): FlowExtensionPermissions { + if (!record(value) || !hasOnlyKeys(value, ['integrations', 'harnesses', 'mcp', 'writes', 'budget'])) { + return invalid('permissions expects integrations, harnesses, mcp, writes, and optional budget.'); + } + let budget: FlowExtensionPermissions['budget']; + if (value.budget !== undefined) { + if (!record(value.budget) || !hasOnlyKeys(value.budget, ['dollars', 'wallclock'])) { + return invalid('permissions.budget expects dollars and/or wallclock.'); + } + if (value.budget.dollars !== undefined + && (typeof value.budget.dollars !== 'number' || value.budget.dollars <= 0 + || !NUMBER_IS_FINITE(value.budget.dollars))) { + return invalid('permissions.budget.dollars must be a positive number.'); + } + if (value.budget.wallclock !== undefined + && (typeof value.budget.wallclock !== 'string' || !matches(WALLCLOCK, value.budget.wallclock))) { + return invalid('permissions.budget.wallclock must be a duration such as 45m.'); + } + const projected = OBJECT_CREATE(null) as { dollars?: number; wallclock?: string }; + if (value.budget.dollars !== undefined) projected.dollars = value.budget.dollars; + if (value.budget.wallclock !== undefined) projected.wallclock = value.budget.wallclock; + budget = OBJECT_FREEZE(projected); + } + const output = OBJECT_CREATE(null) as Record; + output.integrations = stringList(value.integrations, 'permissions.integrations', PROVIDER); + output.harnesses = stringList(value.harnesses, 'permissions.harnesses', /^[a-z]+$/); + output.mcp = stringList(value.mcp, 'permissions.mcp', /^[A-Za-z0-9][A-Za-z0-9_.-]{0,99}$/); + output.writes = stringList(value.writes, 'permissions.writes', WRITE_CLASS); + if (budget !== undefined) output.budget = budget; + return OBJECT_FREEZE(output) as unknown as FlowExtensionPermissions; +} + +function preflightShape(value: unknown): FlowExtensionManifest['preflight'] { + if (!record(value) || !hasOnlyKeys(value, ['credentials', 'servers'])) { + return invalid('Preflight requires credentials and servers arrays.'); + } + const servers = stringList(value.servers, 'preflight.servers', /^https?:\/\/\S+$/); + return OBJECT_FREEZE({ + credentials: stringList(value.credentials, 'preflight.credentials', IDENTIFIER), + servers, + }); +} + +function stringList(value: unknown, what: string, pattern: RegExp): readonly string[] { + if (!ARRAY_IS_ARRAY(value) || value.length > MAX_LIST) { + return invalid(`${what} must be a list of at most ${MAX_LIST} strings.`); + } + const output: string[] = []; + for (let index = 0; index < value.length; index += 1) { + const entry = value[index]; + if (typeof entry !== 'string' || !matches(pattern, entry)) return invalid(`${what} has an invalid entry.`); + for (let prior = 0; prior < output.length; prior += 1) { + if (output[prior] === entry) return invalid(`${what} lists ${entry} twice.`); + } + appendIntrinsicArray(output, entry); + } + return OBJECT_FREEZE(output); +} + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !ARRAY_IS_ARRAY(value); +} + +function hasOnlyKeys(value: Record, allowed: readonly string[]): boolean { + const keys = OBJECT_KEYS(value); + for (let index = 0; index < keys.length; index += 1) { + let found = false; + for (let allowedIndex = 0; allowedIndex < allowed.length; allowedIndex += 1) { + if (keys[index] === allowed[allowedIndex]) { + found = true; + break; + } + } + if (!found) return false; + } + return true; +} + +function matches(pattern: RegExp, value: string): boolean { + return REGEXP_TEST(pattern, value); +} + +function invalid(message: string): never { + throw new PluginError('plugin_manifest_invalid', message); +} diff --git a/packages/sdk/src/hosted-extension-protocol.ts b/packages/sdk/src/hosted-extension-protocol.ts new file mode 100644 index 000000000..a468587d7 --- /dev/null +++ b/packages/sdk/src/hosted-extension-protocol.ts @@ -0,0 +1,262 @@ +import { ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { Readable, Writable } from 'node:stream'; +import { clearTimeout as nodeClearTimeout, setTimeout as nodeSetTimeout } from 'node:timers'; +import { snapshotJsonValue } from './json-value.js'; +import { PluginError } from './plugin-manifest.js'; +import { + assertHostedPromiseSafety, + frozenHostedPromiseValue, +} from './hosted-promise-safety.js'; + +const HOSTED_WRITE = 'cloud:babysitter-turn'; +const MAX_FRAME_BYTES = 256 * 1024; +const MAX_STDERR_BYTES = 16 * 1024; +const ARRAY_IS_ARRAY = Array.isArray; +const BUFFER_BYTE_LENGTH = Buffer.byteLength; +const CHILD_PROCESS_KILL = Function.prototype.call.bind(ChildProcess.prototype.kill) as ( + child: ChildProcess, signal?: NodeJS.Signals | number, +) => boolean; +const EVENT_ON = Function.prototype.call.bind(EventEmitter.prototype.on) as ( + emitter: EventEmitter, event: string, listener: (...args: unknown[]) => void, +) => EventEmitter; +const ERROR_IS_INSTANCE = Function.prototype.call.bind( + Function.prototype[Symbol.hasInstance], + Error, +) as (value: unknown) => boolean; +const JSON_PARSE = JSON.parse; +const JSON_STRINGIFY = JSON.stringify; +const OBJECT_CREATE = Object.create; +const OBJECT_HAS_OWN = Object.hasOwn; +const OBJECT_KEYS = Object.keys; +const OBJECT_FREEZE = Object.freeze; +const PROMISE = Promise; +const PROMISE_THEN = Function.prototype.call.bind(Promise.prototype.then) as ( + promise: Promise, + fulfilled: (value: unknown) => void, + rejected: (reason: unknown) => void, +) => Promise; +const READABLE_SET_ENCODING = Function.prototype.call.bind(Readable.prototype.setEncoding) as ( + stream: Readable, encoding: BufferEncoding, +) => Readable; +const READABLE_RESUME = Function.prototype.call.bind(Readable.prototype.resume) as ( + stream: Readable, +) => Readable; +const CLEAR_TIMEOUT = nodeClearTimeout; +const SET_TIMEOUT = nodeSetTimeout; +const TIMER_SAMPLE = SET_TIMEOUT(() => undefined, 0); +const TIMER_UNREF = Function.prototype.call.bind(TIMER_SAMPLE.unref) as ( + timer: ReturnType, +) => ReturnType; +CLEAR_TIMEOUT(TIMER_SAMPLE); +const STRING = String; +const STRING_INDEX_OF = Function.prototype.call.bind(String.prototype.indexOf) as ( + value: string, search: string, +) => number; +const STRING_SLICE = Function.prototype.call.bind(String.prototype.slice) as ( + value: string, start?: number, end?: number, +) => string; +const WRITABLE_END = Function.prototype.call.bind(Writable.prototype.end) as ( + stream: Writable, +) => Writable; +const WRITABLE_WRITE = Function.prototype.call.bind(Writable.prototype.write) as ( + stream: Writable, chunk: string, +) => boolean; +const SNAPSHOT_LIMITS = Object.freeze({ + maxDepth: 64, + maxNodes: 262_144, + maxBytes: MAX_FRAME_BYTES, +}); + +export interface HostedExtensionProtocolResult { + readonly completionReason: 'success'; + readonly capabilityCalls: 1; +} + +/** A bounded JSON copy, stripped of prototypes and behavior. */ +export function boundedJsonSnapshot(value: unknown, what: string): unknown { + let snapshot: ReturnType; + try { snapshot = snapshotJsonValue(value, what, SNAPSHOT_LIMITS); } + catch { + throw new PluginError('plugin_unsupported', `${what} is not bounded JSON data.`); + } + const encoded = JSON_STRINGIFY(snapshot); + if (BUFFER_BYTE_LENGTH(encoded) > MAX_FRAME_BYTES) { + throw new PluginError('plugin_unsupported', `${what} is not bounded JSON data.`); + } + return snapshot; +} + +/** + * Exchange hostile child frames for one parent-owned capability invocation. + * Descriptor 3 is transport, never authority: every frame and both boundary + * payloads are validated by the parent. A child writing descriptor 3 directly + * can request only the same exact, single capability its context exposes. + */ +export async function exchangeHostedExtension( + child: ChildProcess, + protocol: Readable, + stdin: Writable, + stderr: Readable, + timeoutMs: number, + request: unknown, + invoke: (request: unknown) => Promise, +): Promise { + assertHostedPromiseSafety('plugin_unsupported'); + const requestSnapshot = boundedJsonSnapshot(request, 'hosted extension request'); + let buffer = ''; + let stderrText = ''; + let calls = 0; + READABLE_SET_ENCODING(stderr, 'utf8'); + EVENT_ON(stderr, 'data', chunk => { + stderrText = STRING_SLICE(stderrText + STRING(chunk), -MAX_STDERR_BYTES); + }); + READABLE_RESUME(stderr); + READABLE_SET_ENCODING(protocol, 'utf8'); + + return await new PROMISE((resolvePromise, rejectPromise) => { + let settled = false; + let capabilityState: 'none' | 'pending' | 'completed' | 'failed' = 'none'; + let capabilityError: Error | undefined; + let deferredProtocolError: Error | undefined; + const finish = (error?: Error, result?: HostedExtensionProtocolResult) => { + if (settled) return; + settled = true; + CLEAR_TIMEOUT(timeout); + WRITABLE_END(stdin); + if (child.exitCode === null && child.signalCode === null) CHILD_PROCESS_KILL(child, 'SIGKILL'); + if (error !== undefined) rejectPromise(error); + else resolvePromise(result!); + }; + const failure = (error: unknown): Error => ERROR_IS_INSTANCE(error) + ? error as Error + : new PluginError('plugin_unsupported', 'Hosted capability rejected with a non-error value.'); + const refuse = (message: string) => { + const error = new PluginError('plugin_unsupported', message); + CHILD_PROCESS_KILL(child, 'SIGKILL'); + if (capabilityState === 'pending') { + deferredProtocolError ??= error; + return; + } + finish(capabilityError ?? error); + }; + const timeout = SET_TIMEOUT(() => { + CHILD_PROCESS_KILL(child, 'SIGKILL'); + finish(new PluginError( + 'plugin_unsupported', + capabilityState === 'pending' + ? 'Hosted capability outcome is in doubt after the sandbox timeout.' + : 'Hosted extension sandbox timed out.', + )); + }, timeoutMs); + TIMER_UNREF(timeout); + + EVENT_ON(stdin, 'error', () => refuse('Hosted extension capability channel closed.')); + EVENT_ON(protocol, 'error', () => refuse('Hosted extension protocol channel failed.')); + EVENT_ON(protocol, 'data', chunk => { + buffer += STRING(chunk); + if (BUFFER_BYTE_LENGTH(buffer) > MAX_FRAME_BYTES) return refuse('Hosted extension protocol exceeded its size limit.'); + for (;;) { + const end = STRING_INDEX_OF(buffer, '\n'); + if (end < 0) break; + const line = STRING_SLICE(buffer, 0, end); + buffer = STRING_SLICE(buffer, end + 1); + let message: Record; + try { + const parsed = JSON_PARSE(line) as unknown; + if (typeof parsed !== 'object' || parsed === null || ARRAY_IS_ARRAY(parsed)) { + return refuse('Hosted extension emitted a non-object protocol frame.'); + } + message = boundedJsonSnapshot( + parsed, + 'hosted extension protocol frame', + ) as Record; + } catch { return refuse('Hosted extension emitted malformed protocol data.'); } + + if (message.type === 'capability') { + if (!hasExactKeys(message, ['type', 'id', 'name', 'request'])) { + return refuse('Hosted extension emitted a malformed capability frame.'); + } + calls += 1; + if (calls !== 1 || capabilityState !== 'none' || message.name !== HOSTED_WRITE || message.id !== 1) { + return refuse('Hosted extension requested an undeclared or repeated capability.'); + } + capabilityState = 'pending'; + void PROMISE_THEN(invoke(message.request), + value => { + if (settled) return; + try { + const snapshot = boundedJsonSnapshot(value, 'hosted capability result'); + capabilityState = 'completed'; + if (deferredProtocolError !== undefined) return finish(deferredProtocolError); + WRITABLE_WRITE(stdin, capabilityResultFrame(true, snapshot)); + } catch (error) { + capabilityError = failure(error); + capabilityState = 'failed'; + WRITABLE_WRITE(stdin, capabilityResultFrame(false)); + finish(capabilityError); + } + }, + error => { + if (settled) return; + capabilityError = failure(error); + capabilityState = 'failed'; + WRITABLE_WRITE(stdin, capabilityResultFrame(false)); + finish(capabilityError); + }, + ); + } else if (message.type === 'result') { + if (!hasExactKeys(message, ['type', 'completionReason', 'capabilityCalls']) + || calls !== 1 || capabilityState !== 'completed' + || message.completionReason !== 'success' || message.capabilityCalls !== 1) { + return refuse('Hosted extension reported a completion without exactly one capability call.'); + } + finish(undefined, frozenHostedPromiseValue({ completionReason: 'success', capabilityCalls: 1 })); + } else if (message.type === 'error') { + if (!hasExactKeys(message, ['type', 'message']) || typeof message.message !== 'string') { + return refuse('Hosted extension emitted a malformed error frame.'); + } + const error = new PluginError( + 'plugin_unsupported', + `Hosted extension failed: ${STRING_SLICE(message.message, 0, 8192)}`, + ); + if (capabilityState === 'pending') { + deferredProtocolError ??= error; + return; + } + finish(capabilityError ?? error); + } else return refuse('Hosted extension emitted an unknown protocol message.'); + } + }); + READABLE_RESUME(protocol); + EVENT_ON(child, 'error', () => refuse('Hosted extension sandbox could not start.')); + EVENT_ON(child, 'close', code => { + if (!settled) refuse( + `Hosted extension sandbox exited without a valid completion (exit ${code ?? 'signal'})` + + (stderrText === '' ? '' : `: ${stderrText}`), + ); + }); + WRITABLE_WRITE(stdin, `${JSON_STRINGIFY(requestSnapshot)}\n`); + }); +} + +function capabilityResultFrame(ok: boolean, value?: unknown): string { + // JSON.stringify consults inherited `toJSON` before traversing an object. + // Protocol envelopes therefore never inherit from the ambient prototype; + // nested capability data is already a behavior-free bounded snapshot. + const envelope = OBJECT_CREATE(null) as Record; + envelope.type = 'capability-result'; + envelope.id = 1; + envelope.ok = ok; + if (ok) envelope.value = value; + else envelope.error = 'hosted capability refused'; + return `${JSON_STRINGIFY(envelope)}\n`; +} + +function hasExactKeys(value: Record, keys: readonly string[]): boolean { + if (OBJECT_KEYS(value).length !== keys.length) return false; + for (let index = 0; index < keys.length; index += 1) { + if (!OBJECT_HAS_OWN(value, keys[index]!)) return false; + } + return true; +} diff --git a/packages/sdk/src/hosted-extension-runtime.ts b/packages/sdk/src/hosted-extension-runtime.ts new file mode 100644 index 000000000..9fa18aabd --- /dev/null +++ b/packages/sdk/src/hosted-extension-runtime.ts @@ -0,0 +1,266 @@ +import { realpath } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { canonicalize } from './canonical.js'; +import { + createHostedBaseSnapshot, + hostedBaseSourceDigest, + removeHostedBaseSnapshot, + type HostedBaseSourceRoot, +} from './hosted-base-snapshot.js'; +import { appendIntrinsicArray } from './intrinsic-array.js'; +import { + declarationSignature, + hostedDeclaredExtensions, +} from './hosted-extension-declarations.js'; +import { findHostedProject } from './hosted-project.js'; +import { + assertHostedPromiseSafety, + frozenHostedPromiseValue, +} from './hosted-promise-safety.js'; +import { PluginError } from './plugin-manifest.js'; +import { pluginStoreDirectory, verifyStoredPlugin } from './plugin-store.js'; + +const INSTALLATION_AUTHORITY = new WeakSet(); +const BASE_AUTHORITY = new WeakSet(); +const REALPATH = realpath; +const PATH_DIRNAME = dirname; +const PATH_JOIN = join; +const PATH_RESOLVE = resolve; +const SOFTWARE_FACTORY_SHA256 = '49c993220b9c34fab2d4b0e51911656f62b8b657f534d988691960d45bb9d9b6'; +const ARRAY_IS_ARRAY = Array.isArray; +const OBJECT_FREEZE = Object.freeze; +const WEAK_MAP_GET = Function.prototype.call.bind(WeakMap.prototype.get) as ( + map: WeakMap, + key: K, +) => V | undefined; +const WEAK_MAP_SET = Function.prototype.call.bind(WeakMap.prototype.set) as ( + map: WeakMap, + key: K, + value: V, +) => WeakMap; +const WEAK_SET_ADD = Function.prototype.call.bind(WeakSet.prototype.add) as ( + set: WeakSet, + value: T, +) => WeakSet; +const WEAK_SET_HAS = Function.prototype.call.bind(WeakSet.prototype.has) as ( + set: WeakSet, + value: T, +) => boolean; + +interface RuntimeGeneration { + readonly origin: string; + readonly projectRoot: string | undefined; + declarations: string; + sourceRoots: readonly HostedBaseSourceRoot[]; + sourceSha256: string; +} +const INSTALLATION_GENERATION = new WeakMap(); +const BASE_GENERATION = new WeakMap(); + +export interface HostedExtensionArtifact { + readonly ref: string; + readonly name: string; + readonly version: string; + readonly directory: string; + readonly digest: string; + readonly manifestSha256: string; +} + +export interface HostedExtensionInstallation { + readonly artifacts: readonly HostedExtensionArtifact[]; +} + +export interface HostedExtensionBase { + readonly name: string; + readonly version?: string; +} + +export interface HostedExtensionRuntime { + readonly installation: HostedExtensionInstallation; + readonly base: HostedExtensionBase; +} + +/** Load one base/installation generation that cannot be paired across redeploys. */ +export async function loadHostedExtensionRuntime(flowPath: string): Promise { + assertHostedPromiseSafety('plugin_source_invalid'); + const origin = await REALPATH(PATH_RESOLVE(flowPath)); + const generation = newGeneration(origin); + const loaded = await installationAt(origin, generation); + generation.declarations = loaded.declarations; + const base = await baseAt(origin, generation); + if (loaded.declarations !== declaredExtensions(generation).signature) { + throw new PluginError( + 'plugin_source_drift', + 'Hosted extension declarations changed while their runtime generation was loaded.', + ); + } + await assertCurrentGeneration(generation); + OBJECT_FREEZE(generation); + return frozenHostedPromiseValue({ installation: loaded.installation, base }); +} + +/** @internal Metadata-only test seam; public hosted callers use the combined loader. */ +export async function loadHostedExtensionArtifacts(flowPath: string): Promise { + assertHostedPromiseSafety('plugin_source_invalid'); + const origin = await REALPATH(PATH_RESOLVE(flowPath)); + const generation = newGeneration(origin); + const loaded = await installationAt(origin, generation); + generation.declarations = loaded.declarations; + return loaded.installation; +} + +/** @internal Base-only test seam; public hosted callers use the combined loader. */ +export async function loadHostedExtensionBase(flowPath: string): Promise { + assertHostedPromiseSafety('plugin_source_invalid'); + const origin = await REALPATH(PATH_RESOLVE(flowPath)); + const generation = newGeneration(origin); + generation.declarations = declaredExtensions(generation).signature; + return await baseAt(origin, generation); +} + +/** Validate both opaque values and refuse a cached generation after redeploy. */ +export async function assertHostedRuntimeAuthority( + installation: HostedExtensionInstallation, + base: HostedExtensionBase, +): Promise { + if (typeof base !== 'object' || base === null || !WEAK_SET_HAS(BASE_AUTHORITY, base)) { + throw new PluginError( + 'plugin_incompatible', + 'Hosted extension base authority is malformed; use loadHostedExtensionRuntime.', + ); + } + if ( + typeof installation !== 'object' || + installation === null || + !WEAK_SET_HAS(INSTALLATION_AUTHORITY, installation) + ) { + throw new PluginError( + 'plugin_source_invalid', + 'Hosted extension installation authority is malformed; use loadHostedExtensionRuntime.', + ); + } + const generation = WEAK_MAP_GET(BASE_GENERATION, base); + if (generation === undefined || generation !== WEAK_MAP_GET(INSTALLATION_GENERATION, installation)) { + throw new PluginError( + 'plugin_source_invalid', + 'Hosted extension base and installation must originate from the same runtime generation.', + ); + } + await assertCurrentGeneration(generation); +} + +/** @internal Validate a metadata-only installation used by selection tests. */ +export function assertHostedInstallationAuthority(value: unknown): asserts value is HostedExtensionInstallation { + if ( + typeof value !== 'object' || + value === null || + !WEAK_SET_HAS(INSTALLATION_AUTHORITY, value) || + !ARRAY_IS_ARRAY((value as Partial).artifacts) + ) { + throw new PluginError('plugin_source_invalid', 'Hosted extension installation authority is malformed.'); + } +} + +async function installationAt( + origin: string, + generation: RuntimeGeneration, +): Promise<{ + installation: HostedExtensionInstallation; + declarations: string; +}> { + const root = generation.projectRoot; + if (root === undefined) + return frozenHostedPromiseValue({ + installation: installation([], generation), + declarations: canonicalize([]), + }); + const artifacts: HostedExtensionArtifact[] = []; + const declared = hostedDeclaredExtensions(root); + for (let index = 0; index < declared.length; index += 1) { + const { ref, entry } = declared[index]!; + const directory = pluginStoreDirectory(root, entry.name, entry.digest); + await verifyStoredPlugin(directory, entry.digest); + appendIntrinsicArray(artifacts, OBJECT_FREEZE({ + ref, + name: entry.name, + version: entry.version, + directory, + digest: entry.digest, + manifestSha256: entry.manifestSha256, + })); + } + return frozenHostedPromiseValue({ + installation: installation(artifacts, generation), + declarations: declarationSignature(declared), + }); +} + +function declaredExtensions(generation: RuntimeGeneration): { readonly signature: string } { + const root = generation.projectRoot; + return { + signature: root === undefined ? canonicalize([]) : declarationSignature(hostedDeclaredExtensions(root)), + }; +} + +function newGeneration(origin: string): RuntimeGeneration { + return { + origin, + projectRoot: findHostedProject(PATH_DIRNAME(origin)), + declarations: canonicalize([]), + sourceRoots: OBJECT_FREEZE([]), + sourceSha256: '', + }; +} + +async function assertCurrentGeneration(generation: RuntimeGeneration): Promise { + let currentSource: string; + try { + currentSource = await hostedBaseSourceDigest(generation.sourceRoots); + } catch { + throw new PluginError('plugin_source_invalid', 'Hosted extension runtime generation is no longer readable.'); + } + if ( + declaredExtensions(generation).signature !== generation.declarations || + currentSource !== generation.sourceSha256 + ) { + throw new PluginError( + 'plugin_source_invalid', + 'Hosted extension runtime generation is stale; reload the base and installation together.', + ); + } +} + +async function baseAt(origin: string, generation: RuntimeGeneration): Promise { + const snapshot = await createHostedBaseSnapshot(origin, generation.projectRoot ?? PATH_DIRNAME(origin)); + generation.sourceRoots = snapshot.liveSources; + generation.sourceSha256 = snapshot.liveDigest; + try { + if (snapshot.snapshotFlowSha256 !== SOFTWARE_FACTORY_SHA256) { + throw new PluginError( + 'plugin_source_invalid', + 'Hosted capability isolation accepts only the reviewed Software Factory base source.', + ); + } + const value = frozenHostedPromiseValue({ + name: 'software-factory', + version: '2.0.22', + }); + WEAK_SET_ADD(BASE_AUTHORITY, value); + WEAK_MAP_SET(BASE_GENERATION, value, generation); + return value; + } finally { + await removeHostedBaseSnapshot(snapshot); + } +} + +function installation( + artifacts: readonly HostedExtensionArtifact[], + generation: RuntimeGeneration, +): HostedExtensionInstallation { + const artifactCopy: HostedExtensionArtifact[] = []; + for (let index = 0; index < artifacts.length; index += 1) appendIntrinsicArray(artifactCopy, artifacts[index]!); + const value = frozenHostedPromiseValue({ artifacts: frozenHostedPromiseValue(artifactCopy) }); + WEAK_SET_ADD(INSTALLATION_AUTHORITY, value); + WEAK_MAP_SET(INSTALLATION_GENERATION, value, generation); + return value; +} diff --git a/packages/sdk/src/hosted-extension-sandbox-source.ts b/packages/sdk/src/hosted-extension-sandbox-source.ts new file mode 100644 index 000000000..f40e816d1 --- /dev/null +++ b/packages/sdk/src/hosted-extension-sandbox-source.ts @@ -0,0 +1,94 @@ +/** + * Source for the process that imports one hosted extension inside the Linux + * namespace assembled by hosted-extension-isolation.ts. + * + * Keep this dependency-free. The parent writes it to a private file and + * mounts that file read-only; the extension is not imported by the host. + */ +export const HOSTED_EXTENSION_SANDBOX_SOURCE = String.raw` +import { createReadStream, writeSync } from 'node:fs'; +import { createInterface } from 'node:readline'; +import { pathToFileURL } from 'node:url'; + +const lines = createInterface({ input: createReadStream(null, { fd: 0 }), crlfDelay: Infinity }); +const iterator = lines[Symbol.asyncIterator](); +const send = value => writeSync(3, JSON.stringify(value) + '\n'); +const receive = async () => { + const next = await iterator.next(); + if (next.done) throw new Error('hosted capability channel closed'); + return JSON.parse(next.value); +}; +const subscriptionOf = handler => { + const trigger = handler?.trigger; + const filter = trigger?.filter; + if (trigger?.kind !== 'webhook' || filter === undefined + || typeof filter.provider !== 'string' || filter.provider !== trigger.name + || typeof filter.type !== 'string') return undefined; + const action = typeof filter.payload === 'object' && filter.payload !== null && !Array.isArray(filter.payload) + ? filter.payload.action : undefined; + if (action !== undefined && typeof action !== 'string') return undefined; + return action === undefined + ? { provider: filter.provider, event: filter.type } + : { provider: filter.provider, event: filter.type, action }; +}; +const matches = (subscription, identity) => subscription !== undefined + && subscription.provider === identity.provider + && subscription.event === identity.event + && (subscription.action === undefined || subscription.action === identity.action); + +try { + const request = await receive(); + if (request?.type !== 'execute' || typeof request.entry !== 'string' + || typeof request.surfaceRuntime !== 'string' || typeof request.capability !== 'string') { + throw new Error('malformed hosted extension execution request'); + } + const runtime = await import(pathToFileURL(request.surfaceRuntime).href); + const authored = await import(pathToFileURL(request.entry).href); + const definition = runtime.getFlowDefinition(authored.default); + const selected = definition.handlers.filter(handler => matches(subscriptionOf(handler), request.identity)); + if (selected.length !== 1) throw new Error('hosted event must match exactly one isolated extension handler'); + + let nextCall = 1; + let capabilityCalls = 0; + let completion; + const invoke = async value => { + capabilityCalls += 1; + if (capabilityCalls !== 1) throw new Error('hosted extension may invoke its capability once'); + const id = nextCall++; + send({ type: 'capability', id, name: request.capability, request: value }); + const response = await receive(); + if (response?.type !== 'capability-result' || response.id !== id || typeof response.ok !== 'boolean') { + throw new Error('malformed hosted capability response'); + } + if (!response.ok) throw new Error(typeof response.error === 'string' ? response.error : 'hosted capability refused'); + return response.value; + }; + const capabilities = Object.freeze({ + cloud: Object.freeze({ babysitterTurn: Object.freeze({ queue: invoke }) }), + }); + const available = Object.freeze({ + capabilities, + done(reason) { + if (completion !== undefined) throw new Error('hosted extension completed more than once'); + completion = reason; + }, + }); + const context = new Proxy(available, { + get(target, property, receiver) { + if (property !== 'capabilities' && property !== 'done') { + throw new Error('hosted extension context denies ' + String(property)); + } + return Reflect.get(target, property, receiver); + }, + has(_target, property) { return property === 'capabilities' || property === 'done'; }, + ownKeys() { return ['capabilities', 'done']; }, + }); + await selected[0].body(context, request.input); + if (capabilityCalls !== 1) throw new Error('hosted extension did not invoke its capability exactly once'); + if (completion !== 'success') throw new Error('hosted extension did not complete with success'); + send({ type: 'result', completionReason: completion, capabilityCalls }); +} catch (error) { + send({ type: 'error', message: error instanceof Error ? (error.stack ?? error.message) : String(error) }); + process.exitCode = 1; +} +`; diff --git a/packages/sdk/src/hosted-extension-sandbox.ts b/packages/sdk/src/hosted-extension-sandbox.ts new file mode 100644 index 000000000..6af09a00c --- /dev/null +++ b/packages/sdk/src/hosted-extension-sandbox.ts @@ -0,0 +1,489 @@ +import { createRequire } from 'node:module'; +import { + closeSync, + constants, + existsSync, + fstatSync, + lstatSync, + openSync, + readSync, + realpathSync, + type BigIntStats, +} from 'node:fs'; +import { dirname, join, parse, resolve } from 'node:path'; +import { ChildProcess, spawn } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { Writable, type Readable } from 'node:stream'; +import { payloadManifest, sha256 } from './bundle.js'; +import { + closeDescriptor, + descriptorIsFile, + openDescriptor, + readDescriptor, + statDescriptor, +} from './fs-descriptor.js'; +import { snapshotJsonValue } from './json-value.js'; +import { PluginError } from './plugin-manifest.js'; +import { + assertHostedPromiseSafety, + frozenHostedPromiseValue, + hostedPromiseValue, +} from './hosted-promise-safety.js'; +import { appendIntrinsicArray } from './intrinsic-array.js'; +import { HOSTED_EXTENSION_SANDBOX_SOURCE } from './hosted-extension-sandbox-source.js'; +import { + exchangeHostedExtension, + type HostedExtensionProtocolResult, +} from './hosted-extension-protocol.js'; +import { readStoredPluginFiles } from './plugin-store.js'; + +const HOSTED_WRITE = 'cloud:babysitter-turn'; +const CREATE_REQUIRE = createRequire; +const CLOSE_SYNC = closeSync; +const EXISTS_SYNC = existsSync; +const FSTAT_SYNC = fstatSync; +const LSTAT_SYNC = lstatSync; +const OPEN_SYNC = openSync; +const READ_SYNC = readSync; +const REALPATH_SYNC = realpathSync; +const PATH_DIRNAME = dirname; +const PATH_JOIN = join; +const PATH_PARSE = parse; +const PATH_RESOLVE = resolve; +const SPAWN = spawn; +const PROCESS_EXEC_PATH = process.execPath; +const PROCESS_NODE_VERSION = process.versions.node; +const PROCESS_PLATFORM = process.platform; +const PROMISE = Promise; +const EVENT_ON = Function.prototype.call.bind(EventEmitter.prototype.on) as ( + emitter: EventEmitter, event: string, listener: (...args: unknown[]) => void, +) => EventEmitter; +const CHILD_PROCESS_KILL = Function.prototype.call.bind(ChildProcess.prototype.kill) as ( + child: ChildProcess, signal?: NodeJS.Signals | number, +) => boolean; +const ARRAY_IS_ARRAY = Array.isArray; +const BUFFER_FROM = Buffer.from; +const BUFFER_ALLOC_UNSAFE = Buffer.allocUnsafe; +const BUFFER_TO_STRING = Function.prototype.call.bind(Buffer.prototype.toString) as ( + value: Buffer, encoding: BufferEncoding, +) => string; +const BIG_INT = BigInt; +const JSON_PARSE = JSON.parse; +const OBJECT_ENTRIES = Object.entries; +const OBJECT_FREEZE = Object.freeze; +const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor; +const NUMBER = Number; +const NUMBER_IS_SAFE_INTEGER = Number.isSafeInteger; +const REGEXP_EXEC = Function.prototype.call.bind(RegExp.prototype.exec) as ( + regexp: RegExp, value: string, +) => RegExpExecArray | null; +const SET = Set; +const SET_ADD = Function.prototype.call.bind(Set.prototype.add) as (set: Set, value: T) => Set; +const SET_HAS = Function.prototype.call.bind(Set.prototype.has) as (set: Set, value: T) => boolean; +const STRING = String; +const STRING_SPLIT = Function.prototype.call.bind(String.prototype.split) as ( + value: string, separator: string, +) => string[]; +const STRING_STARTS_WITH = Function.prototype.call.bind(String.prototype.startsWith) as ( + value: string, search: string, +) => boolean; +const WRITABLE_END = Function.prototype.call.bind(Writable.prototype.end) as ( + stream: Writable, +) => Writable; +const WRITABLE_WRITE = Function.prototype.call.bind(Writable.prototype.write) as ( + stream: Writable, chunk: string | Uint8Array, +) => boolean; +const DEFAULT_TIMEOUT_MS = 10_000; +const MAX_SURFACE_PACKAGE_BYTES = 64 * 1024; +const MAX_SURFACE_RUNTIME_BYTES = 512 * 1024; +const MAX_NODE_EXECUTABLE_BYTES = 512 * 1024 * 1024; +const SURFACE_READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0); +const NODE_EXECUTABLE = captureNodeExecutable(); +// These hard limits are inherited across prlimit -> bubblewrap -> Node and its +// descendants. RLIMIT_AS stays high enough for Node 22-26's large virtual V8 +// and Wasm reservations; RLIMIT_DATA is the tighter bound on anonymous/native +// allocations (including Buffer mmap on supported Linux kernels). Together +// with 64 MiB old-space they refuse two hostile 2 GiB Buffers without preventing +// the pinned TypeScript handler from starting on supported Node releases. +const ADDRESS_SPACE_BYTES = 16 * 1024 * 1024 * 1024; +const DATA_BYTES = 3 * 1024 * 1024 * 1024; +const SURFACE_RUNTIME_SHA256 = OBJECT_FREEZE({ + 'flow.js': '4aaeacc55de3074f4d121ce7253c3be50a93757e6540ba8159889a9450d1c05c', + 'helpers/providers.js': '7bc62eccaa3a9e786ae0a689bf74160585149e91feef8208e17ef8eca51eed7f', + 'provider-trigger.js': 'e2664c65397f93fb486eb6f1e756c7cec3f88b3851d79c23567cad986f80f1ff', + 'schedule.js': '8fe72f176a75ec0b5f26e12db7a597575c259a2e2cbb59690f9dc20a5e63940b', + 'triggers.js': '4a3515b571a318f6c7a5661f9310bc9af43e3faf20ea39903a9b363c51258e4c', + 'triggers/github.js': 'e312994320f82aad0af00d09c504175d929cc6c601dfe1bc522632462ad9a48b', +}); +const SURFACE_PACKAGE_JSON = '{"name":"@relayflows/surface","type":"module","exports":{".":"./index.js","./runtime":"./runtime.js"}}'; +const SURFACE_INDEX = "export { flow } from './dist/flow.js';\nexport { github } from './dist/triggers/github.js';\n"; +const SURFACE_RUNTIME = "export { getFlowDefinition } from './dist/flow.js';\n"; + +interface SandboxDataFile { + readonly destination: string; + readonly bytes: Uint8Array; +} + +export interface CapturedExecutable { + readonly descriptor: number; + readonly sha256: string; + readonly stat: BigIntStats; +} + +export interface RunHostedExtensionSandboxOptions { + readonly artifactDirectory: string; + readonly artifactDigest: string; + readonly entry: string; + readonly surfaceVersion: string; + readonly identity: { readonly provider: string; readonly event: string; readonly action?: string }; + readonly input: unknown; + readonly invoke: (request: unknown) => Promise; + readonly timeoutMs?: number; + readonly bubblewrapPath?: string; + readonly nodePath?: string; + readonly prlimitPath?: string; + /** @internal Test seam for a package root with the exact pinned runtime bytes. */ + readonly surfaceRoot?: string; + /** @internal Last parent-side authority check after private snapshots exist. */ + readonly beforeLaunch?: () => Promise; +} + +export async function runHostedExtensionSandbox( + options: RunHostedExtensionSandboxOptions, +): Promise { + assertHostedPromiseSafety('plugin_unsupported'); + if (PROCESS_PLATFORM !== 'linux') return unsupported('hosted extension isolation requires Linux'); + const nodeOverride = ownOption(options, 'nodePath'); + if (nodeOverride === undefined && !supportsHostedSandboxFlags(PROCESS_NODE_VERSION)) { + return unsupported(`hosted extension isolation does not support Node ${PROCESS_NODE_VERSION}`); + } + const bwrap = executable(ownOption(options, 'bubblewrapPath') ?? '/usr/bin/bwrap', 'bubblewrap'); + const prlimit = executable(ownOption(options, 'prlimitPath') ?? '/usr/bin/prlimit', 'prlimit'); + const nodeCapture = nodeOverride === undefined + ? NODE_EXECUTABLE + : captureExecutable(nodeOverride, 'Node'); + if (nodeCapture === undefined) return unsupported('Node is unavailable'); + let nodeBytes: Buffer; + try { nodeBytes = readCapturedExecutable(nodeCapture, 'Node'); } + finally { if (nodeOverride !== undefined) CLOSE_SYNC(nodeCapture.descriptor); } + const timeoutMs = ownOption(options, 'timeoutMs') ?? DEFAULT_TIMEOUT_MS; + if (!NUMBER_IS_SAFE_INTEGER(timeoutMs) || timeoutMs < 1 || timeoutMs > 60_000) { + return unsupported('hosted extension timeout must be an integer from 1 to 60000ms'); + } + const entryPath = PATH_RESOLVE(options.artifactDirectory, options.entry); + if (!STRING_STARTS_WITH(entryPath, `${PATH_RESOLVE(options.artifactDirectory)}/`)) { + throw new PluginError('plugin_path_invalid', `${options.entry}: hosted extension entry escapes its artifact.`); + } + const surfaceRoot = await resolveSurfaceRoot( + options.surfaceVersion, + ownOption(options, 'surfaceRoot'), + ); + const storedFiles = await readStoredPluginFiles(options.artifactDirectory, options.artifactDigest); + const payloadFiles: { path: string; data: Buffer }[] = []; + for (let index = 0; index < storedFiles.length; index += 1) { + const file = storedFiles[index]!; + if (file.path !== 'manifest.json') appendIntrinsicArray(payloadFiles, file); + } + if (sha256(payloadManifest(payloadFiles)) !== options.artifactDigest) { + throw new PluginError( + 'plugin_source_drift', + 'Hosted extension changed while its isolated snapshot was created.', + ); + } + const dataFiles: SandboxDataFile[] = [ + { destination: '/runtime/node', bytes: nodeBytes }, + { destination: '/runtime/runner.mjs', bytes: BUFFER_FROM(HOSTED_EXTENSION_SANDBOX_SOURCE) }, + ]; + const surfaceFiles = await readSurfaceFiles(surfaceRoot); + for (let index = 0; index < surfaceFiles.length; index += 1) { + appendIntrinsicArray(dataFiles, surfaceFiles[index]!); + } + for (let index = 0; index < payloadFiles.length; index += 1) { + const file = payloadFiles[index]!; + appendIntrinsicArray(dataFiles, { + destination: `/extension/src/${file.path}`, + bytes: file.data, + }); + } + const destinations: string[] = []; + for (let index = 0; index < dataFiles.length; index += 1) { + appendIntrinsicArray(destinations, dataFiles[index]!.destination); + } + const args = sandboxArguments({ dataDestinations: destinations }); + await ownOption<() => Promise>(options, 'beforeLaunch')?.(); + const commandArgs = [`--as=${ADDRESS_SPACE_BYTES}`, `--data=${DATA_BYTES}`, '--', bwrap]; + for (let index = 0; index < args.length; index += 1) appendIntrinsicArray(commandArgs, args[index]!); + const stdio: Array<'pipe' | 'ignore'> = ['pipe', 'ignore', 'pipe', 'pipe']; + for (let index = 0; index < dataFiles.length; index += 1) appendIntrinsicArray(stdio, 'pipe'); + const child = SPAWN(prlimit, commandArgs, { cwd: '/', env: {}, stdio }); + for (let index = 0; index < dataFiles.length; index += 1) { + const feed = child.stdio[index + 4] as Writable; + EVENT_ON(feed as unknown as EventEmitter, 'error', () => undefined); + WRITABLE_WRITE(feed, dataFiles[index]!.bytes); + WRITABLE_END(feed); + } + const childClosed = child.exitCode !== null || child.signalCode !== null + ? new PROMISE(resolvePromise => resolvePromise()) + : new PROMISE(resolvePromise => { EVENT_ON(child, 'close', () => resolvePromise()); }); + try { + return await exchangeHostedExtension( + child, + child.stdio[3] as Readable, + child.stdin as Writable, + child.stderr as Readable, + timeoutMs, + { + type: 'execute', + entry: `/extension/src/${options.entry}`, + surfaceRuntime: '/extension/node_modules/@relayflows/surface/runtime.js', + capability: HOSTED_WRITE, + identity: options.identity, + input: options.input, + }, + options.invoke, + ); + } finally { + if (child.exitCode === null && child.signalCode === null) CHILD_PROCESS_KILL(child, 'SIGKILL'); + await childClosed; + } +} + +async function resolveSurfaceRoot(expectedVersion: string, override?: string): Promise { + if (override !== undefined) return await checkedSurfaceRoot(override, expectedVersion); + let resolved: string; + try { resolved = REALPATH_SYNC(CREATE_REQUIRE(import.meta.url).resolve('@relayflows/surface')); } + catch { return unsupported('hosted extension cannot resolve @relayflows/surface'); } + let directory = PATH_DIRNAME(resolved); + const root = PATH_PARSE(directory).root; + while (directory !== root) { + const packageJson = PATH_JOIN(directory, 'package.json'); + if (EXISTS_SYNC(packageJson)) { + try { + const manifest = surfacePackageManifest(await readBoundedSurfaceFile(packageJson, MAX_SURFACE_PACKAGE_BYTES)); + if (manifest.name === '@relayflows/surface' && manifest.version === expectedVersion) { + return REALPATH_SYNC(directory); + } + } catch { /* keep walking */ } + } + directory = PATH_DIRNAME(directory); + } + return unsupported('hosted extension resolved an invalid @relayflows/surface package'); +} + +async function checkedSurfaceRoot(root: string, expectedVersion: string): Promise { + let real: string; + try { real = REALPATH_SYNC(root); } + catch { return unsupported('hosted extension cannot resolve @relayflows/surface'); } + try { + const manifest = surfacePackageManifest( + await readBoundedSurfaceFile(PATH_JOIN(real, 'package.json'), MAX_SURFACE_PACKAGE_BYTES), + ); + if (manifest.name === '@relayflows/surface' && manifest.version === expectedVersion) return real; + } catch { /* fall through */ } + return unsupported('hosted extension resolved an invalid @relayflows/surface package'); +} + +async function readSurfaceFiles(surfaceRoot: string): Promise { + const entries = OBJECT_ENTRIES(SURFACE_RUNTIME_SHA256); + const files: SandboxDataFile[] = [ + { destination: '/extension/node_modules/@relayflows/surface/package.json', bytes: BUFFER_FROM(SURFACE_PACKAGE_JSON) }, + { destination: '/extension/node_modules/@relayflows/surface/index.js', bytes: BUFFER_FROM(SURFACE_INDEX) }, + { destination: '/extension/node_modules/@relayflows/surface/runtime.js', bytes: BUFFER_FROM(SURFACE_RUNTIME) }, + ]; + for (let index = 0; index < entries.length; index += 1) { + const file = entries[index]![0]; + const expected = entries[index]![1]; + let bytes: Buffer; + try { bytes = await readBoundedSurfaceFile(PATH_JOIN(surfaceRoot, 'dist', file), MAX_SURFACE_RUNTIME_BYTES); } + catch { return unsupported(`hosted extension cannot read pinned Surface runtime ${file}`); } + if (sha256(bytes) !== expected) { + return unsupported(`hosted extension Surface runtime ${file} differs from the reviewed bytes`); + } + appendIntrinsicArray(files, { + destination: `/extension/node_modules/@relayflows/surface/dist/${file}`, + bytes, + }); + } + return frozenHostedPromiseValue(files); +} + +function surfacePackageManifest(bytes: Buffer): { readonly name?: unknown; readonly version?: unknown } { + const value = snapshotJsonValue(JSON_PARSE(BUFFER_TO_STRING(bytes, 'utf8')), 'Surface package manifest', { + maxBytes: MAX_SURFACE_PACKAGE_BYTES, + maxDepth: 3, + maxNodes: 64, + }); + if (typeof value !== 'object' || value === null || ARRAY_IS_ARRAY(value)) { + return unsupported('hosted extension resolved an invalid @relayflows/surface package'); + } + return value as { readonly name?: unknown; readonly version?: unknown }; +} + +async function readBoundedSurfaceFile(path: string, maxBytes: number): Promise { + const descriptor = await openDescriptor(path, SURFACE_READ_FLAGS); + try { + const before = await statDescriptor(descriptor, { bigint: true }); + if (!descriptorIsFile(before) || before.size < 0n || before.size > BIG_INT(maxBytes)) { + return unsupported(`hosted extension cannot read bounded Surface file ${path}`); + } + const size = NUMBER(before.size); + const bytes = BUFFER_ALLOC_UNSAFE(size); + let offset = 0; + while (offset < size) { + const bytesRead = await readDescriptor(descriptor, bytes, offset, size - offset, offset); + if (bytesRead === 0) return unsupported(`hosted extension Surface file ${path} changed while reading`); + offset += bytesRead; + } + const extra = BUFFER_ALLOC_UNSAFE(1); + if ((await readDescriptor(descriptor, extra, 0, 1, size)) !== 0) { + return unsupported(`hosted extension Surface file ${path} changed while reading`); + } + const after = await statDescriptor(descriptor, { bigint: true }); + if (after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { + return unsupported(`hosted extension Surface file ${path} changed while reading`); + } + return hostedPromiseValue(bytes); + } finally { + await closeDescriptor(descriptor); + } +} + +/** @internal Pure construction seam for hostile-intrinsic regressions. */ +export function sandboxArguments(input: { + dataDestinations: readonly string[]; +}): string[] { + const args = [ + '--unshare-all', '--die-with-parent', '--new-session', '--clearenv', '--cap-drop', 'ALL', '--dir', '/usr', + ]; + const libraryPaths = ['/usr/lib', '/usr/lib64', '/lib', '/lib64']; + for (let index = 0; index < libraryPaths.length; index += 1) { + const path = libraryPaths[index]!; + if (EXISTS_SYNC(path)) appendIntrinsicArray(args, '--ro-bind', REALPATH_SYNC(path), path); + } + appendIntrinsicArray(args, + '--proc', '/proc', '--dev', '/dev', '--tmpfs', '/tmp', + '--dir', '/runtime', + '--dir', '/extension', '--dir', '/extension/node_modules', '--dir', '/extension/node_modules/@relayflows', + '--dir', '/extension/node_modules/@relayflows/surface', + '--dir', '/extension/node_modules/@relayflows/surface/dist', + '--dir', '/extension/node_modules/@relayflows/surface/dist/helpers', + '--dir', '/extension/node_modules/@relayflows/surface/dist/triggers', + '--dir', '/extension/src', + ); + const directories = new SET(); + const fixedDirectories = [ + '/runtime', '/extension', '/extension/node_modules', '/extension/node_modules/@relayflows', + '/extension/node_modules/@relayflows/surface', '/extension/node_modules/@relayflows/surface/dist', + '/extension/node_modules/@relayflows/surface/dist/helpers', + '/extension/node_modules/@relayflows/surface/dist/triggers', '/extension/src', + ]; + for (let index = 0; index < fixedDirectories.length; index += 1) { + SET_ADD(directories, fixedDirectories[index]!); + } + for (let index = 0; index < input.dataDestinations.length; index += 1) { + const parts = STRING_SPLIT(input.dataDestinations[index]!, '/'); + let directory = ''; + for (let partIndex = 1; partIndex < parts.length - 1; partIndex += 1) { + directory += `/${parts[partIndex]!}`; + if (!SET_HAS(directories, directory)) { + SET_ADD(directories, directory); + appendIntrinsicArray(args, '--dir', directory); + } + } + } + for (let index = 0; index < input.dataDestinations.length; index += 1) { + const destination = input.dataDestinations[index]!; + appendIntrinsicArray( + args, + '--perms', destination === '/runtime/node' ? '0500' : '0400', + '--ro-bind-data', STRING(index + 4), destination, + ); + } + appendIntrinsicArray(args, + '--chdir', '/extension/src', + '--setenv', 'HOME', '/tmp', '--setenv', 'TMPDIR', '/tmp', '--setenv', 'PATH', '/runtime', + '/runtime/node', '--permission', '--experimental-strip-types', '--max-old-space-size=64', + '--allow-fs-read=/runtime', '--allow-fs-read=/extension', '/runtime/runner.mjs', + ); + return args; +} + +function captureNodeExecutable(): CapturedExecutable | undefined { + try { return captureExecutable(PROCESS_EXEC_PATH, 'Node'); } + catch { return undefined; } +} + +/** @internal Descriptor-pinning seam for the executable replacement regression. */ +export function captureExecutable(path: string, name: string): CapturedExecutable { + let real: string; + try { real = REALPATH_SYNC(path); } + catch { return unsupported(`${name} is unavailable`); } + let descriptor: number; + try { descriptor = OPEN_SYNC(real, SURFACE_READ_FLAGS); } + catch { return unsupported(`${name} is unavailable`); } + try { + const stat = FSTAT_SYNC(descriptor, { bigint: true }); + if (!descriptorIsFile(stat) || stat.size < 1n || stat.size > BIG_INT(MAX_NODE_EXECUTABLE_BYTES)) { + return unsupported(`${name} is not a bounded regular file`); + } + const bytes = readExecutableDescriptor(descriptor, NUMBER(stat.size), name); + const after = FSTAT_SYNC(descriptor, { bigint: true }); + if (after.dev !== stat.dev || after.ino !== stat.ino || after.size !== stat.size + || after.mtimeNs !== stat.mtimeNs || after.ctimeNs !== stat.ctimeNs) { + return unsupported(`${name} changed while being captured`); + } + return OBJECT_FREEZE({ descriptor, sha256: sha256(bytes), stat }); + } catch (error) { + CLOSE_SYNC(descriptor); + throw error; + } +} + +/** @internal Reads the inode captured before authored code could replace its path. */ +export function readCapturedExecutable(capture: CapturedExecutable, name: string): Buffer { + const size = NUMBER(capture.stat.size); + const bytes = readExecutableDescriptor(capture.descriptor, size, name); + const after = FSTAT_SYNC(capture.descriptor, { bigint: true }); + if (after.dev !== capture.stat.dev || after.ino !== capture.stat.ino + || after.size !== capture.stat.size || sha256(bytes) !== capture.sha256) { + return unsupported(`${name} changed while reading`); + } + return bytes; +} + +function readExecutableDescriptor(descriptor: number, size: number, name: string): Buffer { + const bytes = BUFFER_ALLOC_UNSAFE(size); + let offset = 0; + while (offset < size) { + const bytesRead = READ_SYNC(descriptor, bytes, offset, size - offset, offset); + if (bytesRead === 0) return unsupported(`${name} changed while reading`); + offset += bytesRead; + } + return bytes; +} + + +function executable(path: string, name: string): string { + let real: string; + try { real = REALPATH_SYNC(path); } + catch { return unsupported(`${name} is unavailable`); } + if (!descriptorIsFile(LSTAT_SYNC(real))) return unsupported(`${name} is not a regular file`); + return real; +} + +function ownOption(options: object, name: string): T | undefined { + const descriptor = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(options, name); + return descriptor !== undefined && 'value' in descriptor ? descriptor.value as T : undefined; +} + +export function supportsHostedSandboxFlags(version: string): boolean { + const match = REGEXP_EXEC(/^(\d+)\.(\d+)\.(\d+)(?:-|$)/, version); + if (match === null) return false; + const major = NUMBER(match[1]); + const minor = NUMBER(match[2]); + return major >= 24 || (major === 23 && minor >= 5) || (major === 22 && minor >= 13); +} + +function unsupported(message: string): never { + throw new PluginError('plugin_unsupported', message); +} diff --git a/packages/sdk/src/hosted-project.ts b/packages/sdk/src/hosted-project.ts new file mode 100644 index 000000000..26ac743b1 --- /dev/null +++ b/packages/sdk/src/hosted-project.ts @@ -0,0 +1,18 @@ +import { existsSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; + +const EXISTS_SYNC = existsSync; +const PATH_DIRNAME = dirname; +const PATH_JOIN = join; +const PATH_RESOLVE = resolve; + +/** Project discovery for hosted authority, captured before authored code runs. */ +export function findHostedProject(start: string): string | undefined { + let current = PATH_RESOLVE(start); + for (;;) { + if (EXISTS_SYNC(PATH_JOIN(current, 'flows.json'))) return current; + const parent = PATH_DIRNAME(current); + if (parent === current) return undefined; + current = parent; + } +} diff --git a/packages/sdk/src/hosted-promise-safety.ts b/packages/sdk/src/hosted-promise-safety.ts new file mode 100644 index 000000000..a6c6024ac --- /dev/null +++ b/packages/sdk/src/hosted-promise-safety.ts @@ -0,0 +1,68 @@ +import { PluginError, type PluginFailureKind } from './plugin-manifest.js'; + +const OBJECT_DEFINE_PROPERTY = Object.defineProperty; +const OBJECT_FREEZE = Object.freeze; +const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor; +const OBJECT_PROTOTYPE = Object.prototype; +const INERT_THEN = { + configurable: false, + enumerable: false, + value: undefined, + writable: false, +} as const; +const PROTOTYPE_THEN_GET = () => undefined; +const PROTOTYPE_THEN_SET = function setOwnThen(this: object, value: unknown): void { + if (this === OBJECT_PROTOTYPE) return; + // Preserve ordinary `object.then = value` behavior for dependencies while + // keeping the inherited slot immutable. Assignment reaches this setter only + // when the receiver does not already have its own `then` property. + OBJECT_DEFINE_PROPERTY(this, 'then', { + configurable: true, + enumerable: true, + value, + writable: true, + }); +}; +const LOCKED_PROTOTYPE_THEN = { + configurable: false, + enumerable: false, + get: PROTOTYPE_THEN_GET, + set: PROTOTYPE_THEN_SET, +} as const; + +// Promise assimilation consults the prototype after every async operation, +// not just at the public entry point. Lock this one dangerous slot while the +// realm is pristine so authored code cannot install a thenable between awaits. +const initialThen = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(OBJECT_PROTOTYPE, 'then'); +if (initialThen === undefined) OBJECT_DEFINE_PROPERTY(OBJECT_PROTOTYPE, 'then', LOCKED_PROTOTYPE_THEN); +const installedThen = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(OBJECT_PROTOTYPE, 'then'); +const PROMISE_PROTOTYPE_SAFE = installedThen !== undefined + && installedThen.get === PROTOTYPE_THEN_GET + && installedThen.set === PROTOTYPE_THEN_SET + && installedThen.configurable === false + && installedThen.enumerable === false; + +/** + * Promise resolution reads a returned object's inherited `then` property. + * Refuse if module initialization did not begin in a pristine realm where the + * inert prototype slot could be locked for the lifetime of the process. + */ +export function assertHostedPromiseSafety( + code: PluginFailureKind, + message = 'Hosted isolation refuses an ambient Object.prototype.then.', +): void { + if (!PROMISE_PROTOTYPE_SAFE) { + throw new PluginError(code, message); + } +} + +/** Shadow inherited thenables before an object is used to settle a promise. */ +export function hostedPromiseValue(value: T): T { + OBJECT_DEFINE_PROPERTY(value, 'then', INERT_THEN); + return value; +} + +/** Freeze a behavior-free object after shadowing inherited thenables. */ +export function frozenHostedPromiseValue(value: T): Readonly { + return OBJECT_FREEZE(hostedPromiseValue(value)); +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index f65fa12df..ccba51a44 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -58,6 +58,24 @@ export type { HeaderBudget } from './budget.js'; export type { StepSpend } from './protocol.js'; export { SPEC_SCHEMA_VERSION } from './spec.js'; +export { + runHostedCapabilityExtension, + loadHostedExtensionRuntime, + type HostedExtensionArtifact, + type HostedExtensionInstallation, + type HostedExtensionBase, + type HostedExtensionRuntime, + type HostedCapabilityAuthority, + type HostedBabysitterCapability, + type RunHostedExtensionOptions, + type HostedExtensionResult, +} from './hosted-extension-isolation.js'; +export { + hostedExtensionDispatchFromVerifiedDelivery, + type HostedExtensionDispatch, + type HostedEventIdentity, +} from './flow-extension-loader.js'; + export { CloudFlowError, type CloudConnectionOptions, type CloudConfigurationReason } from './cloud-http.js'; export { listCloudRuns, getCloudRunDetail, getCloudRunSteps, getCloudRunLog, diff --git a/packages/sdk/src/intrinsic-array.ts b/packages/sdk/src/intrinsic-array.ts new file mode 100644 index 000000000..b19fbd1cb --- /dev/null +++ b/packages/sdk/src/intrinsic-array.ts @@ -0,0 +1,14 @@ +const OBJECT_DEFINE_PROPERTY = Object.defineProperty; + +/** Append without consulting an inherited numeric setter on Array.prototype. */ +export function appendIntrinsicArray(array: T[], ...values: T[]): number { + for (let index = 0; index < values.length; index += 1) { + OBJECT_DEFINE_PROPERTY(array, array.length, { + configurable: true, + enumerable: true, + value: values[index]!, + writable: true, + }); + } + return array.length; +} diff --git a/packages/sdk/src/json-value.ts b/packages/sdk/src/json-value.ts index ec49f8c09..9785c51b7 100644 --- a/packages/sdk/src/json-value.ts +++ b/packages/sdk/src/json-value.ts @@ -1,4 +1,38 @@ import { isProxy } from 'node:util/types'; +import { appendIntrinsicArray } from './intrinsic-array.js'; + +const ARRAY_IS_ARRAY = Array.isArray; +const ARRAY_PROTOTYPE = Array.prototype; +const ERROR = Error; +const IS_PROXY = isProxy; +const JSON_STRINGIFY = JSON.stringify; +const NUMBER = Number; +const NUMBER_IS_FINITE = Number.isFinite; +const NUMBER_IS_INTEGER = Number.isInteger; +const OBJECT_CREATE = Object.create; +const OBJECT_DEFINE_PROPERTY = Object.defineProperty; +const OBJECT_FREEZE = Object.freeze; +const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor; +const OBJECT_GET_PROTOTYPE_OF = Object.getPrototypeOf; +const OBJECT_HAS_OWN = Object.hasOwn; +const OBJECT_PROTOTYPE = Object.prototype; +const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as ( + regexp: RegExp, value: string, +) => boolean; +const STRING = String; +const STRING_CHAR_CODE_AT = Function.prototype.call.bind(String.prototype.charCodeAt) as ( + value: string, index: number, +) => number; +const WEAK_SET = WeakSet; +const WEAK_SET_ADD = Function.prototype.call.bind(WeakSet.prototype.add) as ( + set: WeakSet, value: T, +) => WeakSet; +const WEAK_SET_DELETE = Function.prototype.call.bind(WeakSet.prototype.delete) as ( + set: WeakSet, value: T, +) => boolean; +const WEAK_SET_HAS = Function.prototype.call.bind(WeakSet.prototype.has) as ( + set: WeakSet, value: T, +) => boolean; export type JsonValue = | null @@ -8,15 +42,45 @@ export type JsonValue = | JsonValue[] | { [key: string]: JsonValue }; +export interface JsonSnapshotLimits { + readonly maxDepth: number; + readonly maxNodes: number; + readonly maxBytes: number; +} + +interface SnapshotBudget { + readonly limits?: JsonSnapshotLimits; + nodes: number; + properties: number; + bytes: number; +} + /** Copy runtime input into frozen, behavior-free JSON data. */ -export function snapshotJsonValue(value: unknown, at: string): JsonValue { - return snapshot(value, at, new WeakSet()); +export function snapshotJsonValue(value: unknown, at: string, limits?: JsonSnapshotLimits): JsonValue { + return snapshot(value, at, new WEAK_SET(), { limits, nodes: 0, properties: 0, bytes: 0 }, 0); } -function snapshot(value: unknown, at: string, ancestors: WeakSet): JsonValue { - if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; +function snapshot( + value: unknown, + at: string, + ancestors: WeakSet, + budget: SnapshotBudget, + depth: number, +): JsonValue { + consumeNode(budget, at, depth); + if (typeof value === 'string') { + consumeStringBytes(budget, value, at); + return value; + } + if (value === null || typeof value === 'boolean') { + consumeBytes(budget, value === null ? 4 : value ? 4 : 5, at); + return value; + } if (typeof value === 'number') { - if (Number.isFinite(value)) return value; + if (NUMBER_IS_FINITE(value)) { + consumeBytes(budget, STRING(value).length, at); + return value; + } throw nonJson(at, 'numbers must be finite'); } if (typeof value !== 'object') { @@ -25,15 +89,15 @@ function snapshot(value: unknown, at: string, ancestors: WeakSet): JsonV // Every ordinary reflective operation on a Proxy can execute author code. // Node and Bun expose this trap-free brand check, so reject before touching // its prototype, keys, descriptors, or identity collection. - if (isProxy(value)) throw nonJson(at, 'Proxy objects are not allowed'); - if (ancestors.has(value)) throw nonJson(at, 'cycles are not allowed'); - ancestors.add(value); + if (IS_PROXY(value)) throw nonJson(at, 'Proxy objects are not allowed'); + if (WEAK_SET_HAS(ancestors, value)) throw nonJson(at, 'cycles are not allowed'); + WEAK_SET_ADD(ancestors, value); try { - return Array.isArray(value) - ? snapshotArray(value, at, ancestors) - : snapshotObject(value, at, ancestors); + return ARRAY_IS_ARRAY(value) + ? snapshotArray(value, at, ancestors, budget, depth) + : snapshotObject(value, at, ancestors, budget, depth); } finally { - ancestors.delete(value); + WEAK_SET_DELETE(ancestors, value); } } @@ -41,53 +105,88 @@ function snapshotArray( value: unknown[], at: string, ancestors: WeakSet, + budget: SnapshotBudget, + depth: number, ): JsonValue[] { - const keys = Reflect.ownKeys(value); - for (const key of keys) { - if (key === 'length') continue; - if (typeof key !== 'string' || !isArrayIndex(key, value.length)) { + if (OBJECT_GET_PROTOTYPE_OF(value) !== ARRAY_PROTOTYPE + || OBJECT_GET_PROTOTYPE_OF(ARRAY_PROTOTYPE) !== OBJECT_PROTOTYPE) { + throw nonJson(at, 'only arrays with the intrinsic prototype are allowed'); + } + consumeBytes(budget, 2, at); + if (budget.limits !== undefined && value.length > budget.limits.maxNodes - budget.nodes) { + throw nonJson(at, 'snapshot depth or node limit exceeded'); + } + // `Reflect.ownKeys` allocates the complete key list before a caller can + // enforce cardinality. Enumerate JSON-visible keys one at a time instead. + // Symbols and non-enumerable properties are deliberately ignored, matching + // JSON.stringify; the copied value has neither, so they cannot affect later + // validation or serialization. + for (const key in value) { + consumeProperty(budget, at); + if (!OBJECT_HAS_OWN(value, key)) continue; + if (!isArrayIndex(key, value.length)) { throw nonJson(at, 'arrays may contain only indexed data'); } } const out: JsonValue[] = []; for (let index = 0; index < value.length; index += 1) { - const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (index > 0) consumeBytes(budget, 1, at); + const descriptor = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(value, STRING(index)); if (descriptor === undefined) throw nonJson(`${at}[${index}]`, 'array holes are not allowed'); - out.push(snapshotDescriptor(descriptor, `${at}[${index}]`, ancestors)); + appendIntrinsicArray(out, snapshotDescriptor(descriptor, `${at}[${index}]`, ancestors, budget, depth + 1)); } - return Object.freeze(out) as unknown as JsonValue[]; + // JSON.stringify consults `toJSON` before applying array semantics. Shadow + // any poisoned Array.prototype hook with inert, non-JSON-visible data while + // retaining the intrinsic prototype expected by downstream array consumers. + OBJECT_DEFINE_PROPERTY(out, 'toJSON', { value: undefined }); + return OBJECT_FREEZE(out) as unknown as JsonValue[]; } function snapshotObject( value: object, at: string, ancestors: WeakSet, + budget: SnapshotBudget, + depth: number, ): { [key: string]: JsonValue } { - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) { + const prototype = OBJECT_GET_PROTOTYPE_OF(value); + if (prototype !== OBJECT_PROTOTYPE && prototype !== null) { throw nonJson(at, 'only plain objects are allowed'); } - const out = Object.create(null) as { [key: string]: JsonValue }; - for (const key of Reflect.ownKeys(value)) { - if (typeof key !== 'string') throw nonJson(at, 'symbol keys are not allowed'); + consumeBytes(budget, 2, at); + const out = OBJECT_CREATE(null) as { [key: string]: JsonValue }; + let included = 0; + // Avoid materializing an attacker-sized key array in the unsandboxed + // parent. This visits only JSON-visible own string properties; symbols and + // non-enumerable properties are omitted exactly as JSON.stringify omits + // them, and cannot influence the null-prototype copy. + for (const key in value) { + consumeProperty(budget, at); + if (!OBJECT_HAS_OWN(value, key)) continue; const childAt = propertyPath(at, key); - const descriptor = Object.getOwnPropertyDescriptor(value, key); + const descriptor = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(value, key); if (descriptor === undefined) throw nonJson(childAt, 'missing property descriptor'); const child = descriptorValue(descriptor, childAt); // JSON.stringify and the pre-existing compiler omit undefined object // optionals. Arrays remain strict because undefined there becomes null. if (child === undefined) continue; - out[key] = snapshot(child, childAt, ancestors); + if (included > 0) consumeBytes(budget, 1, at); + consumeStringBytes(budget, key, childAt); + consumeBytes(budget, 1, childAt); + out[key] = snapshot(child, childAt, ancestors, budget, depth + 1); + included += 1; } - return Object.freeze(out); + return OBJECT_FREEZE(out); } function snapshotDescriptor( descriptor: PropertyDescriptor, at: string, ancestors: WeakSet, + budget: SnapshotBudget, + depth: number, ): JsonValue { - return snapshot(descriptorValue(descriptor, at), at, ancestors); + return snapshot(descriptorValue(descriptor, at), at, ancestors, budget, depth); } function descriptorValue(descriptor: PropertyDescriptor, at: string): unknown { @@ -97,14 +196,67 @@ function descriptorValue(descriptor: PropertyDescriptor, at: string): unknown { } function isArrayIndex(key: string, length: number): boolean { - const index = Number(key); - return Number.isInteger(index) && index >= 0 && index < length && String(index) === key; + const index = NUMBER(key); + return NUMBER_IS_INTEGER(index) && index >= 0 && index < length && STRING(index) === key; } function propertyPath(at: string, key: string): string { - return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? `${at}.${key}` : `${at}[${JSON.stringify(key)}]`; + if (key.length > 100) return `${at}[long property]`; + return REGEXP_TEST(/^[A-Za-z_$][A-Za-z0-9_$]*$/, key) + ? `${at}.${key}` + : `${at}[${JSON_STRINGIFY(key)}]`; +} + +function consumeNode(budget: SnapshotBudget, at: string, depth: number): void { + budget.nodes += 1; + if (budget.limits !== undefined + && (depth > budget.limits.maxDepth || budget.nodes > budget.limits.maxNodes)) { + throw nonJson(at, 'snapshot depth or node limit exceeded'); + } +} + +function consumeProperty(budget: SnapshotBudget, at: string): void { + if (budget.limits === undefined) return; + budget.properties += 1; + if (budget.properties > budget.limits.maxNodes) { + throw nonJson(at, 'snapshot depth or node limit exceeded'); + } +} + +/** Count JSON's UTF-8 string encoding without allocating the escaped value. */ +function consumeStringBytes(budget: SnapshotBudget, value: string, at: string): void { + consumeBytes(budget, 2, at); + if (budget.limits !== undefined && value.length > budget.limits.maxBytes - budget.bytes) { + throw nonJson(at, 'snapshot byte limit exceeded'); + } + for (let index = 0; index < value.length; index += 1) { + const code = STRING_CHAR_CODE_AT(value, index); + if (code === 0x22 || code === 0x5c || code === 0x08 || code === 0x09 + || code === 0x0a || code === 0x0c || code === 0x0d) { + consumeBytes(budget, 2, at); + } else if (code < 0x20 || (code >= 0xd800 && code <= 0xdfff)) { + if (code >= 0xd800 && code <= 0xdbff && index + 1 < value.length) { + const low = STRING_CHAR_CODE_AT(value, index + 1); + if (low >= 0xdc00 && low <= 0xdfff) { + consumeBytes(budget, 4, at); + index += 1; + continue; + } + } + consumeBytes(budget, 6, at); + } else if (code < 0x80) consumeBytes(budget, 1, at); + else if (code < 0x800) consumeBytes(budget, 2, at); + else consumeBytes(budget, 3, at); + } +} + +function consumeBytes(budget: SnapshotBudget, bytes: number, at: string): void { + budget.bytes += bytes; + if (budget.limits !== undefined && budget.bytes > budget.limits.maxBytes) { + throw nonJson(at, 'snapshot byte limit exceeded'); + } } function nonJson(at: string, detail: string): Error { - return new Error(`${at}: expected JSON-compatible data; ${detail}`); + return new ERROR(`${at}: expected JSON-compatible data; ${detail}`); } diff --git a/packages/sdk/src/plugin-store.ts b/packages/sdk/src/plugin-store.ts index 8ff7d232f..1fa5f99f1 100644 --- a/packages/sdk/src/plugin-store.ts +++ b/packages/sdk/src/plugin-store.ts @@ -1,6 +1,25 @@ -import { lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { mkdir, mkdtemp, rename, rm, writeFile } from 'node:fs/promises'; import { dirname, join, resolve } from 'node:path'; import { payloadManifest, safePath, sha256 } from './bundle.js'; +import { + closeDescriptor, + closeDirectory, + descriptorIsDirectory, + descriptorIsFile, + directoryEntryIsDirectory, + directoryEntryIsFile, + lstatPath, + openDirectory, + openDescriptor, + readDescriptor, + readDirectoryEntry, + statDescriptor, +} from './fs-descriptor.js'; +import { frozenHostedPromiseValue, hostedPromiseValue } from './hosted-promise-safety.js'; +import { appendIntrinsicArray } from './intrinsic-array.js'; +import { snapshotJsonValue, type JsonValue } from './json-value.js'; +import { MAX_PLUGIN_FILE_BYTES, MAX_PLUGIN_FILES, MAX_PLUGIN_TOTAL_BYTES } from './plugin-github.js'; import { PluginError } from './plugin-manifest.js'; /** @@ -13,93 +32,341 @@ import { PluginError } from './plugin-manifest.js'; * refusal, not a surprise. */ export const PLUGIN_STORE = '.flows/plugins'; +const MKDIR = mkdir; +const MKDTEMP = mkdtemp; +const RENAME = rename; +const RM = rm; +const WRITE_FILE = writeFile; +const PATH_DIRNAME = dirname; +const PATH_JOIN = join; +const PATH_RESOLVE = resolve; +const PROCESS_PLATFORM = process.platform; +const ERROR = Error; +const MAX_PLUGIN_MANIFEST_BYTES = 4_000_000; +const MAX_PLUGIN_STORE_ENTRIES = 10_000; +const ARRAY_IS_ARRAY = Array.isArray; +const BIG_INT = BigInt; +const BUFFER_ALLOC_UNSAFE = Buffer.allocUnsafe; +const BUFFER_TO_STRING = Function.prototype.call.bind(Buffer.prototype.toString) as ( + value: Buffer, + encoding: BufferEncoding, +) => string; +const JSON_PARSE = JSON.parse; +const NUMBER = Number; +const NUMBER_IS_SAFE_INTEGER = Number.isSafeInteger; +const OBJECT_CREATE = Object.create; +const OBJECT_FREEZE = Object.freeze; +const OBJECT_GET_OWN_PROPERTY_DESCRIPTOR = Object.getOwnPropertyDescriptor; +const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as (pattern: RegExp, value: string) => boolean; +const SET = Set; +const SET_ADD = Function.prototype.call.bind(Set.prototype.add) as (set: Set, value: T) => Set; +const SET_FOR_EACH = Function.prototype.call.bind(Set.prototype.forEach) as ( + set: Set, + callback: (value: T) => void, +) => void; +const SET_HAS = Function.prototype.call.bind(Set.prototype.has) as (set: Set, value: T) => boolean; +const STRING_SPLIT = Function.prototype.call.bind(String.prototype.split) as ( + value: string, + separator: string, +) => string[]; +const STRING_STARTS_WITH = Function.prototype.call.bind(String.prototype.startsWith) as ( + value: string, + search: string, +) => boolean; +const READ_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0); export function pluginStoreDirectory(root: string, name: string, digest: string): string { - return join(resolve(root), PLUGIN_STORE, `${name}@sha256:${digest}`); + return PATH_JOIN(PATH_RESOLVE(root), PLUGIN_STORE, `${name}@sha256:${digest}`); } -export interface StoredPluginFile { readonly path: string; readonly data: Uint8Array } +export interface StoredPluginFile { + readonly path: string; + readonly data: Uint8Array; +} + +/** @internal Deterministic race seams for the bounded store reader. */ +export interface StoredPluginReadTestHooks { + readonly beforeOpen?: (path: string) => Promise; + readonly afterStat?: (path: string) => Promise; + readonly beforeDirectoryStat?: () => Promise; +} + +const EMPTY_STORED_PLUGIN_HOOKS = OBJECT_FREEZE( + OBJECT_CREATE(null), +) as StoredPluginReadTestHooks; /** Write the files atomically; an existing directory is verified instead of overwritten. */ -export async function materializePlugin(root: string, name: string, files: readonly StoredPluginFile[]): Promise<{ directory: string; digest: string }> { +export async function materializePlugin( + root: string, + name: string, + files: readonly StoredPluginFile[], +): Promise<{ directory: string; digest: string }> { const manifest = payloadManifest(files); const digest = sha256(manifest); const directory = pluginStoreDirectory(root, name, digest); let exists = false; - try { await lstat(directory); exists = true; } - catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } - if (exists) { await verifyStoredPlugin(directory, digest); return { directory, digest }; } - const parent = dirname(directory); - await mkdir(parent, { recursive: true }); - const staging = await mkdtemp(join(parent, '.install-')); try { - for (const file of files) { - if (!safePath(file.path) || file.path === 'manifest.json') throw new PluginError('plugin_path_invalid', `${file.path}: invalid plugin path.`); - await mkdir(dirname(join(staging, file.path)), { recursive: true }); - await writeFile(join(staging, file.path), file.data, { mode: 0o644 }); + await lstatPath(directory); + exists = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + if (exists) { + await verifyStoredPlugin(directory, digest); + return frozenHostedPromiseValue({ directory, digest }); + } + const parent = PATH_DIRNAME(directory); + await MKDIR(parent, { recursive: true }); + const staging = await MKDTEMP(PATH_JOIN(parent, '.install-')); + try { + for (let index = 0; index < files.length; index += 1) { + const file = files[index]!; + if (!safePath(file.path) || file.path === 'manifest.json') + throw new PluginError('plugin_path_invalid', `${file.path}: invalid plugin path.`); + await MKDIR(PATH_DIRNAME(PATH_JOIN(staging, file.path)), { + recursive: true, + }); + await WRITE_FILE(PATH_JOIN(staging, file.path), file.data, { + mode: 0o644, + }); } - await writeFile(join(staging, 'manifest.json'), manifest); - try { await rename(staging, directory); } - catch (error) { - if (!['EEXIST', 'ENOTEMPTY'].includes((error as NodeJS.ErrnoException).code ?? '')) throw error; + await WRITE_FILE(PATH_JOIN(staging, 'manifest.json'), manifest); + try { + await RENAME(staging, directory); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== 'EEXIST' && code !== 'ENOTEMPTY') throw error; await verifyStoredPlugin(directory, digest); } - } finally { await rm(staging, { recursive: true, force: true }); } - return { directory, digest }; + } finally { + await RM(staging, { recursive: true, force: true }); + } + return frozenHostedPromiseValue({ directory, digest }); } -async function regularFile(root: string, path: string): Promise { - const parts = path.split('/'); - for (let i = 1; i <= parts.length; i++) { - const stat = await lstat(join(root, ...parts.slice(0, i))); - if (i === parts.length ? !stat.isFile() : !stat.isDirectory()) throw new PluginError('plugin_source_drift', `${path}: expected a regular file, without symlinks.`); +async function regularFile( + root: string, + path: string, + maxBytes: number, + expectedBytes?: number, + hooks: StoredPluginReadTestHooks = EMPTY_STORED_PLUGIN_HOOKS, +): Promise { + const absolute = PATH_JOIN(root, path); + const handle = await openStoredFile(root, path, hooks); + try { + const before = await statDescriptor(handle, { bigint: true }); + if ( + !descriptorIsFile(before) || + before.size < 0n || + before.size > BIG_INT(maxBytes) || + (expectedBytes !== undefined && before.size !== BIG_INT(expectedBytes)) + ) { + throw new PluginError('plugin_source_drift', `${path}: expected a bounded regular file.`); + } + await ownHook(hooks, 'afterStat')?.(absolute); + const size = NUMBER(before.size); + const bytes = BUFFER_ALLOC_UNSAFE(size); + let offset = 0; + while (offset < size) { + const bytesRead = await readDescriptor(handle, bytes, offset, size - offset, offset); + if (bytesRead === 0) { + throw new PluginError('plugin_source_drift', `${path}: changed while reading.`); + } + offset += bytesRead; + } + const extra = BUFFER_ALLOC_UNSAFE(1); + if ((await readDescriptor(handle, extra, 0, 1, size)) !== 0) { + throw new PluginError('plugin_source_drift', `${path}: changed while reading.`); + } + const after = await statDescriptor(handle, { bigint: true }); + if (after.size !== before.size || after.mtimeNs !== before.mtimeNs || after.ctimeNs !== before.ctimeNs) { + throw new PluginError('plugin_source_drift', `${path}: changed while reading.`); + } + return hostedPromiseValue(bytes); + } finally { + await closeDescriptor(handle); + } +} + +async function openStoredFile(root: string, path: string, hooks: StoredPluginReadTestHooks): Promise { + const parts = STRING_SPLIT(path, '/'); + if (PROCESS_PLATFORM !== 'linux') { + for (let i = 1; i < parts.length; i++) { + let parent = root; + for (let index = 0; index < i; index += 1) parent = PATH_JOIN(parent, parts[index]!); + if (!descriptorIsDirectory(await lstatPath(parent))) { + throw new PluginError('plugin_source_drift', `${path}: expected a regular file, without symlinks.`); + } + } + await ownHook(hooks, 'beforeOpen')?.(PATH_JOIN(root, path)); + return await openDescriptor(PATH_JOIN(root, path), READ_FLAGS); + } + let directory = await openDescriptor(PATH_RESOLVE(root), READ_FLAGS); + try { + if (!descriptorIsDirectory(await statDescriptor(directory, { bigint: true }))) { + throw new PluginError('plugin_source_drift', `${path}: plugin store root is not a directory.`); + } + for (let index = 0; index < parts.length - 1; index += 1) { + const part = parts[index]!; + const child = await openDescriptor(`/proc/self/fd/${directory}/${part}`, READ_FLAGS); + let adopted = false; + try { + await ownHook(hooks, 'beforeDirectoryStat')?.(); + if (!descriptorIsDirectory(await statDescriptor(child, { bigint: true }))) { + throw new PluginError('plugin_source_drift', `${path}: expected a regular file, without symlinks.`); + } + await closeDescriptor(directory); + directory = child; + adopted = true; + } finally { + if (!adopted) await closeDescriptor(child); + } + } + await ownHook(hooks, 'beforeOpen')?.(PATH_JOIN(root, path)); + return await openDescriptor(`/proc/self/fd/${directory}/${parts[parts.length - 1]!}`, READ_FLAGS); + } finally { + await closeDescriptor(directory); } - return readFile(join(root, path)); } /** Re-hash a materialized plugin and compare with the digest the lockfile recorded. */ -export async function verifyStoredPlugin(directory: string, expectedDigest: string): Promise { - const drift = (message: string): never => { throw new PluginError('plugin_source_drift', `${directory}: ${message}`); }; - let raw: string; +export async function verifyStoredPlugin( + directory: string, + expectedDigest: string, + hooks: StoredPluginReadTestHooks = EMPTY_STORED_PLUGIN_HOOKS, +): Promise { + await readVerifiedStoredPluginFiles(directory, expectedDigest, hooks); +} + +async function readVerifiedStoredPluginFiles( + directory: string, + expectedDigest: string, + hooks: StoredPluginReadTestHooks, +): Promise { + const drift = (message: string): never => { + throw new PluginError('plugin_source_drift', `${directory}: ${message}`); + }; + let manifest: Buffer; try { - if (!(await lstat(directory)).isDirectory()) return drift('not a directory'); - raw = (await regularFile(directory, 'manifest.json')).toString('utf8'); - } catch (error) { return drift(error instanceof PluginError ? error.message : 'manifest.json is missing'); } + if (!descriptorIsDirectory(await lstatPath(directory))) return drift('not a directory'); + manifest = await regularFile(directory, 'manifest.json', MAX_PLUGIN_MANIFEST_BYTES, undefined, hooks); + } catch (error) { + return drift(error instanceof PluginError ? error.message : 'manifest.json is missing'); + } + const raw = BUFFER_TO_STRING(manifest, 'utf8'); if (sha256(raw) !== expectedDigest) return drift('manifest.json digest differs from the lockfile'); - let entries: { path: string; sha256: string; bytes: number }[]; - try { entries = JSON.parse(raw); if (!Array.isArray(entries)) throw new Error(); } - catch { return drift('manifest.json is not a manifest'); } - const paths = new Set(); - for (const entry of entries) { - if (typeof entry?.path !== 'string' || !safePath(entry.path)) return drift('manifest.json lists an invalid path'); + let entries: JsonValue[]; + try { + const value = snapshotJsonValue(JSON_PARSE(raw), 'stored plugin manifest', { + maxBytes: MAX_PLUGIN_MANIFEST_BYTES, + maxDepth: 3, + maxNodes: 1 + MAX_PLUGIN_FILES * 4, + }); + if (!ARRAY_IS_ARRAY(value)) throw new ERROR(); + entries = value; + } catch { + return drift('manifest.json is not a manifest'); + } + if (entries.length > MAX_PLUGIN_FILES) return drift(`manifest.json lists more than ${MAX_PLUGIN_FILES} files`); + const paths = new SET(); + const files = [{ path: 'manifest.json', data: manifest }]; + let totalBytes = 0; + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]!; + if ( + typeof entry !== 'object' || + entry === null || + ARRAY_IS_ARRAY(entry) + ) + return drift('manifest.json lists an invalid file'); + const path = entry.path; + const digest = entry.sha256; + const bytes = entry.bytes; + if ( + typeof path !== 'string' || + !safePath(path) || + typeof digest !== 'string' || + !REGEXP_TEST(/^[a-f0-9]{64}$/, digest) || + typeof bytes !== 'number' || + !NUMBER_IS_SAFE_INTEGER(bytes) || + bytes < 0 || + bytes > MAX_PLUGIN_FILE_BYTES || + SET_HAS(paths, path) + ) + return drift('manifest.json lists an invalid file'); + totalBytes += bytes; + if (totalBytes > MAX_PLUGIN_TOTAL_BYTES) return drift(`plugin exceeds ${MAX_PLUGIN_TOTAL_BYTES} bytes`); let data: Buffer; - try { data = await regularFile(directory, entry.path); } - catch (error) { return drift(error instanceof PluginError ? error.message : `${entry.path} is missing`); } - if (data.length !== entry.bytes || sha256(data) !== entry.sha256) return drift(`${entry.path} changed since installation`); - paths.add(entry.path); + try { + data = await regularFile(directory, path, MAX_PLUGIN_FILE_BYTES, bytes, hooks); + } catch (error) { + return drift(error instanceof PluginError ? error.message : `${path} is missing`); + } + if (sha256(data) !== digest) return drift(`${path} changed since installation`); + SET_ADD(paths, path); + appendIntrinsicArray(files, { path, data }); + } + SET_ADD(paths, 'manifest.json'); + await rejectExtras(directory, '', paths, drift); + const frozen: Readonly<{ path: string; data: Buffer }>[] = []; + for (let index = 0; index < files.length; index += 1) { + appendIntrinsicArray(frozen, OBJECT_FREEZE(files[index]!)); } - await rejectExtras(directory, '', new Set([...paths, 'manifest.json']), drift); + return frozenHostedPromiseValue(frozen); } /** Re-verify, then return every stored file including the payload `manifest.json`. */ -export async function readStoredPluginFiles(directory: string, expectedDigest: string): Promise { - await verifyStoredPlugin(directory, expectedDigest); - const manifest = await regularFile(directory, 'manifest.json'); - const entries = JSON.parse(manifest.toString('utf8')) as { path: string }[]; - const files = [{ path: 'manifest.json', data: manifest }]; - for (const entry of entries) files.push({ path: entry.path, data: await regularFile(directory, entry.path) }); - return files; +export async function readStoredPluginFiles( + directory: string, + expectedDigest: string, + hooks: StoredPluginReadTestHooks = EMPTY_STORED_PLUGIN_HOOKS, +): Promise { + return await readVerifiedStoredPluginFiles(directory, expectedDigest, hooks); +} + +function ownHook( + hooks: StoredPluginReadTestHooks, + name: keyof StoredPluginReadTestHooks, +): ((...args: unknown[]) => Promise) | undefined { + const descriptor = OBJECT_GET_OWN_PROPERTY_DESCRIPTOR(hooks, name); + return descriptor !== undefined && 'value' in descriptor && typeof descriptor.value === 'function' + ? descriptor.value as (...args: unknown[]) => Promise + : undefined; } /** Drop a materialized plugin directory. Missing is a no-op. */ export async function removeStoredPlugin(directory: string): Promise { - await rm(directory, { recursive: true, force: true }); + await RM(directory, { recursive: true, force: true }); } -async function rejectExtras(root: string, prefix: string, paths: Set, drift: (m: string) => never): Promise { - for (const entry of await readdir(join(root, prefix), { withFileTypes: true })) { - const path = prefix + entry.name; - if (entry.isDirectory() && [...paths].some(file => file.startsWith(`${path}/`))) await rejectExtras(root, `${path}/`, paths, drift); - else if (!entry.isFile() || !paths.has(path)) drift(`${path}: unlisted file or unsupported file type`); +async function rejectExtras( + root: string, + prefix: string, + paths: Set, + drift: (m: string) => never, +): Promise { + let entries = 0; + async function visit(currentPrefix: string): Promise { + const directory = await openDirectory(PATH_JOIN(root, currentPrefix)); + try { + for (;;) { + const entry = await readDirectoryEntry(directory); + if (entry === null) break; + entries += 1; + if (entries > MAX_PLUGIN_STORE_ENTRIES) drift('plugin store contains too many entries'); + const path = currentPrefix + entry.name; + let declaredDescendant = false; + SET_FOR_EACH(paths, file => { + if (STRING_STARTS_WITH(file, `${path}/`)) declaredDescendant = true; + }); + if (directoryEntryIsDirectory(entry) && declaredDescendant) await visit(`${path}/`); + else if (!directoryEntryIsFile(entry) || !SET_HAS(paths, path)) { + drift(`${path}: unlisted file or unsupported file type`); + } + } + } finally { + await closeDirectory(directory); + } } + await visit(prefix); } diff --git a/packages/sdk/src/semver-range.ts b/packages/sdk/src/semver-range.ts index 3a2cfc8ed..97cc34776 100644 --- a/packages/sdk/src/semver-range.ts +++ b/packages/sdk/src/semver-range.ts @@ -9,19 +9,41 @@ */ const VERSION = /^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/; const RANGE = /^(\*|(?:[\^~]|>=)?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?: <\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)?)$/; +const MATH_MAX = Math.max; +const NUMBER = Number; +const NUMBER_IS_SAFE_INTEGER = Number.isSafeInteger; +const REGEXP_EXEC = Function.prototype.call.bind(RegExp.prototype.exec) as ( + regexp: RegExp, + value: string, +) => RegExpExecArray | null; +const REGEXP_TEST = Function.prototype.call.bind(RegExp.prototype.test) as ( + regexp: RegExp, + value: string, +) => boolean; +const STRING_SLICE = Function.prototype.call.bind(String.prototype.slice) as ( + value: string, + start: number, + end?: number, +) => string; +const STRING_SPLIT = Function.prototype.call.bind(String.prototype.split) as ( + value: string, + separator: string, +) => string[]; interface Parsed { readonly triple: readonly [number, number, number]; readonly pre?: string } export function parseVersion(value: string): Parsed | undefined { - const m = VERSION.exec(value); + const m = REGEXP_EXEC(VERSION, value); if (!m) return undefined; - const triple = [Number(m[1]), Number(m[2]), Number(m[3])] as const; - if (triple.some(n => !Number.isSafeInteger(n))) return undefined; + const triple = [NUMBER(m[1]), NUMBER(m[2]), NUMBER(m[3])] as const; + for (let index = 0; index < triple.length; index += 1) { + if (!NUMBER_IS_SAFE_INTEGER(triple[index])) return undefined; + } return m[4] === undefined ? { triple } : { triple, pre: m[4] }; } export function isVersionRange(value: string): boolean { - return RANGE.test(value); + return REGEXP_TEST(RANGE, value); } function compare(a: Parsed, b: Parsed): number { @@ -33,16 +55,16 @@ function compare(a: Parsed, b: Parsed): number { } function comparePrerelease(a: string, b: string): number { - const as = a.split('.'); - const bs = b.split('.'); - const n = Math.max(as.length, bs.length); + const as = STRING_SPLIT(a, '.'); + const bs = STRING_SPLIT(b, '.'); + const n = MATH_MAX(as.length, bs.length); for (let i = 0; i < n; i++) { const left = as[i]; const right = bs[i]; if (left === undefined) return -1; if (right === undefined) return 1; - const leftNum = /^\d+$/.test(left) ? Number(left) : undefined; - const rightNum = /^\d+$/.test(right) ? Number(right) : undefined; + const leftNum = REGEXP_TEST(/^\d+$/, left) ? NUMBER(left) : undefined; + const rightNum = REGEXP_TEST(/^\d+$/, right) ? NUMBER(right) : undefined; if (leftNum !== undefined && rightNum !== undefined) { if (leftNum !== rightNum) return leftNum - rightNum; continue; @@ -59,15 +81,18 @@ export function satisfiesRange(version: string, range: string): boolean { const v = parseVersion(version); if (v === undefined || !isVersionRange(range)) return false; if (range === '*') return true; - const [lowerText, upperText] = range.split(' ') as [string, string | undefined]; - const operator = /^[\^~]|^>=/.exec(lowerText)?.[0] ?? ''; - const lower = parseVersion(lowerText.slice(operator.length))!; + const parts = STRING_SPLIT(range, ' '); + const lowerText = parts[0]!; + const upperText = parts[1]; + const operator = REGEXP_EXEC(/^[\^~]|^>=/, lowerText)?.[0] ?? ''; + const lower = parseVersion(STRING_SLICE(lowerText, operator.length))!; if (compare(v, lower) < 0) return false; if (operator === '') return compare(v, lower) === 0; let upper: Parsed | undefined; - if (upperText !== undefined) upper = parseVersion(upperText.slice(1)); + if (upperText !== undefined) upper = parseVersion(STRING_SLICE(upperText, 1)); else if (operator === '^') { - const [major, minor] = lower.triple; + const major = lower.triple[0]; + const minor = lower.triple[1]; upper = major > 0 ? { triple: [major + 1, 0, 0] } : minor > 0 ? { triple: [0, minor + 1, 0] } : { triple: [0, 0, lower.triple[2] + 1] }; } else if (operator === '~') upper = { triple: [lower.triple[0], lower.triple[1] + 1, 0] }; return upper === undefined || compare(v, upper) < 0; diff --git a/packages/sdk/tests/babysitter-native-extension.test.ts b/packages/sdk/tests/babysitter-native-extension.test.ts index 46df1773d..b99a5d2b3 100644 --- a/packages/sdk/tests/babysitter-native-extension.test.ts +++ b/packages/sdk/tests/babysitter-native-extension.test.ts @@ -1,4 +1,5 @@ -import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { createRequire, syncBuiltinESMExports } from 'node:module'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import type { Ctx } from '@relayflows/surface'; @@ -9,12 +10,23 @@ import { exportBabysitterCatalogBundle } from '../src/babysitter-catalog-export. import { addExtensionPlugin } from '../src/cli/add-extension.js'; import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/flow-extension-loader.js'; import { resolveExtensionSubmission } from '../src/flow-extension-submit.js'; +import { + loadHostedExtensionArtifacts, + loadHostedExtensionRuntime, + runHostedCapabilityExtension, + selectHostedExtensionForRuntime, +} from '../src/hosted-extension-isolation.js'; +import { assertHostedRuntimeAuthority } from '../src/hosted-extension-runtime.js'; import { JournalClient } from '../src/journal-client.js'; +import { materializePlugin } from '../src/plugin-store.js'; import { preflightProviderTriggers } from '../src/provider-trigger-contract.js'; -import { SHA_A, entriesFromDirectory, fakeGithub } from './fake-github.js'; +import { entriesFromDirectory, fakeGithub } from './fake-github.js'; const PATH = 'extensions/babysitter'; -const REF = `github:AgentWorkforce/flows@${SHA_A}#${PATH}`; +const NATIVE_SHA = '8b33ebab8347514f80d9da5a81206a087f641714'; +const REF = `github:AgentWorkforce/flows@${NATIVE_SHA}#${PATH}`; +const DIGEST = 'bdf2187b9a242667d34bbc63e7a744753e146dc8cd6f4047047f2aed28f406ee'; +const MANIFEST_SHA256 = '5631a06bbdc8186f4ee0ff955610ead24d001c5197b59fb1fe81fe422c44f226'; // pull_request.labeled, .unlabeled and .ready_for_review route only in the surface after 2.0.25. const versions = { sdk: '2.0.26', surface: '2.0.26' }; const now = () => new Date('2026-09-22T12:00:00Z'); @@ -29,7 +41,7 @@ const dirs: string[] = []; afterAll(() => dirs.splice(0).forEach(p => rmSync(p, { recursive: true, force: true }))); function github() { - return fakeGithub({ 'AgentWorkforce/flows': { refs: {}, commits: { [SHA_A]: { entries } } } }); + return fakeGithub({ 'AgentWorkforce/flows': { refs: {}, commits: { [NATIVE_SHA]: { entries } } } }); } /** Install the committed bytes the way an operator would, then load the composition. */ @@ -39,14 +51,25 @@ async function composed() { symlinkSync(resolve('node_modules/@relayflows/surface'), join(cwd, 'node_modules/@relayflows/surface')); writeFileSync(join(cwd, 'package.json'), '{"type":"module"}'); writeFileSync(join(cwd, 'flows.json'), JSON.stringify({ cli: 'codex', executors: ['github'] })); - writeFileSync(join(cwd, 'software-factory.flow.ts'), ` - import { flow } from '@relayflows/surface'; - export default flow('software-factory', { budget: { dollars: 10, wallclock: '1h' } }, async f => { f.done('success'); }); - `); + writeFileSync( + join(cwd, 'software-factory.flow.ts'), + readFileSync(resolve('../..', 'examples/software-factory/software-factory.flow.ts')), + ); const io = { stdout: () => {}, stderr: (s: string) => { throw new Error(s); } }; expect(await addExtensionPlugin(REF, io, { cwd, fetch: github().fetch, now, versions })).toBe(0); const loaded = await loadAuthoredFlow(join(cwd, 'software-factory.flow.ts'), { versions }); - return { loaded, extension: loaded.extensions[0]! }; + const flowPath = join(cwd, 'software-factory.flow.ts'); + const baseLoaded = await loadAuthoredFlow(flowPath, { extensions: 'none', versions }); + const baseDefinition = baseLoaded.getDefinition(baseLoaded.handle); + const hostedRuntime = await loadHostedExtensionRuntime(flowPath); + return { + cwd, + flowPath, + loaded, + hostedRuntime, + extension: loaded.extensions[0]!, + base: { name: baseDefinition.name, version: baseDefinition.header.version }, + }; } function subscriptionOf(trigger: unknown): string { @@ -84,7 +107,9 @@ async function handle(eventType: string, input: unknown, f: Ctx) { describe('native Babysitter extension', () => { it('composes onto Software Factory with exactly the declared, deliverable subscriptions', async () => { - const { loaded, extension } = installed; + const { loaded, extension, base, hostedRuntime } = installed; + expect(base).toEqual({ name: 'software-factory', version: '2.0.22' }); + expect(hostedRuntime.base).toEqual(base); expect(extension.manifest.permissions).toEqual({ integrations: ['github'], harnesses: ['codex'], mcp: [], writes: ['cloud:babysitter-turn'], budget: { dollars: 1, wallclock: '5m' }, @@ -103,6 +128,299 @@ describe('native Babysitter extension', () => { })).rejects.toMatchObject({ code: 'plugin_unsupported' }); }); + it.skipIf(process.platform !== 'linux' || !existsSync('/usr/bin/bwrap'))( + 'runs the exact published 2.0.26 native bytes in the isolated capability path', async () => { + const { installation, base } = installed.hostedRuntime; + expect(installation.artifacts).toHaveLength(1); + expect(installation.artifacts[0]).toMatchObject({ ref: REF, digest: DIGEST, manifestSha256: MANIFEST_SHA256 }); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'gh-delivery-7', + }); + const calls: unknown[] = []; + await expect(runHostedCapabilityExtension({ + installation, + base, + dispatch, + input: descriptor('pull_request.labeled'), + babysitterTurn: { queue: async (request, authority) => { + calls.push(request); + expect(authority.dispatch).toBe(dispatch); + expect(authority.extension).toEqual({ + name: 'babysitter', version: '0.2.0', ref: REF, digest: DIGEST, + }); + return { receiptId: `bst_${'a'.repeat(64)}`, status: 'queued' }; + } }, + })).resolves.toEqual({ completionReason: 'success', capabilityCalls: 1 }); + expect(calls).toEqual([{ delivery: { + deliveryId: 'gh-delivery-7', provider: 'github', eventType: 'pull_request.labeled', + pullRequest: { owner: 'AgentWorkforce', repository: 'flows', number: 551 }, + } }]); + }); + + it('refuses the real bytes on 2.0.25 before execution and admits them on 2.0.26', async () => { + const installation = installed.hostedRuntime.installation; + await expect(selectHostedExtensionForRuntime( + installation, installed.base, + { provider: 'github', event: 'pull_request', action: 'labeled' }, + { sdk: '2.0.25', surface: '2.0.25' }, + )).rejects.toMatchObject({ code: 'plugin_incompatible' }); + await expect(selectHostedExtensionForRuntime( + installation, installed.base, + { provider: 'github', event: 'pull_request', action: 'labeled' }, + versions, + )).resolves.toMatchObject({ artifact: { ref: REF, digest: DIGEST } }); + }); + + it('binds the pinned artifact to the actual base and complete installed route set', async () => { + const installation = await loadHostedExtensionArtifacts(installed.flowPath); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'gh-delivery-7', + }); + let calls = 0; + await expect(runHostedCapabilityExtension({ + installation, + base: installed.loaded as never, + dispatch, + input: descriptor('pull_request.labeled'), + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'never', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_incompatible' }); + expect(calls).toBe(0); + + await expect(runHostedCapabilityExtension({ + installation, + base: installed.base as never, + dispatch, + input: descriptor('pull_request.labeled'), + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'never', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_incompatible' }); + expect(calls).toBe(0); + + await expect(runHostedCapabilityExtension({ + installation: { artifacts: installation.artifacts } as never, + base: installed.hostedRuntime.base, + dispatch, + input: descriptor('pull_request.labeled'), + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'never', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_source_invalid' }); + expect(calls).toBe(0); + + const otherProject = await composed(); + await expect(runHostedCapabilityExtension({ + installation, + base: otherProject.hostedRuntime.base, + dispatch, + input: descriptor('pull_request.labeled'), + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'never', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_source_invalid' }); + expect(calls).toBe(0); + }); + + it('validates opaque runtime authority with captured WeakSet and WeakMap methods', async () => { + const originalHas = WeakSet.prototype.has; + const originalGet = WeakMap.prototype.get; + const originalCall = Function.prototype.call; + const reflectApply = Reflect.apply; + let poisonCalls = 0; + let failure: unknown; + try { + WeakSet.prototype.has = (() => { poisonCalls += 1; return true; }) as typeof WeakSet.prototype.has; + WeakMap.prototype.get = (() => { poisonCalls += 1; return undefined; }) as typeof WeakMap.prototype.get; + Function.prototype.call = function poisonedCall( + this: Function, + thisArg: unknown, + ...args: unknown[] + ) { + if (this === originalHas || this === originalGet) poisonCalls += 1; + return reflectApply(originalCall, this, [thisArg, ...args]); + } as typeof Function.prototype.call; + await assertHostedRuntimeAuthority( + installed.hostedRuntime.installation, + installed.hostedRuntime.base, + ); + try { + await assertHostedRuntimeAuthority({ artifacts: [] } as never, { name: 'software-factory' } as never); + } catch (error) { + failure = error; + } + } finally { + Function.prototype.call = originalCall; + WeakSet.prototype.has = originalHas; + WeakMap.prototype.get = originalGet; + } + expect(poisonCalls).toBe(0); + expect(failure).toMatchObject({ code: 'plugin_incompatible' }); + }); + + it('checks the reviewed base pin with captured hash methods', async () => { + const racing = await composed(); + writeFileSync(racing.flowPath, `export default {};\n`); + const prototype = Object.getPrototypeOf(createHash('sha256')) as { + update: typeof import('node:crypto').Hash.prototype.update; + digest: typeof import('node:crypto').Hash.prototype.digest; + }; + const originalUpdate = prototype.update; + const originalDigest = prototype.digest; + let poisonCalls = 0; + try { + prototype.update = function poisonedUpdate() { poisonCalls += 1; return this; } as typeof prototype.update; + prototype.digest = (() => { + poisonCalls += 1; + return '49c993220b9c34fab2d4b0e51911656f62b8b657f534d988691960d45bb9d9b6'; + }) as typeof prototype.digest; + await expect(loadHostedExtensionRuntime(racing.flowPath)) + .rejects.toMatchObject({ code: 'plugin_source_invalid' }); + } finally { + prototype.update = originalUpdate; + prototype.digest = originalDigest; + } + expect(poisonCalls).toBe(0); + }); + + it('checks the reviewed base pin with the captured hash factory', async () => { + const racing = await composed(); + writeFileSync(racing.flowPath, `export default {};\n`); + const require = createRequire(import.meta.url); + const crypto = require('node:crypto') as typeof import('node:crypto'); + const originalCreateHash = crypto.createHash; + let poisonCalls = 0; + try { + crypto.createHash = (() => { + poisonCalls += 1; + throw new Error('ambient createHash must not run'); + }) as typeof crypto.createHash; + syncBuiltinESMExports(); + await expect(loadHostedExtensionRuntime(racing.flowPath)) + .rejects.toMatchObject({ code: 'plugin_source_invalid' }); + } finally { + crypto.createHash = originalCreateHash; + syncBuiltinESMExports(); + } + expect(poisonCalls).toBe(0); + }); + + it('refuses stale installation authority after the same flow path is redeployed', async () => { + const configPath = join(installed.cwd, 'flows.json'); + const lockPath = join(installed.cwd, 'flows.lock.json'); + const flowPath = installed.flowPath; + const originalConfig = readFileSync(configPath, 'utf8'); + const originalLock = readFileSync(lockPath, 'utf8'); + const originalFlow = readFileSync(flowPath, 'utf8'); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'gh-delivery-7', + }); + let calls = 0; + const capability = { queue: async () => { + calls += 1; + return { receiptId: 'never', status: 'queued' }; + } }; + try { + const config = JSON.parse(originalConfig) as { plugins?: string[] }; + config.plugins = []; + const lock = JSON.parse(originalLock) as { version: 2; plugins: unknown[] }; + lock.plugins = []; + writeFileSync(configPath, JSON.stringify(config)); + writeFileSync(lockPath, JSON.stringify(lock)); + writeFileSync(flowPath, `export default {};\n`); + await expect(loadHostedExtensionRuntime(installed.flowPath)) + .rejects.toMatchObject({ code: 'plugin_source_invalid' }); + await expect(runHostedCapabilityExtension({ + installation: installed.hostedRuntime.installation, + base: installed.hostedRuntime.base, + dispatch, + input: descriptor('pull_request.labeled'), + babysitterTurn: capability, + })).rejects.toMatchObject({ code: 'plugin_source_invalid' }); + } finally { + writeFileSync(configPath, originalConfig); + writeFileSync(lockPath, originalLock); + writeFileSync(flowPath, originalFlow); + } + expect(calls).toBe(0); + }); + + it('never imports tenant base code to derive hosted authority', async () => { + const racing = await composed(); + writeFileSync(racing.flowPath, ` + JSON.stringify = () => '{"name":"software-factory"}'; + process.stdout.write('{"name":"software-factory"}'); + process.exit(0); + `); + await expect(loadHostedExtensionRuntime(racing.flowPath)) + .rejects.toMatchObject({ code: 'plugin_source_invalid' }); + }); + + it('refuses project-owned package imports instead of following live node_modules', async () => { + const racing = await composed(); + const packageRoot = join(racing.cwd, 'node_modules/local-identity'); + mkdirSync(packageRoot, { recursive: true }); + writeFileSync(join(packageRoot, 'package.json'), JSON.stringify({ + name: 'local-identity', type: 'module', exports: './index.js', + })); + writeFileSync(join(packageRoot, 'index.js'), `export const baseName = 'software-factory';\n`); + writeFileSync(racing.flowPath, ` + import { flow } from '@relayflows/surface'; + import { baseName } from 'local-identity'; + export default flow(baseName, async f => f.done('success')); + `); + await expect(loadHostedExtensionRuntime(racing.flowPath)) + .rejects.toMatchObject({ code: 'plugin_source_invalid' }); + writeFileSync(join(packageRoot, 'index.js'), `export const baseName = 'release-manager';\n`); + await expect(loadHostedExtensionRuntime(racing.flowPath)) + .rejects.toMatchObject({ code: 'plugin_source_invalid' }); + }); + + it('refuses a second installed extension that overlaps an action-specific route', async () => { + const overlapRef = `github:AgentWorkforce/other@${'b'.repeat(40)}#extensions/overlap`; + const overlapManifest = { + schema: 2, kind: 'flow-extension', name: 'overlap', version: '1.0.0', + compat: { surface: '^2.0.26', sdk: '^2.0.26', base: [{ name: 'software-factory', version: '*' }] }, + entry: 'overlap.flow.ts', extends: { handlers: true, hooks: [] }, + triggers: [{ provider: 'github', event: 'pull_request', actions: [] }], + permissions: { integrations: ['github'], harnesses: [], mcp: [], writes: [], budget: { dollars: 0.01, wallclock: '1m' } }, + preflight: { credentials: [], servers: [] }, + }; + const manifestBytes = Buffer.from(JSON.stringify(overlapManifest)); + const stored = await materializePlugin(installed.cwd, 'overlap', [ + { path: 'flows-plugin.json', data: manifestBytes }, + { path: 'overlap.flow.ts', data: Buffer.from('export default {};') }, + ]); + const configPath = join(installed.cwd, 'flows.json'); + const lockPath = join(installed.cwd, 'flows.lock.json'); + const config = JSON.parse(readFileSync(configPath, 'utf8')) as { plugins: string[] }; + const lock = JSON.parse(readFileSync(lockPath, 'utf8')) as { version: 2; plugins: Array> }; + config.plugins.push(overlapRef); + lock.plugins.push({ + name: 'overlap', kind: 'flow-extension', version: '1.0.0', + source: { host: 'github', owner: 'AgentWorkforce', repo: 'other', sha: 'b'.repeat(40), path: 'extensions/overlap' }, + digest: stored.digest, + manifestSha256: createHash('sha256').update(manifestBytes).digest('hex'), + order: 2, + resolvedAt: '2026-09-22T12:00:00.000Z', + }); + writeFileSync(configPath, JSON.stringify(config)); + writeFileSync(lockPath, JSON.stringify(lock)); + + const installation = await loadHostedExtensionArtifacts(installed.flowPath); + await expect(selectHostedExtensionForRuntime( + installation, + installed.base, + { provider: 'github', event: 'pull_request', action: 'labeled' }, + versions, + )).rejects.toMatchObject({ code: 'plugin_event_ambiguous' }); + }); + it('satisfies the #550 catalog exporter from the committed bytes, manifest unmodified', async () => { const options = { fetch: github().fetch, versions }; const bundle = await resolveExtensionSubmission(REF, options); @@ -197,3 +515,4 @@ describe('native Babysitter extension', () => { expect(c.done).toEqual(['declined']); }); }); +import { createHash } from 'node:crypto'; diff --git a/packages/sdk/tests/fs-descriptor.test.ts b/packages/sdk/tests/fs-descriptor.test.ts new file mode 100644 index 000000000..09b20560c --- /dev/null +++ b/packages/sdk/tests/fs-descriptor.test.ts @@ -0,0 +1,77 @@ +import { Dir, Dirent, Stats, closeSync, fstatSync, mkdtempSync, openSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + closeDescriptor, + closeDirectory, + lstatPath, + openDirectory, + openDescriptor, + readDirectory, + readDirectoryEntry, + statDescriptor, +} from '../src/fs-descriptor.js'; + +const roots: string[] = []; + +afterEach(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))); + +function fixture(): { directory: string; file: string } { + const directory = mkdtempSync(join(tmpdir(), 'fs-descriptor-test-')); + roots.push(directory); + const file = join(directory, 'entry.txt'); + writeFileSync(file, 'entry'); + return { directory, file }; +} + +function restoreThen(prototype: object, previous: PropertyDescriptor | undefined): void { + if (previous === undefined) delete (prototype as { then?: unknown }).then; + else Object.defineProperty(prototype, 'then', previous); +} + +describe('captured descriptor operations', () => { + it('shadows native fs values before promise resolution reads their prototypes', async () => { + const { directory, file } = fixture(); + const sample = openSync(file, 'r'); + const bigintStatsPrototype = Object.getPrototypeOf(fstatSync(sample, { bigint: true })) as object; + closeSync(sample); + const prototypes = [Array.prototype, Stats.prototype, bigintStatsPrototype, Dir.prototype, Dirent.prototype]; + const previous = prototypes.map(prototype => Object.getOwnPropertyDescriptor(prototype, 'then')); + let poisonCalls = 0; + try { + for (const prototype of prototypes) { + Object.defineProperty(prototype, 'then', { + configurable: true, + get() { + poisonCalls += 1; + return undefined; + }, + }); + } + + const entries = await readDirectory(directory); + expect(entries.map(entry => entry.name)).toEqual(['entry.txt']); + expect(await lstatPath(file)).toBeInstanceOf(Stats); + + const descriptor = await openDescriptor(file, 0); + try { + expect((await statDescriptor(descriptor, { bigint: true })).size).toBe(5n); + } finally { + await closeDescriptor(descriptor); + } + + const opened = await openDirectory(directory); + try { + expect((await readDirectoryEntry(opened))?.name).toBe('entry.txt'); + } finally { + await closeDirectory(opened); + } + } finally { + for (let index = 0; index < prototypes.length; index += 1) { + restoreThen(prototypes[index]!, previous[index]); + } + } + expect(poisonCalls).toBe(0); + }); +}); diff --git a/packages/sdk/tests/hosted-base-snapshot.test.ts b/packages/sdk/tests/hosted-base-snapshot.test.ts new file mode 100644 index 000000000..9bc975849 --- /dev/null +++ b/packages/sdk/tests/hosted-base-snapshot.test.ts @@ -0,0 +1,472 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from 'node:fs'; +import { open as openFileHandle } from 'node:fs/promises'; +import { createRequire, syncBuiltinESMExports } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + createHostedBaseSnapshot, + hostedBaseSourceDigest, + removeHostedBaseSnapshot, +} from '../src/hosted-base-snapshot.js'; +import { loadHostedExtensionRuntime } from '../src/hosted-extension-runtime.js'; + +const roots: string[] = []; +const require = createRequire(import.meta.url); +afterEach(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))); + +function fixture() { + const project = mkdtempSync(join(tmpdir(), 'hosted-base-snapshot-test-')); + roots.push(project); + writeFileSync(join(project, 'package.json'), '{"type":"module"}'); + writeFileSync(join(project, 'flows.json'), '{}'); + const flowPath = join(project, 'software-factory.flow.ts'); + const source = `throw new Error('tenant base must not execute');\n`; + writeFileSync(flowPath, source); + return { project, flowPath, source }; +} + +describe('hosted base private snapshot', () => { + it('shadows inherited thenables on completed snapshot authority', async () => { + const { flowPath } = fixture(); + const snapshot = await createHostedBaseSnapshot(flowPath); + try { + expect(Object.getOwnPropertyDescriptor(snapshot, 'then')).toMatchObject({ value: undefined }); + expect(Object.getOwnPropertyDescriptor(snapshot.liveSources, 'then')).toMatchObject({ value: undefined }); + } finally { + await removeHostedBaseSnapshot(snapshot); + } + }); + + it('locks the inherited then slot before authored code can schedule a replacement', async () => { + const { flowPath } = fixture(); + let poisonCalls = 0; + const locked = Object.getOwnPropertyDescriptor(Object.prototype, 'then'); + expect(locked).toMatchObject({ configurable: false, enumerable: false }); + expect(typeof locked?.get).toBe('function'); + expect(typeof locked?.set).toBe('function'); + expect(() => { + Object.defineProperty(Object.prototype, 'then', { + configurable: true, + get() { + poisonCalls += 1; + return undefined; + }, + }); + }).toThrow(TypeError); + const snapshot = await createHostedBaseSnapshot(flowPath); + await removeHostedBaseSnapshot(snapshot); + expect(poisonCalls).toBe(0); + }); + + it('keeps the buffered base bytes when the live source changes', async () => { + const { flowPath, source } = fixture(); + const snapshot = await createHostedBaseSnapshot(flowPath); + try { + writeFileSync(flowPath, `throw new Error('live replacement');\n`); + expect(readFileSync(snapshot.snapshotFlowPath, 'utf8')).toBe(source); + expect(await hostedBaseSourceDigest(snapshot.liveSources)).not.toBe(snapshot.liveDigest); + } finally { + await removeHostedBaseSnapshot(snapshot); + } + }); + + it('keeps source digests sensitive when ambient Array.map is poisoned', async () => { + const { project } = fixture(); + const sources = [{ root: project, prefix: '' }]; + const before = await hostedBaseSourceDigest(sources); + writeFileSync(join(project, 'helper.ts'), `export const identity = 'changed';\n`); + const originalMap = Array.prototype.map; + let poisonCalls = 0; + let after: string | undefined; + try { + Array.prototype.map = function poisonedMap() { + poisonCalls += 1; + return []; + } as typeof Array.prototype.map; + after = await hostedBaseSourceDigest(sources); + } finally { + Array.prototype.map = originalMap; + } + expect(poisonCalls).toBe(0); + expect(after).not.toBe(before); + }); + + it('defines source entries without consulting inherited numeric setters', async () => { + const { project } = fixture(); + writeFileSync(join(project, 'helper.ts'), `export const identity = 'authentic';\n`); + const previous = Object.getOwnPropertyDescriptor(Array.prototype, '0'); + let poisonCalls = 0; + try { + Object.defineProperty(Array.prototype, '0', { + configurable: true, + set(this: unknown[], value: unknown) { + const caller = (new Error().stack ?? '').split('\n', 3)[2] ?? ''; + if (caller.includes('/src/hosted-')) { + poisonCalls += 1; + throw new Error('inherited array setter must not run'); + } + Object.defineProperty(this, '0', { + configurable: true, + enumerable: true, + value, + writable: true, + }); + }, + }); + await expect(hostedBaseSourceDigest([{ root: project, prefix: '' }])).resolves.toMatch(/^[a-f0-9]{64}$/); + } finally { + if (previous === undefined) delete (Array.prototype as unknown as Record)['0']; + else Object.defineProperty(Array.prototype, '0', previous); + } + expect(poisonCalls).toBe(0); + }); + + it('uses the module-captured platform during source traversal', async () => { + const { project } = fixture(); + const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')!; + let poisonCalls = 0; + try { + Object.defineProperty(process, 'platform', { + configurable: descriptor.configurable, + get() { + const caller = (new Error().stack ?? '').split('\n', 3)[2] ?? ''; + if (caller.includes('/src/hosted-base-snapshot.')) { + poisonCalls += 1; + throw new Error('ambient process.platform must not run'); + } + return descriptor.value; + }, + }); + await expect(hostedBaseSourceDigest([{ root: project, prefix: '' }])).resolves.toMatch(/^[a-f0-9]{64}$/); + } finally { + Object.defineProperty(process, 'platform', descriptor); + } + expect(poisonCalls).toBe(0); + }); + + it('shadows native directory arrays before promise resolution can substitute them', async () => { + const { flowPath } = fixture(); + const previous = Object.getOwnPropertyDescriptor(Array.prototype, 'then'); + const empty: unknown[] = []; + Object.defineProperty(empty, 'then', { value: undefined }); + let poisonCalls = 0; + let snapshot: Awaited> | undefined; + try { + Object.defineProperty(Array.prototype, 'then', { + configurable: true, + get(this: unknown[]) { + const first = this[0] as { name?: unknown; isFile?: unknown } | undefined; + if (typeof first?.name === 'string' && typeof first.isFile === 'function') { + poisonCalls += 1; + return (resolvePromise: (value: unknown) => void) => resolvePromise(empty); + } + return undefined; + }, + }); + snapshot = await createHostedBaseSnapshot(flowPath); + expect(readFileSync(snapshot.snapshotFlowPath, 'utf8')).toContain('tenant base must not execute'); + } finally { + if (snapshot !== undefined) await removeHostedBaseSnapshot(snapshot); + if (previous === undefined) delete (Array.prototype as { then?: unknown }).then; + else Object.defineProperty(Array.prototype, 'then', previous); + } + expect(poisonCalls).toBe(0); + }); + + it('excludes project node_modules from the admitted generation', async () => { + const { project, flowPath } = fixture(); + const dependency = join(project, 'node_modules/local-identity'); + mkdirSync(dependency, { recursive: true }); + writeFileSync(join(dependency, 'package.json'), '{"name":"local-identity"}'); + writeFileSync(join(dependency, 'index.js'), `throw new Error('must not execute');\n`); + const snapshot = await createHostedBaseSnapshot(flowPath); + try { + expect(() => readFileSync(join(snapshot.snapshotRoot, 'node_modules/local-identity/index.js'))).toThrow(); + const before = await hostedBaseSourceDigest(snapshot.liveSources); + writeFileSync(join(dependency, 'index.js'), `throw new Error('replacement');\n`); + expect(await hostedBaseSourceDigest(snapshot.liveSources)).toBe(before); + } finally { + await removeHostedBaseSnapshot(snapshot); + } + }); + + it('refuses an oversized source file before buffering its contents', async () => { + const { project, flowPath } = fixture(); + const oversized = join(project, 'oversized.bin'); + writeFileSync(oversized, ''); + truncateSync(oversized, 64 * 1024 * 1024 + 1); + await expect(createHostedBaseSnapshot(flowPath)).rejects.toMatchObject({ + code: 'plugin_source_invalid', + message: expect.stringContaining('snapshot entry or byte limit'), + }); + }); + + it.each(['flows.json', 'flows.lock.json'])('refuses oversized sparse %s before parsing it', async declaration => { + const { project, flowPath } = fixture(); + const path = join(project, declaration); + if (!existsSync(path)) writeFileSync(path, ''); + truncateSync(path, 1024 * 1024 + 1); + await expect(loadHostedExtensionRuntime(flowPath)).rejects.toMatchObject({ + code: 'plugin_source_invalid', + message: expect.stringContaining('bounded regular file'), + }); + }); + + it('captures conversion and allocation across declarations and source snapshots', async () => { + const { flowPath } = fixture(); + const number = Number; + const allocUnsafe = Buffer.allocUnsafe; + let poisonCalls = 0; + try { + globalThis.Number = ((value?: unknown) => { + const stack = new Error().stack ?? ''; + const directCaller = stack.split('\n', 3)[2] ?? ''; + if ( + directCaller.includes('/src/hosted-base-snapshot.') || + directCaller.includes('/src/hosted-extension-runtime.') + ) { + poisonCalls += 1; + throw new Error('ambient Number must not run'); + } + return number(value); + }) as unknown as NumberConstructor; + Buffer.allocUnsafe = ((size: number) => { + const stack = new Error().stack ?? ''; + const directCaller = stack.split('\n', 3)[2] ?? ''; + if ( + directCaller.includes('/src/hosted-base-snapshot.') || + directCaller.includes('/src/hosted-extension-runtime.') + ) { + poisonCalls += 1; + throw new Error('ambient Buffer.allocUnsafe must not run'); + } + return allocUnsafe(size); + }) as typeof Buffer.allocUnsafe; + await expect(loadHostedExtensionRuntime(flowPath)).rejects.toMatchObject({ + code: 'plugin_source_invalid', + message: expect.stringContaining('reviewed Software Factory base source'), + }); + } finally { + globalThis.Number = number; + Buffer.allocUnsafe = allocUnsafe; + } + expect(poisonCalls).toBe(0); + }); + + it('keeps declaration and reviewed-base reads bound after builtin export synchronization', async () => { + const { flowPath } = fixture(); + const builtinFs = require('node:fs') as typeof import('node:fs'); + const builtinPath = require('node:path') as typeof import('node:path'); + const originalOpenSync = builtinFs.openSync; + const originalReadFile = builtinFs.promises.readFile; + const originalExistsSync = builtinFs.existsSync; + const originalResolve = builtinPath.resolve; + const originalJoin = builtinPath.join; + const originalDirname = builtinPath.dirname; + let poisonCalls = 0; + const directProductionCaller = (): boolean => { + const directCaller = (new Error().stack ?? '').split('\n', 4)[3] ?? ''; + return directCaller.includes('/src/hosted-extension-runtime.'); + }; + try { + Object.defineProperty(builtinFs, 'openSync', { + ...Object.getOwnPropertyDescriptor(builtinFs, 'openSync'), + value: (...args: unknown[]) => { + if (directProductionCaller()) { + poisonCalls += 1; + throw new Error('ambient openSync must not run'); + } + return Reflect.apply(originalOpenSync, builtinFs, args); + }, + }); + Object.defineProperty(builtinFs.promises, 'readFile', { + ...Object.getOwnPropertyDescriptor(builtinFs.promises, 'readFile'), + value: async (...args: unknown[]) => { + if (directProductionCaller()) { + poisonCalls += 1; + throw new Error('ambient readFile must not run'); + } + return await Reflect.apply(originalReadFile, builtinFs.promises, args); + }, + }); + Object.defineProperty(builtinFs, 'existsSync', { + ...Object.getOwnPropertyDescriptor(builtinFs, 'existsSync'), + value: (...args: unknown[]) => { + const caller = (new Error().stack ?? '').split('\n', 4)[3] ?? ''; + if (caller.includes('/src/hosted-project.')) { + poisonCalls += 1; + throw new Error('ambient existsSync must not run'); + } + return Reflect.apply(originalExistsSync, builtinFs, args); + }, + }); + for (const [name, original] of [ + ['resolve', originalResolve], ['join', originalJoin], ['dirname', originalDirname], + ] as const) { + Object.defineProperty(builtinPath, name, { + ...Object.getOwnPropertyDescriptor(builtinPath, name), + value: (...args: unknown[]) => { + const caller = (new Error().stack ?? '').split('\n', 4)[3] ?? ''; + if (caller.includes('/src/hosted-project.')) { + poisonCalls += 1; + throw new Error(`ambient path.${name} must not run`); + } + return Reflect.apply(original, builtinPath, args); + }, + }); + } + syncBuiltinESMExports(); + await expect(loadHostedExtensionRuntime(flowPath)).rejects.toMatchObject({ + code: 'plugin_source_invalid', + message: expect.stringContaining('reviewed Software Factory base source'), + }); + } finally { + Object.defineProperty(builtinFs, 'openSync', { + ...Object.getOwnPropertyDescriptor(builtinFs, 'openSync'), + value: originalOpenSync, + }); + Object.defineProperty(builtinFs.promises, 'readFile', { + ...Object.getOwnPropertyDescriptor(builtinFs.promises, 'readFile'), + value: originalReadFile, + }); + Object.defineProperty(builtinFs, 'existsSync', { + ...Object.getOwnPropertyDescriptor(builtinFs, 'existsSync'), + value: originalExistsSync, + }); + Object.defineProperty(builtinPath, 'resolve', { + ...Object.getOwnPropertyDescriptor(builtinPath, 'resolve'), value: originalResolve, + }); + Object.defineProperty(builtinPath, 'join', { + ...Object.getOwnPropertyDescriptor(builtinPath, 'join'), value: originalJoin, + }); + Object.defineProperty(builtinPath, 'dirname', { + ...Object.getOwnPropertyDescriptor(builtinPath, 'dirname'), value: originalDirname, + }); + syncBuiltinESMExports(); + } + expect(poisonCalls).toBe(0); + }); + + it('reads through captured descriptors and charges admitted descriptor sizes', async () => { + const { project, flowPath } = fixture(); + const sample = await openFileHandle(flowPath, 'r'); + const fileHandlePrototype = Object.getPrototypeOf(sample) as { + read: (...args: unknown[]) => unknown; + }; + const fileHandleRead = fileHandlePrototype.read; + await sample.close(); + const typedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype) as object; + const byteLength = Object.getOwnPropertyDescriptor(typedArrayPrototype, 'byteLength')!; + let poisonCalls = 0; + try { + fileHandlePrototype.read = function poisonedRead(this: unknown, ...args: unknown[]) { + poisonCalls += 1; + return Reflect.apply(fileHandleRead, this, args); + }; + Object.defineProperty(typedArrayPrototype, 'byteLength', { + ...byteLength, + get(this: Uint8Array) { + const stack = new Error().stack ?? ''; + const directCaller = stack.split('\n', 3)[2] ?? ''; + if (directCaller.includes('hosted-base-snapshot.')) { + poisonCalls += 1; + return 0; + } + return Reflect.apply(byteLength.get!, this, []); + }, + }); + await expect(hostedBaseSourceDigest([{ root: project, prefix: '' }])).resolves.toMatch(/^[a-f0-9]{64}$/); + } finally { + fileHandlePrototype.read = fileHandleRead; + Object.defineProperty(typedArrayPrototype, 'byteLength', byteLength); + } + expect(poisonCalls).toBe(0); + }); + + it('ignores inherited snapshot test hooks when production omits them', async () => { + const { project } = fixture(); + const names = ['beforeOpen', 'afterStat'] as const; + const previous = names.map(name => Object.getOwnPropertyDescriptor(Object.prototype, name)); + let poisonCalls = 0; + try { + for (const name of names) { + Object.defineProperty(Object.prototype, name, { + configurable: true, + value: async () => { + poisonCalls += 1; + throw new Error(`inherited ${name} must not run`); + }, + }); + } + await expect(hostedBaseSourceDigest([{ root: project, prefix: '' }])).resolves.toMatch(/^[a-f0-9]{64}$/); + } finally { + for (let index = 0; index < names.length; index += 1) { + const descriptor = previous[index]; + if (descriptor === undefined) delete (Object.prototype as Record)[names[index]!]; + else Object.defineProperty(Object.prototype, names[index]!, descriptor); + } + } + expect(poisonCalls).toBe(0); + }); + + it('bounds a file that grows after its admitted size was checked', async () => { + const { project } = fixture(); + const raced = join(project, 'raced.bin'); + writeFileSync(raced, 'small'); + await expect( + hostedBaseSourceDigest([{ root: project, prefix: '' }], { + afterStat: async path => { + if (path === raced) truncateSync(raced, 64 * 1024 * 1024 + 1); + }, + }), + ).rejects.toMatchObject({ + code: 'plugin_source_invalid', + message: expect.stringContaining('changed while reading "raced.bin"'), + }); + }); + + it.runIf(process.platform === 'linux' && existsSync('/usr/bin/mkfifo'))( + 'opens a substituted FIFO without blocking before rejecting it', + async () => { + const { project } = fixture(); + const raced = join(project, 'raced.txt'); + writeFileSync(raced, 'regular'); + let release: ReturnType | undefined; + const started = Date.now(); + try { + await expect( + hostedBaseSourceDigest([{ root: project, prefix: '' }], { + beforeOpen: async path => { + if (path !== raced) return; + rmSync(raced); + const result = spawnSync('/usr/bin/mkfifo', [raced]); + if (result.status !== 0) throw new Error(result.stderr.toString()); + // If O_NONBLOCK is removed, release the read-only open so the test + // fails on elapsed time instead of hanging the test process. + release = setTimeout(() => writeFileSync(raced, 'release'), 1_000); + }, + }), + ).rejects.toMatchObject({ + code: 'plugin_source_invalid', + message: expect.stringContaining('unsupported entry "raced.txt"'), + }); + expect(Date.now() - started).toBeLessThan(500); + } finally { + if (release !== undefined) clearTimeout(release); + } + }, + ); + + it('stops streaming project entries at the shared count limit', async () => { + const { project, flowPath } = fixture(); + for (let index = 0; index < 10_001; index += 1) { + writeFileSync(join(project, `empty-${index}.txt`), ''); + } + await expect(createHostedBaseSnapshot(flowPath)).rejects.toMatchObject({ + code: 'plugin_source_invalid', + message: expect.stringContaining('snapshot entry or byte limit'), + }); + }); +}); diff --git a/packages/sdk/tests/hosted-extension-isolation.test.ts b/packages/sdk/tests/hosted-extension-isolation.test.ts new file mode 100644 index 000000000..d065ae52f --- /dev/null +++ b/packages/sdk/tests/hosted-extension-isolation.test.ts @@ -0,0 +1,824 @@ +import { createHash } from 'node:crypto'; +import { + chmodSync, + copyFileSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + truncateSync, + writeFileSync, +} from 'node:fs'; +import { createServer } from 'node:http'; +import { createRequire, syncBuiltinESMExports } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/index.js'; +import { + loadHostedExtensionArtifacts, + runVerifiedNativeExtensionSandbox, + selectHostedExtensionForRuntime, + type HostedExtensionArtifact, +} from '../src/hosted-extension-isolation.js'; +import { runtimeVersions } from '../src/flow-extension-compat.js'; +import { validateFlowExtensionManifest } from '../src/flow-extension-manifest.js'; +import { materializePlugin } from '../src/plugin-store.js'; + +const roots: string[] = []; +const require = createRequire(import.meta.url); +afterEach(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))); + +const manifest = (writes: string[] = ['cloud:babysitter-turn']) => ({ + schema: 2, + kind: 'flow-extension', + name: 'babysitter', + version: '0.2.0', + compat: { surface: '*', sdk: '*', base: [{ name: 'software-factory', version: '*' }] }, + entry: 'babysitter.flow.ts', + extends: { handlers: true, hooks: [] }, + triggers: [ + { provider: 'github', event: 'pull_request', actions: ['opened', 'synchronize', 'reopened', 'ready_for_review', 'closed', 'labeled', 'unlabeled'] }, + { provider: 'github', event: 'pull_request_review', actions: ['submitted', 'dismissed'] }, + { provider: 'github', event: 'check_run', actions: ['completed'] }, + { provider: 'github', event: 'issue_comment', actions: ['created'] }, + ], + permissions: { + integrations: ['github'], harnesses: ['codex'], mcp: [], writes, + budget: { dollars: 1, wallclock: '5m' }, + }, + preflight: { credentials: [], servers: [] }, +}); + +const ordinaryExtension = ` +import { flow, github } from '@relayflows/surface'; +export default flow('babysitter', async f => f.done('declined')) + .on(github.pull_request('labeled'), async (f, input) => { + const receipt = await f.capabilities.cloud.babysitterTurn.queue({ delivery: { + deliveryId: input.event.deliveryId, + provider: input.event.provider, + eventType: input.event.eventType, + pullRequest: { owner: input.pullRequest.owner, repository: input.pullRequest.repo, number: input.pullRequest.number }, + } }); + if (!receipt || !['queued', 'duplicate'].includes(receipt.status)) throw new Error('bad receipt'); + f.done('success'); + }); +`; + +function descriptor(deliveryId = 'delivery-1') { + return { + event: { provider: 'github', eventType: 'pull_request.labeled', deliveryId }, + pullRequest: { owner: 'AgentWorkforce', repo: 'flows', number: 551 }, + }; +} + +async function artifact(source = ordinaryExtension, value = manifest()): Promise { + const root = mkdtempSync(join(tmpdir(), 'hosted-extension-test-')); roots.push(root); + const manifestBytes = Buffer.from(JSON.stringify(value)); + const stored = await materializePlugin(root, 'babysitter', [ + { path: 'flows-plugin.json', data: manifestBytes }, + { path: 'babysitter.flow.ts', data: Buffer.from(source) }, + ]); + return { + ref: `github:AgentWorkforce/flows@${'a'.repeat(40)}#extensions/babysitter`, + name: 'babysitter', + version: '0.2.0', + directory: stored.directory, + digest: stored.digest, + manifestSha256: createHash('sha256').update(manifestBytes).digest('hex'), + }; +} + +function surfaceFixture(): string { + const sourceRoot = resolve('../surface'); + const sourceManifest = JSON.parse(readFileSync(join(sourceRoot, 'package.json'), 'utf8')) as { + version: string; + }; + const surfaceRoot = mkdtempSync(join(tmpdir(), 'hosted-surface-test-')); + roots.push(surfaceRoot); + writeFileSync(join(surfaceRoot, 'package.json'), JSON.stringify({ + name: '@relayflows/surface', version: sourceManifest.version, type: 'module', + })); + for (const file of [ + 'flow.js', 'helpers/providers.js', 'provider-trigger.js', + 'schedule.js', 'triggers.js', 'triggers/github.js', + ]) { + const target = join(surfaceRoot, 'dist', file); + mkdirSync(resolve(target, '..'), { recursive: true }); + copyFileSync(join(sourceRoot, 'dist', file), target); + } + return surfaceRoot; +} + +/** Emit raw parent-protocol frames during import, before any handler can run. */ +function hostileImport(frames: readonly unknown[]): string { + const writes = frames.map(frame => `writeSync(3, ${JSON.stringify(`${JSON.stringify(frame)}\n`)});`).join('\n'); + return ` + import { writeSync } from 'node:fs'; + import { flow, github } from '@relayflows/surface'; + ${writes} + export default flow('babysitter', async f => f.done('declined')) + .on(github.pull_request('labeled'), async () => { await new Promise(() => {}); }); + `; +} + +describe('hosted extension capability isolation', () => { + it('keeps compile-time runtime pins synchronized with both package manifests', () => { + const sdk = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }; + const surface = JSON.parse(readFileSync(new URL('../../surface/package.json', import.meta.url), 'utf8')) as { + version: string; + }; + expect(runtimeVersions()).toEqual({ sdk: sdk.version, surface: surface.version }); + }); + + it('resolves locked artifacts without importing extension top-level code', async () => { + const marker = join(mkdtempSync(join(tmpdir(), 'hosted-loader-marker-')), 'imported'); + roots.push(marker.slice(0, marker.lastIndexOf('/'))); + const installed = await artifact(` + import { writeFileSync } from 'node:fs'; + writeFileSync(${JSON.stringify(marker)}, 'imported'); + ${ordinaryExtension} + `); + const root = resolve(installed.directory, '../../..'); + writeFileSync(join(root, 'flows.json'), JSON.stringify({ plugins: [installed.ref] })); + writeFileSync(join(root, 'flows.lock.json'), JSON.stringify({ + version: 2, + plugins: [{ + name: 'babysitter', kind: 'flow-extension', version: '0.2.0', + source: { + host: 'github', owner: 'AgentWorkforce', repo: 'flows', + sha: 'a'.repeat(40), path: 'extensions/babysitter', + }, + digest: installed.digest, manifestSha256: installed.manifestSha256, + order: 1, resolvedAt: '2026-09-22T12:00:00.000Z', + }], + })); + const flowPath = join(root, 'software-factory.flow.ts'); + writeFileSync(flowPath, 'export default {};'); + expect((await loadHostedExtensionArtifacts(flowPath)).artifacts).toEqual([installed]); + expect(() => readFileSync(marker)).toThrow(); + }); + + it('parses hosted locks and manifests without ambient map or freeze methods', async () => { + const installed = await artifact(); + const root = resolve(installed.directory, '../../..'); + writeFileSync(join(root, 'flows.json'), JSON.stringify({ plugins: [installed.ref] })); + writeFileSync(join(root, 'flows.lock.json'), JSON.stringify({ + version: 2, + plugins: [{ + name: 'babysitter', kind: 'flow-extension', version: '0.2.0', + source: { + host: 'github', owner: 'AgentWorkforce', repo: 'flows', + sha: 'a'.repeat(40), path: 'extensions/babysitter', + }, + digest: installed.digest, manifestSha256: installed.manifestSha256, + order: 1, resolvedAt: '2026-09-22T12:00:00.000Z', + }], + })); + const flowPath = join(root, 'software-factory.flow.ts'); + writeFileSync(flowPath, 'export default {};'); + const map = Array.prototype.map; + const freeze = Object.freeze; + let poisonCalls = 0; + try { + Array.prototype.map = function poisonedMap(this: unknown[], ...args: unknown[]) { + const caller = (new Error().stack ?? '').split('\n', 4)[3] ?? ''; + if (caller.includes('/src/hosted-extension-') || caller.includes('/src/plugin-lock.')) { + poisonCalls += 1; + throw new Error('ambient map must not run'); + } + return Reflect.apply(map, this, args as Parameters); + } as typeof Array.prototype.map; + Object.freeze = ((value: object) => { + const caller = (new Error().stack ?? '').split('\n', 4)[3] ?? ''; + if (caller.includes('/src/hosted-extension-') || caller.includes('/src/flow-extension-manifest.')) { + poisonCalls += 1; + throw new Error('ambient freeze must not run'); + } + return freeze(value); + }) as typeof Object.freeze; + const installation = await loadHostedExtensionArtifacts(flowPath); + await expect(selectHostedExtensionForRuntime( + installation, + { name: 'software-factory', version: '2.0.22' }, + { provider: 'github', event: 'pull_request', action: 'labeled' }, + runtimeVersions(), + )).rejects.toMatchObject({ code: 'plugin_event_unroutable' }); + } finally { + Array.prototype.map = map; + Object.freeze = freeze; + } + expect(poisonCalls).toBe(0); + }); + + it.each(['queued', 'duplicate'] as const)('executes the exact capability-only handler for a %s receipt', async status => { + const installed = await artifact(); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + const calls: unknown[] = []; + const result = await runVerifiedNativeExtensionSandbox({ + artifact: installed, manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), + babysitterTurn: { + queue: async (request, authority) => { + calls.push(request); + expect(authority.dispatch).toBe(dispatch); + expect(authority.extension).toMatchObject({ name: 'babysitter', version: '0.2.0', digest: installed.digest }); + return { receiptId: 'receipt-1', status }; + }, + }, + }); + expect(result).toEqual({ completionReason: 'success', capabilityCalls: 1 }); + expect(calls).toEqual([{ delivery: { + deliveryId: 'delivery-1', provider: 'github', eventType: 'pull_request.labeled', + pullRequest: { owner: 'AgentWorkforce', repository: 'flows', number: 551 }, + } }]); + }); + + it.runIf(process.platform === 'linux')( + 'launches through the captured process primitive after builtin export synchronization', + async () => { + const builtinChildProcess = require('node:child_process') as typeof import('node:child_process'); + const builtinFs = require('node:fs') as typeof import('node:fs'); + const originalSpawn = builtinChildProcess.spawn; + const originalReadFileSync = builtinFs.readFileSync; + const originalExecPath = process.execPath; + const installed = await artifact(); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + let poisonCalls = 0; + try { + Object.defineProperty(builtinChildProcess, 'spawn', { + ...Object.getOwnPropertyDescriptor(builtinChildProcess, 'spawn'), + value: (...args: unknown[]) => { + const caller = (new Error().stack ?? '').split('\n', 4)[3] ?? ''; + if (caller.includes('/src/hosted-extension-sandbox.')) { + poisonCalls += 1; + throw new Error('ambient spawn must not run'); + } + return Reflect.apply(originalSpawn, builtinChildProcess, args); + }, + }); + Object.defineProperty(builtinFs, 'readFileSync', { + ...Object.getOwnPropertyDescriptor(builtinFs, 'readFileSync'), + value: (...args: unknown[]) => { + const caller = (new Error().stack ?? '').split('\n', 4)[3] ?? ''; + if (caller.includes('/src/flow-extension-compat.')) { + poisonCalls += 1; + throw new Error('ambient runtime-version read must not run'); + } + return Reflect.apply(originalReadFileSync, builtinFs, args as Parameters); + }, + }); + syncBuiltinESMExports(); + process.execPath = '/attacker-controlled-node'; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, + manifest: validateFlowExtensionManifest(manifest()), + dispatch, + input: descriptor(), + babysitterTurn: { queue: async () => ({ receiptId: 'receipt-spawn', status: 'queued' }) }, + })).resolves.toEqual({ completionReason: 'success', capabilityCalls: 1 }); + } finally { + process.execPath = originalExecPath; + Object.defineProperty(builtinChildProcess, 'spawn', { + ...Object.getOwnPropertyDescriptor(builtinChildProcess, 'spawn'), value: originalSpawn, + }); + Object.defineProperty(builtinFs, 'readFileSync', { + ...Object.getOwnPropertyDescriptor(builtinFs, 'readFileSync'), value: originalReadFileSync, + }); + syncBuiltinESMExports(); + } + expect(poisonCalls).toBe(0); + }, + ); + + it('requires flows.json to own its plugins declaration', async () => { + const installed = await artifact(); + const root = resolve(installed.directory, '../../..'); + writeFileSync(join(root, 'flows.json'), '{}'); + writeFileSync(join(root, 'flows.lock.json'), JSON.stringify({ + version: 2, + plugins: [{ + name: 'babysitter', kind: 'flow-extension', version: '0.2.0', + source: { + host: 'github', owner: 'AgentWorkforce', repo: 'flows', + sha: 'a'.repeat(40), path: 'extensions/babysitter', + }, + digest: installed.digest, manifestSha256: installed.manifestSha256, + order: 1, resolvedAt: '2026-09-22T12:00:00.000Z', + }], + })); + const flowPath = join(root, 'software-factory.flow.ts'); + writeFileSync(flowPath, 'export default {};'); + const previous = Object.getOwnPropertyDescriptor(Object.prototype, 'plugins'); + let poisonCalls = 0; + try { + Object.defineProperty(Object.prototype, 'plugins', { + configurable: true, + get: () => { + poisonCalls += 1; + return [installed.ref]; + }, + }); + await expect(loadHostedExtensionArtifacts(flowPath)).rejects.toMatchObject({ + code: 'plugin_lock_invalid', + message: expect.stringContaining('declarations differ'), + }); + } finally { + if (previous === undefined) delete (Object.prototype as { plugins?: unknown }).plugins; + else Object.defineProperty(Object.prototype, 'plugins', previous); + } + expect(poisonCalls).toBe(0); + }); + + it.runIf(process.platform === 'linux')( + 'ignores inherited launcher overrides and decodes manifests with the captured Buffer intrinsic', + async () => { + const installed = await artifact(); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-options', + }); + const optionNames = [ + 'bubblewrapPath', 'nodePath', 'prlimitPath', 'surfaceRoot', 'timeoutMs', 'beforeLaunch', + ] as const; + const previousOptions = optionNames.map(name => Object.getOwnPropertyDescriptor(Object.prototype, name)); + const bufferToString = Buffer.prototype.toString; + let poisonCalls = 0; + try { + for (const name of optionNames) { + Object.defineProperty(Object.prototype, name, { + configurable: true, + value: name === 'beforeLaunch' + ? async () => { poisonCalls += 1; throw new Error('inherited beforeLaunch must not run'); } + : name === 'timeoutMs' ? 1 : `/attacker/${name}`, + }); + } + Buffer.prototype.toString = function poisonedToString(this: Buffer, ...args: unknown[]) { + const caller = (new Error().stack ?? '').split('\n', 3)[2] ?? ''; + if (caller.includes('/src/hosted-extension-isolation.')) { + poisonCalls += 1; + throw new Error('ambient Buffer.toString must not run'); + } + return Reflect.apply(bufferToString, this, args as [BufferEncoding?]); + } as typeof Buffer.prototype.toString; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, + manifest: validateFlowExtensionManifest(manifest()), + dispatch, + input: descriptor('delivery-options'), + babysitterTurn: { queue: async () => ({ receiptId: 'receipt-options', status: 'queued' }) }, + })).resolves.toEqual({ completionReason: 'success', capabilityCalls: 1 }); + } finally { + Buffer.prototype.toString = bufferToString; + for (let index = 0; index < optionNames.length; index += 1) { + const descriptor = previousOptions[index]; + if (descriptor === undefined) { + delete (Object.prototype as Record)[optionNames[index]!]; + } else Object.defineProperty(Object.prototype, optionNames[index]!, descriptor); + } + } + expect(poisonCalls).toBe(0); + }, + ); + + it('streams verified bytes when the live store is replaced and no writable staging path exists', async () => { + const installed = await artifact(); + const wrapperRoot = mkdtempSync(join(tmpdir(), 'hosted-bwrap-wrapper-')); + roots.push(wrapperRoot); + const wrapper = join(wrapperRoot, 'bwrap-wrapper'); + const replaced = `${installed.directory}.replaced`; + const replacementSource = hostileImport([{ type: 'error', message: 'replacement executed' }]); + const existingStaging = new Set( + readdirSync(tmpdir()).filter(name => name.startsWith('flows-hosted-extension-')), + ); + writeFileSync(wrapper, `#!${process.execPath} +const { mkdirSync, readdirSync, renameSync, writeFileSync } = require('node:fs'); +const { join } = require('node:path'); +const { spawnSync } = require('node:child_process'); +const existingStaging = new Set(${JSON.stringify([...existingStaging])}); +if (readdirSync(${JSON.stringify(tmpdir())}).some(name => name.startsWith('flows-hosted-extension-') && !existingStaging.has(name))) { + process.exit(91); +} +renameSync(${JSON.stringify(installed.directory)}, ${JSON.stringify(replaced)}); +mkdirSync(${JSON.stringify(installed.directory)}, { recursive: true }); +writeFileSync(join(${JSON.stringify(installed.directory)}, 'babysitter.flow.ts'), ${JSON.stringify(replacementSource)}); +const args = process.argv.slice(2); +let highestFd = 3; +for (let index = 0; index < args.length - 1; index += 1) { + if (args[index] === '--ro-bind-data') highestFd = Math.max(highestFd, Number(args[index + 1])); +} +const stdio = [0, 1, 2, 3]; +for (let fd = 4; fd <= highestFd; fd += 1) stdio[fd] = fd; +const child = spawnSync('/usr/bin/bwrap', args, { stdio }); +if (child.error) throw child.error; +process.exit(child.status ?? 1); +`); + chmodSync(wrapper, 0o700); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), + bubblewrapPath: wrapper, timeoutMs: 3_000, + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'receipt-1', status: 'queued' }; + } }, + })).resolves.toEqual({ completionReason: 'success', capabilityCalls: 1 }); + expect(calls).toBe(1); + expect(readFileSync(join(installed.directory, 'babysitter.flow.ts'), 'utf8')).toBe(replacementSource); + }); + + it('mounts pinned private Surface bytes when the live package changes before launch', async () => { + const surfaceRoot = surfaceFixture(); + const installed = await artifact(); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, + manifest: validateFlowExtensionManifest(manifest()), + dispatch, + input: descriptor(), + surfaceRoot, + beforeLaunch: async () => { + writeFileSync(join(surfaceRoot, 'dist/flow.js'), `throw new Error('live Surface executed');\n`); + }, + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'receipt-1', status: 'queued' }; + } }, + })).resolves.toEqual({ completionReason: 'success', capabilityCalls: 1 }); + expect(calls).toBe(1); + expect(readFileSync(join(surfaceRoot, 'dist/flow.js'), 'utf8')).toContain('live Surface executed'); + }); + + it('refuses Surface runtime bytes that differ from the reviewed pin before launch', async () => { + const surfaceRoot = surfaceFixture(); + writeFileSync(join(surfaceRoot, 'dist/flow.js'), `throw new Error('unreviewed Surface');\n`); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(), + manifest: validateFlowExtensionManifest(manifest()), + dispatch, + input: descriptor(), + surfaceRoot, + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'receipt-1', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(calls).toBe(0); + }); + + it('refuses oversized Surface files through the bounded descriptor reader', async () => { + const surfaceRoot = surfaceFixture(); + truncateSync(join(surfaceRoot, 'dist/flow.js'), 512 * 1024 + 1); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-surface-size', + }); + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(), + manifest: validateFlowExtensionManifest(manifest()), + dispatch, + input: descriptor('delivery-surface-size'), + surfaceRoot, + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'receipt-1', status: 'queued' }; + } }, + })).rejects.toMatchObject({ + code: 'plugin_unsupported', + message: expect.stringContaining('cannot read pinned Surface runtime flow.js'), + }); + expect(calls).toBe(0); + }); + + it('shields verified Surface files before async settlement', async () => { + const surfaceRoot = surfaceFixture(); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-surface-then', + }); + const previous = Object.getOwnPropertyDescriptor(Array.prototype, 'then'); + let poisonCalls = 0; + try { + Object.defineProperty(Array.prototype, 'then', { + configurable: true, + get(this: unknown[]) { + const first = this[0] as { destination?: unknown } | undefined; + if (this.length === 9 + && first?.destination === '/extension/node_modules/@relayflows/surface/package.json') { + poisonCalls += 1; + } + return undefined; + }, + }); + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(), + manifest: validateFlowExtensionManifest(manifest()), + dispatch, + input: descriptor('delivery-surface-then'), + surfaceRoot, + babysitterTurn: { queue: async () => ({ receiptId: 'receipt-1', status: 'queued' }) }, + })).resolves.toEqual({ completionReason: 'success', capabilityCalls: 1 }); + } finally { + if (previous === undefined) delete (Array.prototype as { then?: unknown }).then; + else Object.defineProperty(Array.prototype, 'then', previous); + } + expect(poisonCalls).toBe(0); + }); + + it('preserves a typed host refusal while disclosing only a fixed marker to the child', async () => { + const source = ` + import { flow, github } from '@relayflows/surface'; + export default flow('babysitter', async f => f.done('declined')) + .on(github.pull_request('labeled'), async (f, input) => { + try { + await f.capabilities.cloud.babysitterTurn.queue({ delivery: { + deliveryId: input.event.deliveryId, provider: input.event.provider, eventType: input.event.eventType, + pullRequest: { owner: input.pullRequest.owner, repository: input.pullRequest.repo, number: input.pullRequest.number }, + } }); + } catch (error) { + if (error?.message !== 'hosted capability refused') throw new Error('host refusal leaked into child'); + throw error; + } + }); + `; + const refusal = Object.assign(new Error('private Cloud policy detail'), { code: 'cloud_policy_refusal' }); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(source), manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), + babysitterTurn: { queue: async () => { throw refusal; } }, + })).rejects.toBe(refusal); + }); + + it('denies ambient credentials, host files, writes, network, subprocesses, and undeclared context verbs', async () => { + const server = createServer((_request, response) => response.end('host-network-visible')); + await new Promise((resolveListen, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolveListen); + }); + const address = server.address(); + if (address === null || typeof address === 'string') throw new Error('host network fixture did not listen'); + const sentinel = join(mkdtempSync(join(tmpdir(), 'hosted-sentinel-')), 'secret.txt'); + roots.push(sentinel.slice(0, sentinel.lastIndexOf('/'))); + writeFileSync(sentinel, 'host-secret'); + const escaped = `${sentinel}.escaped`; + const source = ` +import { flow, github } from '@relayflows/surface'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { connect } from 'node:net'; +const denied = async fn => { try { await fn(); return 'allowed'; } catch (error) { return error?.code ?? error?.message ?? 'denied'; } }; +const reachHostNetwork = () => new Promise((resolveNetwork, rejectNetwork) => { + const socket = connect({ host: '127.0.0.1', port: ${address.port} }); + const timer = setTimeout(() => { socket.destroy(); rejectNetwork(new Error('network timeout')); }, 1000); + socket.once('connect', () => { clearTimeout(timer); socket.destroy(); resolveNetwork(); }); + socket.once('error', error => { clearTimeout(timer); rejectNetwork(error); }); +}); +const evidence = { + ambient: process.env.HOSTED_EXTENSION_TEST_SECRET, + read: await denied(() => readFileSync(${JSON.stringify(sentinel)}, 'utf8')), + write: await denied(() => writeFileSync(${JSON.stringify(escaped)}, 'escape')), + child: await denied(() => { const result = spawnSync('/usr/bin/true'); if (result.error) throw result.error; }), + network: await denied(reachHostNetwork), +}; +export default flow('babysitter', async f => f.done('declined')) + .on(github.pull_request('labeled'), async (f, input) => { + evidence.run = await denied(() => f.run); + evidence.github = await denied(() => f.github); + evidence.mcp = await denied(() => f.mcp); + evidence.agent = await denied(() => f.agent); + if (evidence.ambient !== undefined + || ['read', 'write', 'child', 'network'].some(key => evidence[key] === 'allowed') + || ['run', 'github', 'mcp', 'agent'].some(key => !String(evidence[key]).startsWith('hosted extension context denies '))) { + throw new Error('sandbox escape was allowed'); + } + await f.capabilities.cloud.babysitterTurn.queue({ delivery: { + deliveryId: input.event.deliveryId, + provider: input.event.provider, + eventType: input.event.eventType, + pullRequest: { owner: input.pullRequest.owner, repository: input.pullRequest.repo, number: input.pullRequest.number }, + } }); + f.done('success'); + }); +`; + const installed = await artifact(source); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + const original = process.env.HOSTED_EXTENSION_TEST_SECRET; + process.env.HOSTED_EXTENSION_TEST_SECRET = 'host-secret-env'; + try { + await runVerifiedNativeExtensionSandbox({ + artifact: installed, manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), + babysitterTurn: { queue: async () => ({ receiptId: 'receipt-1', status: 'queued' }) }, + }); + } finally { + server.close(); + if (original === undefined) delete process.env.HOSTED_EXTENSION_TEST_SECRET; + else process.env.HOSTED_EXTENSION_TEST_SECRET = original; + } + expect(() => readFileSync(escaped)).toThrow(); + }); + + it('enforces OS address-space and data bounds on native Buffer allocation', async () => { + const installed = await artifact(` + const allocations = [Buffer.alloc(2 * 1024 * 1024 * 1024), Buffer.alloc(2 * 1024 * 1024 * 1024)]; + if (allocations.some(allocation => allocation.byteLength !== 2 * 1024 * 1024 * 1024)) throw new Error('short allocation'); + ${ordinaryExtension} + `); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, + manifest: validateFlowExtensionManifest(manifest()), + dispatch, + input: descriptor(), + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'receipt-1', status: 'queued' }; + } }, + })).rejects.toMatchObject({ + code: 'plugin_unsupported', + message: expect.stringMatching(/(?:Failed to allocate memory|Array buffer allocation failed)/u), + }); + expect(calls).toBe(0); + }); + + it('blocks extra handler fields and authority-bearing receipt fields at the parent port', async () => { + const source = ` + import { flow, github } from '@relayflows/surface'; + export default flow('babysitter', async f => f.done('declined')) + .on(github.pull_request('labeled'), async (f, input) => { + await f.capabilities.cloud.babysitterTurn.queue({ delivery: input, sessionId: 'attacker' }); + f.done('success'); + }); + `; + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(source), manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), + babysitterTurn: { queue: async () => { calls += 1; return { receiptId: 'r', status: 'queued' }; } }, + })).rejects.toMatchObject({ code: 'plugin_event_unroutable' }); + expect(calls).toBe(0); + + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(), manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), + babysitterTurn: { queue: async () => ({ receiptId: 'r', status: 'queued', sessionId: 'host-leak' }) }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + }); + + it('refuses unbranded or mismatched authority before the capability adapter runs', async () => { + const installed = await artifact(); + let calls = 0; + const capability = { queue: async () => { calls += 1; return { receiptId: 'r', status: 'queued' }; } }; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, manifest: validateFlowExtensionManifest(manifest()), + dispatch: { provenance: 'integration-watch', provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1' } as never, + input: descriptor(), babysitterTurn: capability, + })).rejects.toMatchObject({ code: 'plugin_event_unroutable' }); + + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor('attacker-delivery'), babysitterTurn: capability, + })).rejects.toMatchObject({ code: 'plugin_event_unroutable' }); + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, manifest: validateFlowExtensionManifest(manifest()), dispatch, + input: { ...descriptor(), rawWebhook: { installationToken: 'must-not-enter-child' } }, + babysitterTurn: capability, + })).rejects.toMatchObject({ code: 'plugin_event_unroutable' }); + expect(calls).toBe(0); + }); + + it('refuses broader permissions before importing extension code', async () => { + const marker = join(mkdtempSync(join(tmpdir(), 'hosted-import-marker-')), 'imported'); + roots.push(marker.slice(0, marker.lastIndexOf('/'))); + const installed = await artifact( + `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(marker)}, 'imported'); ${ordinaryExtension}`, + manifest(['cloud:babysitter-turn', 'github:pull_request']), + ); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, manifest: validateFlowExtensionManifest(manifest(['cloud:babysitter-turn', 'github:pull_request'])), dispatch, input: descriptor(), + babysitterTurn: { queue: async () => ({ receiptId: 'r', status: 'queued' }) }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(() => readFileSync(marker)).toThrow(); + }); + + it('constructs adapter authority with the captured freeze intrinsic', async () => { + const installed = await artifact(); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-freeze', + }); + const validatedManifest = validateFlowExtensionManifest(manifest()); + const originalFreeze = Object.freeze; + let poisonCalls = 0; + let result: Awaited> | undefined; + try { + Object.freeze = ((value: object) => { + if ('dispatch' in value || ('ref' in value && 'digest' in value && 'version' in value)) { + poisonCalls += 1; + throw new Error('ambient authority freeze'); + } + return originalFreeze(value); + }) as typeof Object.freeze; + result = await runVerifiedNativeExtensionSandbox({ + artifact: installed, + manifest: validatedManifest, + dispatch, + input: descriptor('delivery-freeze'), + babysitterTurn: { queue: async () => ({ receiptId: 'receipt-freeze', status: 'queued' }) }, + timeoutMs: 3_000, + }); + } finally { + Object.freeze = originalFreeze; + } + expect(poisonCalls).toBe(0); + expect(result).toEqual({ completionReason: 'success', capabilityCalls: 1 }); + }); + + it.runIf(process.platform === 'linux')( + 'writes the Surface manifest and protocol without inherited toJSON behavior', + async () => { + const installed = await artifact(); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-to-json', + }); + const previous = Object.getOwnPropertyDescriptor(Object.prototype, 'toJSON'); + let poisonCalls = 0; + try { + Object.defineProperty(Object.prototype, 'toJSON', { + configurable: true, + value: function poisonedToJSON(this: unknown) { + const caller = (new Error().stack ?? '').split('\n', 4)[3] ?? ''; + if (caller.includes('/src/hosted-extension-')) { + poisonCalls += 1; + throw new Error('ambient Object.prototype.toJSON must not run'); + } + return this; + }, + }); + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, + manifest: validateFlowExtensionManifest(manifest()), + dispatch, + input: descriptor('delivery-to-json'), + babysitterTurn: { queue: async () => ({ receiptId: 'receipt-to-json', status: 'queued' }) }, + timeoutMs: 3_000, + })).resolves.toEqual({ completionReason: 'success', capabilityCalls: 1 }); + } finally { + if (previous === undefined) delete (Object.prototype as { toJSON?: unknown }).toJSON; + else Object.defineProperty(Object.prototype, 'toJSON', previous); + } + expect(poisonCalls).toBe(0); + }, + ); + + it('fails closed when the handler omits or repeats the single capability call', async () => { + for (const body of [ + `f.done('success')`, + `await f.capabilities.cloud.babysitterTurn.queue({ delivery: { + deliveryId: input.event.deliveryId, provider: input.event.provider, eventType: input.event.eventType, + pullRequest: { owner: input.pullRequest.owner, repository: input.pullRequest.repo, number: input.pullRequest.number }, + } })`, + `const request = { delivery: { + deliveryId: input.event.deliveryId, provider: input.event.provider, eventType: input.event.eventType, + pullRequest: { owner: input.pullRequest.owner, repository: input.pullRequest.repo, number: input.pullRequest.number }, + } }; await f.capabilities.cloud.babysitterTurn.queue(request); await f.capabilities.cloud.babysitterTurn.queue(request); f.done('success')`, + ]) { + const installed = await artifact(` + import { flow, github } from '@relayflows/surface'; + export default flow('babysitter', async f => f.done('declined')) + .on(github.pull_request('labeled'), async (f, input) => { ${body}; }); + `); + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, manifest: validateFlowExtensionManifest(manifest()), dispatch, input: descriptor(), + babysitterTurn: { queue: async () => ({ receiptId: 'r', status: 'queued' }) }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + } + }); + +}); diff --git a/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts b/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts new file mode 100644 index 000000000..8634c1717 --- /dev/null +++ b/packages/sdk/tests/hosted-extension-protocol-intrinsics.test.ts @@ -0,0 +1,281 @@ +import type { ChildProcess } from 'node:child_process'; +import { EventEmitter } from 'node:events'; +import { PassThrough, Readable, Writable } from 'node:stream'; +import { expect, it } from 'vitest'; +import { exchangeHostedExtension } from '../src/hosted-extension-protocol.js'; + +it('constructs protocol completion with the captured Promise', async () => { + const NativePromise = Promise; + const protocol = new PassThrough(); + const stdin = new PassThrough(); + const stderr = new PassThrough(); + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: () => true, + }) as unknown as ChildProcess; + let adapterCalls = 0; + let poisonCalls = 0; + stdin.on('data', chunk => { + if (String(chunk).includes('capability-result')) { + protocol.write(`${JSON.stringify({ + type: 'result', completionReason: 'success', capabilityCalls: 1, + })}\n`); + } + }); + + class PoisonedPromise extends NativePromise { + constructor(executor: ConstructorParameters>[0]) { + poisonCalls += 1; + super(executor); + return NativePromise.resolve({ + completionReason: 'success', capabilityCalls: 1, + }) as unknown as PoisonedPromise; + } + } + + let run!: ReturnType; + try { + globalThis.Promise = PoisonedPromise as PromiseConstructor; + run = exchangeHostedExtension( + child, protocol, stdin, stderr, 10_000, { type: 'run' }, + async () => { + adapterCalls += 1; + return { receiptId: 'receipt-captured-constructor', status: 'queued' }; + }, + ); + } finally { + globalThis.Promise = NativePromise; + } + + protocol.write(`${JSON.stringify({ + type: 'capability', id: 1, name: 'cloud:babysitter-turn', request: { delivery: 'exact' }, + })}\n`); + await expect(run).resolves.toEqual({ completionReason: 'success', capabilityCalls: 1 }); + expect(adapterCalls).toBe(1); + expect(poisonCalls).toBe(0); +}); + +it('classifies adapter rejection with the captured Error brand', async () => { + const NativeError = Error; + const protocol = new PassThrough(); + const stdin = new PassThrough(); + const stderr = new PassThrough(); + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: () => true, + }) as unknown as ChildProcess; + const refusal = Object.assign(new NativeError('typed refusal'), { code: 'cloud_policy_refusal' }); + let poisonCalls = 0; + class PoisonedError extends NativeError { + static [Symbol.hasInstance](_value: unknown) { + poisonCalls += 1; + throw new NativeError('ambient Error brand must not run'); + } + } + let run!: ReturnType; + try { + globalThis.Error = PoisonedError as ErrorConstructor; + run = exchangeHostedExtension( + child, protocol, stdin, stderr, 10_000, { type: 'run' }, + async () => { throw refusal; }, + ); + protocol.write(`${JSON.stringify({ + type: 'capability', id: 1, name: 'cloud:babysitter-turn', request: { delivery: 'exact' }, + })}\n`); + await expect(run).rejects.toBe(refusal); + } finally { + globalThis.Error = NativeError; + } + expect(poisonCalls).toBe(0); +}); + +it('enforces the protocol deadline with captured timer operations', async () => { + const nativeSetTimeout = globalThis.setTimeout; + const nativeClearTimeout = globalThis.clearTimeout; + const sample = nativeSetTimeout(() => undefined, 60_000); + nativeClearTimeout(sample); + const timerPrototype = Object.getPrototypeOf(sample) as { unref: () => unknown }; + const nativeUnref = timerPrototype.unref; + const protocol = new PassThrough(); + const stdin = new PassThrough(); + const stderr = new PassThrough(); + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: () => true, + }) as unknown as ChildProcess; + let setTimeoutCalls = 0; + let clearTimeoutCalls = 0; + let unrefCalls = 0; + let unrefCallsDuringSetup = 0; + try { + globalThis.setTimeout = (() => { + setTimeoutCalls += 1; + return { unref: () => { unrefCalls += 1; } }; + }) as unknown as typeof setTimeout; + globalThis.clearTimeout = (() => { clearTimeoutCalls += 1; }) as typeof clearTimeout; + timerPrototype.unref = () => { unrefCalls += 1; }; + const run = exchangeHostedExtension( + child, protocol, stdin, stderr, 5, { type: 'run' }, + async () => ({ receiptId: 'never', status: 'queued' }), + ); + unrefCallsDuringSetup = unrefCalls; + timerPrototype.unref = nativeUnref; + await expect(run).rejects.toMatchObject({ code: 'plugin_unsupported' }); + } finally { + globalThis.setTimeout = nativeSetTimeout; + globalThis.clearTimeout = nativeClearTimeout; + timerPrototype.unref = nativeUnref; + } + expect({ setTimeoutCalls, clearTimeoutCalls, unrefCallsDuringSetup }).toEqual({ + setTimeoutCalls: 0, + clearTimeoutCalls: 0, + unrefCallsDuringSetup: 0, + }); +}); + +it('registers and completes protocol I/O with captured stream operations', async () => { + const eventOn = EventEmitter.prototype.on; + const eventOnce = EventEmitter.prototype.once; + const setEncoding = Readable.prototype.setEncoding; + const resume = Readable.prototype.resume; + const write = Writable.prototype.write; + const end = Writable.prototype.end; + const protocol = new PassThrough(); + const stdin = new PassThrough(); + const stderr = new PassThrough(); + let poisonCalls = 0; + let adapterCalls = 0; + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: () => { poisonCalls += 1; return true; }, + }) as unknown as ChildProcess; + let rejection: unknown; + try { + EventEmitter.prototype.on = function poisonedOn(this: EventEmitter, ...args) { + poisonCalls += 1; + return Reflect.apply(eventOn, this, args); + } as typeof EventEmitter.prototype.on; + EventEmitter.prototype.once = function poisonedOnce(this: EventEmitter, ...args) { + poisonCalls += 1; + return Reflect.apply(eventOnce, this, args); + } as typeof EventEmitter.prototype.once; + Readable.prototype.setEncoding = function poisonedSetEncoding(this: Readable, ...args) { + poisonCalls += 1; + return Reflect.apply(setEncoding, this, args); + } as typeof Readable.prototype.setEncoding; + Readable.prototype.resume = function poisonedResume(this: Readable, ...args) { + poisonCalls += 1; + return Reflect.apply(resume, this, args); + } as typeof Readable.prototype.resume; + Writable.prototype.write = function poisonedWrite(this: Writable, ...args) { + poisonCalls += 1; + return Reflect.apply(write, this, args); + } as typeof Writable.prototype.write; + Writable.prototype.end = function poisonedEnd(this: Writable, ...args) { + poisonCalls += 1; + return Reflect.apply(end, this, args); + } as typeof Writable.prototype.end; + + const run = exchangeHostedExtension( + child, protocol, stdin, stderr, 10_000, { type: 'run' }, + async () => { + adapterCalls += 1; + return { receiptId: 'must-not-run', status: 'queued' }; + }, + ); + Reflect.apply(write, protocol, ['{}\n']); + try { await run; } catch (error) { rejection = error; } + } finally { + EventEmitter.prototype.on = eventOn; + EventEmitter.prototype.once = eventOnce; + Readable.prototype.setEncoding = setEncoding; + Readable.prototype.resume = resume; + Writable.prototype.write = write; + Writable.prototype.end = end; + } + expect(rejection).toMatchObject({ code: 'plugin_unsupported' }); + expect({ poisonCalls, adapterCalls }).toEqual({ poisonCalls: 0, adapterCalls: 0 }); +}); + +it('serializes protocol envelopes without inherited toJSON behavior', async () => { + const previous = Object.getOwnPropertyDescriptor(Object.prototype, 'toJSON'); + const protocol = new PassThrough(); + const stdin = new PassThrough(); + const stderr = new PassThrough(); + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: () => true, + }) as unknown as ChildProcess; + let poisonCalls = 0; + let adapterCalls = 0; + stdin.on('data', chunk => { + if (String(chunk).includes('capability-result')) { + protocol.write('{"type":"result","completionReason":"success","capabilityCalls":1}\n'); + } + }); + + try { + Object.defineProperty(Object.prototype, 'toJSON', { + configurable: true, + value: () => { + poisonCalls += 1; + throw new Error('ambient Object.prototype.toJSON must not run'); + }, + }); + const run = exchangeHostedExtension( + child, protocol, stdin, stderr, 10_000, { type: 'execute', identity: { provider: 'github' } }, + async () => { + adapterCalls += 1; + return { receiptId: 'receipt-to-json', status: 'queued' }; + }, + ); + protocol.write('{"type":"capability","id":1,"name":"cloud:babysitter-turn","request":{"delivery":"exact"}}\n'); + await expect(run).resolves.toEqual({ completionReason: 'success', capabilityCalls: 1 }); + } finally { + if (previous === undefined) delete (Object.prototype as { toJSON?: unknown }).toJSON; + else Object.defineProperty(Object.prototype, 'toJSON', previous); + } + expect({ poisonCalls, adapterCalls }).toEqual({ poisonCalls: 0, adapterCalls: 1 }); +}); + +it('rejects frames without consulting an inherited type discriminator', async () => { + const previous = Object.getOwnPropertyDescriptor(Object.prototype, 'type'); + const protocol = new PassThrough(); + const stdin = new PassThrough(); + const stderr = new PassThrough(); + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: () => true, + }) as unknown as ChildProcess; + let poisonCalls = 0; + let adapterCalls = 0; + try { + Object.defineProperty(Object.prototype, 'type', { + configurable: true, + get: () => { + const caller = (new Error().stack ?? '').split('\n', 4)[3] ?? ''; + if (caller.includes('/src/hosted-extension-protocol.')) poisonCalls += 1; + return 'capability'; + }, + }); + const run = exchangeHostedExtension( + child, protocol, stdin, stderr, 10_000, { type: 'execute' }, + async () => { + adapterCalls += 1; + return { receiptId: 'must-not-run', status: 'queued' }; + }, + ); + protocol.write('{}\n'); + await expect(run).rejects.toMatchObject({ code: 'plugin_unsupported' }); + } finally { + if (previous === undefined) delete (Object.prototype as { type?: unknown }).type; + else Object.defineProperty(Object.prototype, 'type', previous); + } + expect({ poisonCalls, adapterCalls }).toEqual({ poisonCalls: 0, adapterCalls: 0 }); +}); diff --git a/packages/sdk/tests/hosted-extension-protocol.test.ts b/packages/sdk/tests/hosted-extension-protocol.test.ts new file mode 100644 index 000000000..d5a86b8d0 --- /dev/null +++ b/packages/sdk/tests/hosted-extension-protocol.test.ts @@ -0,0 +1,512 @@ +import { createHash } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { createRequire, syncBuiltinESMExports } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; +import type { ChildProcess } from 'node:child_process'; +import { afterEach, describe, expect, it } from 'vitest'; +import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/index.js'; +import { + runVerifiedNativeExtensionSandbox, + type HostedExtensionArtifact, +} from '../src/hosted-extension-isolation.js'; +import { + boundedJsonSnapshot, + exchangeHostedExtension, +} from '../src/hosted-extension-protocol.js'; +import { snapshotJsonValue } from '../src/json-value.js'; +import { validateFlowExtensionManifest } from '../src/flow-extension-manifest.js'; +import { materializePlugin } from '../src/plugin-store.js'; + +const roots: string[] = []; +const require = createRequire(import.meta.url); +afterEach(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))); + +const manifest = () => ({ + schema: 2, + kind: 'flow-extension', + name: 'babysitter', + version: '0.2.0', + compat: { surface: '*', sdk: '*', base: [{ name: 'software-factory', version: '*' }] }, + entry: 'babysitter.flow.ts', + extends: { handlers: true, hooks: [] }, + triggers: [ + { provider: 'github', event: 'pull_request', actions: ['opened', 'synchronize', 'reopened', 'ready_for_review', 'closed', 'labeled', 'unlabeled'] }, + { provider: 'github', event: 'pull_request_review', actions: ['submitted', 'dismissed'] }, + { provider: 'github', event: 'check_run', actions: ['completed'] }, + { provider: 'github', event: 'issue_comment', actions: ['created'] }, + ], + permissions: { + integrations: ['github'], harnesses: ['codex'], mcp: [], writes: ['cloud:babysitter-turn'], + budget: { dollars: 1, wallclock: '5m' }, + }, + preflight: { credentials: [], servers: [] }, +}); + +function descriptor() { + return { + event: { provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1' }, + pullRequest: { owner: 'AgentWorkforce', repo: 'flows', number: 551 }, + }; +} + +async function artifact(source: string): Promise { + const root = mkdtempSync(join(tmpdir(), 'hosted-protocol-test-')); + roots.push(root); + const manifestBytes = Buffer.from(JSON.stringify(manifest())); + const stored = await materializePlugin(root, 'babysitter', [ + { path: 'flows-plugin.json', data: manifestBytes }, + { path: 'babysitter.flow.ts', data: Buffer.from(source) }, + ]); + return { + ref: `github:AgentWorkforce/flows@${'a'.repeat(40)}#extensions/babysitter`, + name: 'babysitter', version: '0.2.0', directory: stored.directory, digest: stored.digest, + manifestSha256: createHash('sha256').update(manifestBytes).digest('hex'), + }; +} + +function capabilityFrame(overrides: Record = {}): Record { + return { + type: 'capability', id: 1, name: 'cloud:babysitter-turn', + request: { delivery: { + deliveryId: 'delivery-1', provider: 'github', eventType: 'pull_request.labeled', + pullRequest: { owner: 'AgentWorkforce', repository: 'flows', number: 551 }, + ...overrides, + } }, + }; +} + +function hostileImport(frames: readonly unknown[]): string { + const writes = frames.map(frame => `writeSync(3, ${JSON.stringify(`${JSON.stringify(frame)}\n`)});`).join('\n'); + return ` + import { writeSync } from 'node:fs'; + import { flow, github } from '@relayflows/surface'; + ${writes} + export default flow('babysitter', async f => f.done('declined')) + .on(github.pull_request('labeled'), async () => { await new Promise(() => {}); }); + `; +} + +function normalImport(): string { + return ` + import { flow, github } from '@relayflows/surface'; + export default flow('babysitter', async f => f.done('declined')) + .on(github.pull_request('labeled'), async (f, input) => { + await f.capabilities.cloud.babysitterTurn.queue({ delivery: { + deliveryId: input.event.deliveryId, + provider: input.event.provider, + eventType: input.event.eventType, + pullRequest: { + owner: input.pullRequest.owner, + repository: input.pullRequest.repo, + number: input.pullRequest.number, + }, + } }); + f.done('success'); + }); + `; +} + +const dispatch = () => hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', +}); + +async function waitForInvocation(invoked: Promise): Promise { + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('hostile child did not invoke the adapter')), 10_000); + void invoked.then(() => { clearTimeout(timeout); resolve(); }, reject); + }); +} + +describe('hosted extension hostile protocol', () => { + it('counts a control followed by a low surrogate as two JSON escapes', () => { + expect(() => snapshotJsonValue('\0\udc00', 'delivery', { + maxDepth: 4, maxNodes: 4, maxBytes: 13, + })).toThrow(/snapshot byte limit exceeded/); + }); + + it('caps object cardinality without materializing the complete key list', () => { + const hostile = Object.create(null) as Record; + for (let index = 0; index < 10_000; index += 1) hostile[`ignored-${index}`] = undefined; + const started = Date.now(); + expect(() => snapshotJsonValue(hostile, 'delivery', { + maxDepth: 4, maxNodes: 32, maxBytes: 1024, + })).toThrow(/snapshot depth or node limit exceeded/); + expect(Date.now() - started).toBeLessThan(1_000); + }); + + it('copies only JSON-visible metadata into the behavior-free snapshot', () => { + const value = { visible: 'yes' } as Record; + Object.defineProperty(value, 'hidden', { value: 'no', enumerable: false }); + value[Symbol('hidden')] = 'no'; + expect(snapshotJsonValue(value, 'delivery')).toEqual({ visible: 'yes' }); + }); + + it('rejects a Proxy in an array prototype chain without executing its traps', () => { + let traps = 0; + const prototype = new Proxy(Array.prototype, { + ownKeys: () => { traps += 1; return []; }, + getOwnPropertyDescriptor: () => { traps += 1; return undefined; }, + }); + const value: unknown[] = []; + Object.setPrototypeOf(value, prototype); + expect(() => snapshotJsonValue(value, 'delivery')).toThrow(/intrinsic prototype/); + expect(traps).toBe(0); + }); + + it('uses the captured proxy detector after builtin export synchronization', () => { + const builtinUtil = require('node:util') as typeof import('node:util'); + const originalIsProxy = builtinUtil.types.isProxy; + let poisonCalls = 0; + try { + builtinUtil.types.isProxy = (() => { + poisonCalls += 1; + return false; + }) as typeof builtinUtil.types.isProxy; + syncBuiltinESMExports(); + expect(() => snapshotJsonValue(new Proxy({}, {}), 'delivery')).toThrow(/Proxy objects are not allowed/); + } finally { + builtinUtil.types.isProxy = originalIsProxy; + syncBuiltinESMExports(); + } + expect(poisonCalls).toBe(0); + }); + + it('shadows a poisoned Array.prototype.toJSON on copied arrays', () => { + const previous = Object.getOwnPropertyDescriptor(Array.prototype, 'toJSON'); + let calls = 0; + try { + Object.defineProperty(Array.prototype, 'toJSON', { + configurable: true, + value: () => { calls += 1; return 'x'.repeat(1024 * 1024); }, + }); + const snapshot = snapshotJsonValue(['safe'], 'delivery', { + maxDepth: 4, maxNodes: 4, maxBytes: 32, + }); + expect(JSON.stringify(snapshot)).toBe('["safe"]'); + expect(calls).toBe(0); + } finally { + if (previous === undefined) delete (Array.prototype as { toJSON?: unknown }).toJSON; + else Object.defineProperty(Array.prototype, 'toJSON', previous); + } + }); + + it('uses captured byte-counting intrinsics', () => { + const byteLength = Object.getOwnPropertyDescriptor(Buffer, 'byteLength')!; + const charCodeAt = Object.getOwnPropertyDescriptor(String.prototype, 'charCodeAt')!; + let calls = 0; + try { + Object.defineProperty(Buffer, 'byteLength', { + ...byteLength, value: () => { calls += 1; return 0; }, + }); + Object.defineProperty(String.prototype, 'charCodeAt', { + ...charCodeAt, value: () => { calls += 1; return 0; }, + }); + expect(boundedJsonSnapshot({ delivery: 'safe' }, 'delivery')).toEqual({ delivery: 'safe' }); + } finally { + Object.defineProperty(Buffer, 'byteLength', byteLength); + Object.defineProperty(String.prototype, 'charCodeAt', charCodeAt); + } + expect(calls).toBe(0); + }); + + it.each(['inherited toJSON', 'stateful getter'] as const)( + 'refuses a delivery descriptor with %s before the adapter', async attack => { + const input = descriptor(); + let hostile: unknown = input; + if (attack === 'inherited toJSON') { + hostile = Object.assign(Object.create({ + toJSON: () => ({ ...input, pullRequest: { owner: 'attacker', repo: 'other', number: 999 } }), + }), input); + } else { + Object.defineProperty(input.pullRequest, 'owner', { + enumerable: true, + get: () => 'AgentWorkforce', + }); + } + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(normalImport()), + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: hostile, + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'never', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(calls).toBe(0); + }, + ); + + it('bounds an exponentially shared delivery graph before cloning it', async () => { + let shared: Record = { leaf: 'x' }; + for (let depth = 0; depth < 40; depth += 1) shared = { left: shared, right: shared }; + const input = { ...descriptor(), extra: shared }; + let calls = 0; + const started = Date.now(); + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(normalImport()), + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input, + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'never', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(Date.now() - started).toBeLessThan(2_000); + expect(calls).toBe(0); + }); + + it('rejects an oversized escaped string before materializing its JSON encoding', async () => { + const input = { ...descriptor(), extra: '\0'.repeat(1024 * 1024) }; + let calls = 0; + const started = Date.now(); + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(normalImport()), + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input, + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'never', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(Date.now() - started).toBeLessThan(2_000); + expect(calls).toBe(0); + }); + + it('rejects extra delivery fields with Array.prototype.sort poisoned', async () => { + const installed = await artifact(normalImport()); + const validated = validateFlowExtensionManifest(manifest()); + const input = { ...descriptor(), sessionId: 'forged-session' }; + const sort = Object.getOwnPropertyDescriptor(Array.prototype, 'sort')!; + let calls = 0; + try { + Object.defineProperty(Array.prototype, 'sort', { ...sort, value: () => [] }); + await expect(runVerifiedNativeExtensionSandbox({ + artifact: installed, manifest: validated, dispatch: dispatch(), input, + babysitterTurn: { queue: async () => { + calls += 1; + return { receiptId: 'never', status: 'queued' }; + } }, + })).rejects.toMatchObject({ code: 'plugin_event_unroutable' }); + } finally { + Object.defineProperty(Array.prototype, 'sort', sort); + } + expect(calls).toBe(0); + }); + + it.runIf(process.platform === 'linux')( + 'uses captured JSON intrinsics for the complete parent boundary', async () => { + const installed = await artifact(normalImport()); + const validated = validateFlowExtensionManifest(manifest()); + const trustedDispatch = dispatch(); + const stringify = Object.getOwnPropertyDescriptor(JSON, 'stringify')!; + const parse = Object.getOwnPropertyDescriptor(JSON, 'parse')!; + const calls: unknown[] = []; + let result: Awaited>; + try { + Object.defineProperty(JSON, 'stringify', { ...stringify, value: () => '{"forged":true}' }); + Object.defineProperty(JSON, 'parse', { ...parse, value: () => ({ forged: true }) }); + result = await runVerifiedNativeExtensionSandbox({ + artifact: installed, + manifest: validated, + dispatch: trustedDispatch, + input: descriptor(), + babysitterTurn: { queue: async request => { + calls.push(request); + return { receiptId: 'receipt-1', status: 'queued' }; + } }, + }); + } finally { + Object.defineProperty(JSON, 'stringify', stringify); + Object.defineProperty(JSON, 'parse', parse); + } + expect(result!).toEqual({ completionReason: 'success', capabilityCalls: 1 }); + expect(calls).toEqual([{ delivery: { + deliveryId: 'delivery-1', provider: 'github', eventType: 'pull_request.labeled', + pullRequest: { owner: 'AgentWorkforce', repository: 'flows', number: 551 }, + } }]); + }, + ); + + it('rejects completion while an exact direct call is pending', async () => { + let settle!: (value: unknown) => void; + const adapter = new Promise(resolve => { settle = resolve; }); + let markInvoked!: () => void; + const invoked = new Promise(resolve => { markInvoked = resolve; }); + let calls = 0; + const protocol = new PassThrough(); + const stdin = new PassThrough(); + const stderr = new PassThrough(); + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: () => true, + }) as unknown as ChildProcess; + const run = exchangeHostedExtension( + child, + protocol, + stdin, + stderr, + 10_000, + { type: 'run' }, + async () => { calls += 1; markInvoked(); return await adapter; }, + ); + protocol.write(`${JSON.stringify(capabilityFrame())}\n${JSON.stringify({ + type: 'result', completionReason: 'success', capabilityCalls: 1, + })}\n`); + await waitForInvocation(invoked); + settle({ receiptId: 'settled-after-refusal', status: 'queued' }); + await expect(run).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(calls).toBe(1); + }, 15_000); + + it('settles successful completion with captured intrinsics', async () => { + const protocol = new PassThrough(); + const stdin = new PassThrough(); + const stderr = new PassThrough(); + const child = Object.assign(new EventEmitter(), { + exitCode: null, + signalCode: null, + kill: () => true, + }) as unknown as ChildProcess; + const freeze = Object.freeze; + const then = Promise.prototype.then; + const adapter = Promise.resolve({ receiptId: 'receipt-captured-intrinsics', status: 'queued' }); + let poisonCalls = 0; + let result: Awaited> | undefined; + try { + Object.freeze = ((value: object) => { + if ('completionReason' in value) { + poisonCalls += 1; + return { completionReason: 'forged', capabilityCalls: 0 } as typeof value; + } + return freeze(value); + }) as typeof Object.freeze; + Promise.prototype.then = function poisonedThen(this: Promise, onfulfilled, onrejected) { + if (this === adapter) { + poisonCalls += 1; + onfulfilled?.({ receiptId: 'forged', status: 'queued' }); + return Promise.resolve(undefined) as Promise; + } + return Reflect.apply(then, this, [onfulfilled, onrejected]) as Promise; + } as typeof Promise.prototype.then; + stdin.on('data', chunk => { + if (String(chunk).includes('capability-result')) { + protocol.write(`${JSON.stringify({ + type: 'result', completionReason: 'success', capabilityCalls: 1, + })}\n`); + } + }); + const run = exchangeHostedExtension( + child, protocol, stdin, stderr, 10_000, { type: 'run' }, + () => adapter, + ); + protocol.write(`${JSON.stringify(capabilityFrame())}\n`); + result = await run; + } finally { + Object.freeze = freeze; + Promise.prototype.then = then; + } + expect(poisonCalls).toBe(0); + expect(result).toEqual({ completionReason: 'success', capabilityCalls: 1 }); + }); + + it('rejects non-object protocol output with zero adapter calls', async () => { + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(hostileImport([null])), + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: descriptor(), + babysitterTurn: { queue: async () => { calls += 1; return { receiptId: 'never', status: 'queued' }; } }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(calls).toBe(0); + }); + + it.each([ + ['different PR', capabilityFrame({ pullRequest: { owner: 'AgentWorkforce', repository: 'flows', number: 999 } })], + ['different delivery', capabilityFrame({ deliveryId: 'delivery-2' })], + ['different event', capabilityFrame({ eventType: 'pull_request.opened' })], + ])('rejects an import-time %s frame with zero adapter calls', async (_case, frame) => { + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(hostileImport([frame, { type: 'error', message: 'forged terminal' }])), + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: descriptor(), timeoutMs: 1_000, + babysitterTurn: { queue: async () => { calls += 1; return { receiptId: 'never', status: 'queued' }; } }, + })).rejects.toMatchObject({ code: 'plugin_event_unroutable' }); + expect(calls).toBe(0); + }); + + it('rejects a forged result before any capability call', async () => { + let calls = 0; + await expect(runVerifiedNativeExtensionSandbox({ + artifact: await artifact(hostileImport([{ type: 'result', completionReason: 'success', capabilityCalls: 1 }])), + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: descriptor(), + babysitterTurn: { queue: async () => { calls += 1; return { receiptId: 'never', status: 'queued' }; } }, + })).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(calls).toBe(0); + }); + + it('rejects two forged calls after the authoritative first outcome settles', async () => { + let settle!: (value: unknown) => void; + const adapter = new Promise(resolve => { settle = resolve; }); + let markInvoked!: () => void; + const invoked = new Promise(resolve => { markInvoked = resolve; }); + let calls = 0; + const run = runVerifiedNativeExtensionSandbox({ + artifact: await artifact(hostileImport([capabilityFrame(), capabilityFrame()])), + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: descriptor(), timeoutMs: 10_000, + babysitterTurn: { queue: async () => { + calls += 1; + markInvoked(); + return await adapter; + } }, + }); + await waitForInvocation(invoked); + settle({ receiptId: 'settled', status: 'queued' }); + await expect(run).rejects.toMatchObject({ code: 'plugin_unsupported' }); + expect(calls).toBe(1); + }, 15_000); + + it.each(['reject', 'resolve'] as const)( + 'waits for a pending adapter to %s after a forged child error', async outcome => { + const refusal = Object.assign(new Error('typed Cloud refusal'), { code: 'cloud_policy_refusal' }); + let settle!: (value: unknown) => void; + let reject!: (error: Error) => void; + const adapter = new Promise((resolve, rejectPromise) => { settle = resolve; reject = rejectPromise; }); + let markInvoked!: () => void; + const invoked = new Promise(resolve => { markInvoked = resolve; }); + let completed = false; + const run = runVerifiedNativeExtensionSandbox({ + artifact: await artifact(hostileImport([capabilityFrame(), { type: 'error', message: 'premature' }])), + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: descriptor(), timeoutMs: 1_000, + babysitterTurn: { queue: async () => { markInvoked(); return await adapter; } }, + }).finally(() => { completed = true; }); + const observed = run.then(() => undefined, error => error as Error); + await waitForInvocation(invoked); + expect(completed).toBe(false); + if (outcome === 'reject') reject(refusal); + else settle({ receiptId: 'settled', status: 'queued' }); + const error = await observed; + if (outcome === 'reject') expect(error).toBe(refusal); + else expect(error).toMatchObject({ code: 'plugin_unsupported' }); + }, 15_000, + ); + + it('returns a typed adapter rejection even when the hostile child hangs', async () => { + const refusal = Object.assign(new Error('typed Cloud refusal'), { code: 'cloud_policy_refusal' }); + let rejectAdapter!: (error: Error) => void; + const adapter = new Promise((_resolve, reject) => { rejectAdapter = reject; }); + let markInvoked!: () => void; + const invoked = new Promise(resolve => { markInvoked = resolve; }); + let calls = 0; + const run = runVerifiedNativeExtensionSandbox({ + artifact: await artifact(hostileImport([capabilityFrame()])), + manifest: validateFlowExtensionManifest(manifest()), dispatch: dispatch(), input: descriptor(), timeoutMs: 10_000, + babysitterTurn: { queue: async () => { calls += 1; markInvoked(); return await adapter; } }, + }); + const observed = run.then(() => undefined, error => error as Error); + await waitForInvocation(invoked); + rejectAdapter(refusal); + expect(await observed).toBe(refusal); + expect(calls).toBe(1); + }, 15_000); +}); diff --git a/packages/sdk/tests/hosted-extension-routing.test.ts b/packages/sdk/tests/hosted-extension-routing.test.ts new file mode 100644 index 000000000..4d46b5a1a --- /dev/null +++ b/packages/sdk/tests/hosted-extension-routing.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from 'vitest'; +import { closeSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { github } from '@relayflows/surface'; +import { + extensionHandlerForHostedDispatch, + hostedExtensionDispatchIdentity, +} from '../src/flow-extension-loader.js'; +import { hostedExtensionDispatchFromVerifiedDelivery } from '../src/index.js'; +import { hostedManifestRoutes } from '../src/hosted-extension-isolation.js'; +import { + captureExecutable, + readCapturedExecutable, + sandboxArguments, + supportsHostedSandboxFlags, +} from '../src/hosted-extension-sandbox.js'; + +describe('hosted extension routing policy', () => { + it('accepts only Node releases that implement every sandbox flag', () => { + expect(supportsHostedSandboxFlags('22.12.0')).toBe(false); + expect(supportsHostedSandboxFlags('22.13.0')).toBe(true); + expect(supportsHostedSandboxFlags('23.4.0')).toBe(false); + expect(supportsHostedSandboxFlags('23.5.0')).toBe(true); + expect(supportsHostedSandboxFlags('24.0.0')).toBe(true); + expect(supportsHostedSandboxFlags('not-a-version')).toBe(false); + }); + + it('parses the captured Node version without ambient regex or number hooks', () => { + const exec = RegExp.prototype.exec; + const number = Number; + let poisonCalls = 0; + try { + RegExp.prototype.exec = (() => { + poisonCalls += 1; + throw new Error('ambient RegExp.exec must not run'); + }) as typeof RegExp.prototype.exec; + globalThis.Number = (() => { + poisonCalls += 1; + throw new Error('ambient Number must not run'); + }) as unknown as NumberConstructor; + expect(supportsHostedSandboxFlags('24.0.0')).toBe(true); + } finally { + RegExp.prototype.exec = exec; + globalThis.Number = number; + } + expect(poisonCalls).toBe(0); + }); + + it('constructs the pinned Surface mounts without ambient array methods', () => { + const surfaceFiles = [ + 'flow.js', 'helpers/providers.js', 'provider-trigger.js', 'schedule.js', + 'triggers.js', 'triggers/github.js', + ]; + const dataDestinations = ['/runtime/node', '/runtime/runner.mjs', '/extension/src/babysitter.flow.ts']; + for (let index = 0; index < surfaceFiles.length; index += 1) { + dataDestinations[dataDestinations.length] = `/extension/node_modules/@relayflows/surface/dist/${surfaceFiles[index]!}`; + } + const flatMap = Array.prototype.flatMap; + const push = Array.prototype.push; + let calls = 0; + let args: string[] | undefined; + try { + Array.prototype.flatMap = function poisonedFlatMap( + this: unknown[], + callback: (value: unknown, index: number, array: unknown[]) => unknown, + thisArg?: unknown, + ) { + if (this.length === 6 && this[0] === 'flow.js') { + calls += 1; + return ['--bind', '/attacker', '/runtime/runner.mjs'] as never[]; + } + return Reflect.apply(flatMap, this, [callback, thisArg]) as never[]; + } as typeof Array.prototype.flatMap; + Array.prototype.push = function poisonedPush(this: unknown[], ...values: unknown[]) { + if (this[0] === '--unshare-all') { + calls += 1; + return this.length; + } + return Reflect.apply(push, this, values); + } as typeof Array.prototype.push; + args = sandboxArguments({ dataDestinations }); + } finally { + Array.prototype.flatMap = flatMap; + Array.prototype.push = push; + } + expect(calls).toBe(0); + expect(args).not.toContain('/attacker'); + for (const file of surfaceFiles) { + expect(args).toContain(`/extension/node_modules/@relayflows/surface/dist/${file}`); + } + expect(args).toContain('--ro-bind-data'); + expect(args).not.toContain('/trusted/extension'); + const nodeIndex = args.indexOf('/runtime/node'); + expect(args.slice(nodeIndex - 4, nodeIndex + 1)).toEqual([ + '--perms', '0500', '--ro-bind-data', '4', '/runtime/node', + ]); + }); + + it('reads the executable inode captured before an atomic path replacement', () => { + const root = mkdtempSync(join(tmpdir(), 'hosted-node-pin-')); + const executable = join(root, 'node'); + const replacement = join(root, 'replacement'); + writeFileSync(executable, 'reviewed-node-bytes'); + writeFileSync(replacement, 'attacker-node-bytes'); + const capture = captureExecutable(executable, 'test Node'); + try { + renameSync(replacement, executable); + expect(readCapturedExecutable(capture, 'test Node').toString('utf8')) + .toBe('reviewed-node-bytes'); + } finally { + closeSync(capture.descriptor); + rmSync(root, { recursive: true, force: true }); + } + }); + + it('matches the SDK router for exact, absent, duplicate, and generic-overlap routes', () => { + const body = async () => {}; + const specific = { name: 'specific', handlers: [{ trigger: github.pull_request('labeled'), body }] }; + const duplicate = { name: 'duplicate', handlers: [{ trigger: github.pull_request('labeled'), body }] }; + const generic = { name: 'generic', handlers: [{ trigger: github.pull_request(), body }] }; + const labeled = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }); + const edited = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.edited', deliveryId: 'delivery-2', + }); + expect(extensionHandlerForHostedDispatch(labeled, [specific])?.extension.name).toBe('specific'); + expect(hostedManifestRoutes( + { triggers: [{ provider: 'github', event: 'pull_request', actions: ['labeled'] }] }, + { provider: 'github', event: 'pull_request', action: 'labeled' }, + )).toBe(true); + expect(extensionHandlerForHostedDispatch(edited, [specific])).toBeUndefined(); + expect(hostedManifestRoutes( + { triggers: [{ provider: 'github', event: 'pull_request', actions: ['labeled'] }] }, + { provider: 'github', event: 'pull_request', action: 'edited' }, + )).toBe(false); + expect(() => extensionHandlerForHostedDispatch(labeled, [specific, duplicate])) + .toThrow(expect.objectContaining({ code: 'plugin_event_ambiguous' })); + expect(() => extensionHandlerForHostedDispatch(labeled, [specific, generic])) + .toThrow(expect.objectContaining({ code: 'plugin_event_ambiguous' })); + expect(() => extensionHandlerForHostedDispatch({ + provenance: 'integration-watch', provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-1', + }, [specific])).toThrow(expect.objectContaining({ code: 'plugin_event_unroutable' })); + }); + + it('keeps the refusal match when Array.prototype has an inherited numeric setter', () => { + const body = async () => {}; + const handler = { trigger: github.pull_request('labeled'), body }; + const extension = { name: 'specific', handlers: [handler] }; + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-setter', + }); + const defineProperty = Object.defineProperty; + const previous = Object.getOwnPropertyDescriptor(Array.prototype, '0'); + let poisonCalls = 0; + let selected: ReturnType; + try { + defineProperty(Array.prototype, '0', { + configurable: true, + set(this: unknown[], value: unknown) { + const match = value as { extension?: unknown; handler?: unknown } | null; + if (match !== null && typeof match === 'object' + && match.extension === extension && match.handler === handler) { + poisonCalls += 1; + return; + } + defineProperty(this, '0', { + configurable: true, + enumerable: true, + value, + writable: true, + }); + }, + }); + selected = extensionHandlerForHostedDispatch(dispatch, [extension]); + } finally { + if (previous === undefined) Reflect.deleteProperty(Array.prototype, '0'); + else defineProperty(Array.prototype, '0', previous); + } + expect(poisonCalls).toBe(0); + expect(selected?.extension).toBe(extension); + expect(selected?.handler).toBe(handler); + }); + + it('parses and routes dispatch authority with captured intrinsics', () => { + const body = async () => {}; + const extension = { name: 'specific', handlers: [{ trigger: github.pull_request('labeled'), body }] }; + const originals = { + freeze: Object.freeze, + split: String.prototype.split, + test: RegExp.prototype.test, + some: Array.prototype.some, + flatMap: Array.prototype.flatMap, + }; + let poisonCalls = 0; + let identity: ReturnType | undefined; + let selected: ReturnType; + try { + Object.freeze = (() => { poisonCalls += 1; throw new Error('ambient freeze'); }) as typeof Object.freeze; + String.prototype.split = (() => { poisonCalls += 1; return ['forged']; }) as typeof String.prototype.split; + RegExp.prototype.test = (() => { poisonCalls += 1; return false; }) as typeof RegExp.prototype.test; + Array.prototype.some = (() => { poisonCalls += 1; return true; }) as typeof Array.prototype.some; + Array.prototype.flatMap = (() => { poisonCalls += 1; return []; }) as typeof Array.prototype.flatMap; + const dispatch = hostedExtensionDispatchFromVerifiedDelivery({ + provider: 'github', eventType: 'pull_request.labeled', deliveryId: 'delivery-poison', + }); + identity = hostedExtensionDispatchIdentity(dispatch); + selected = extensionHandlerForHostedDispatch(dispatch, [extension]); + } finally { + Object.freeze = originals.freeze; + String.prototype.split = originals.split; + RegExp.prototype.test = originals.test; + Array.prototype.some = originals.some; + Array.prototype.flatMap = originals.flatMap; + } + expect(poisonCalls).toBe(0); + expect(identity).toEqual({ provider: 'github', event: 'pull_request', action: 'labeled' }); + expect(selected?.extension).toBe(extension); + }); +}); diff --git a/packages/sdk/tests/plugin-store-bounds.test.ts b/packages/sdk/tests/plugin-store-bounds.test.ts new file mode 100644 index 000000000..c2188a08c --- /dev/null +++ b/packages/sdk/tests/plugin-store-bounds.test.ts @@ -0,0 +1,280 @@ +import { mkdtempSync, readdirSync, rmSync, truncateSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { payloadManifest, sha256 } from '../src/bundle.js'; +import { materializePlugin, readStoredPluginFiles, verifyStoredPlugin } from '../src/plugin-store.js'; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach(root => rmSync(root, { recursive: true, force: true }))); + +async function fixture() { + const root = mkdtempSync(join(tmpdir(), 'plugin-store-bounds-')); + roots.push(root); + const stored = await materializePlugin(root, 'bounded', [ + { path: 'flows-plugin.json', data: Buffer.from('{"name":"bounded"}') }, + { path: 'entry.ts', data: Buffer.from('export default {};') }, + { path: 'nested/value.ts', data: Buffer.from('export const value = 1;') }, + ]); + return stored; +} + +describe('bounded plugin-store verification', () => { + it('hashes the exact supplied buffers without ambient sort or map', () => { + const sort = Array.prototype.sort; + const map = Array.prototype.map; + let calls = 0; + let encoded: string | undefined; + try { + Array.prototype.sort = (() => { + calls += 1; + return []; + }) as typeof Array.prototype.sort; + Array.prototype.map = (() => { + calls += 1; + return []; + }) as typeof Array.prototype.map; + encoded = payloadManifest([ + { path: 'z.ts', data: Buffer.from('z') }, + { path: 'a.ts', data: Buffer.from('a') }, + ]); + } finally { + Array.prototype.sort = sort; + Array.prototype.map = map; + } + expect(calls).toBe(0); + expect(JSON.parse(encoded!)).toMatchObject([{ path: 'a.ts' }, { path: 'z.ts' }]); + }); + + it('counts manifest bytes without the ambient typed-array length getter', () => { + const typedArrayPrototype = Object.getPrototypeOf(Uint8Array.prototype) as object; + const length = Object.getOwnPropertyDescriptor(typedArrayPrototype, 'length')!; + const files = [{ path: 'entry.ts', data: Buffer.from('exact bytes') }]; + let poisonCalls = 0; + let encoded: string | undefined; + try { + Object.defineProperty(typedArrayPrototype, 'length', { + ...length, + get() { + poisonCalls += 1; + throw new Error('ambient typed-array length must not run'); + }, + }); + encoded = payloadManifest(files); + } finally { + Object.defineProperty(typedArrayPrototype, 'length', length); + } + expect(poisonCalls).toBe(0); + expect(JSON.parse(encoded!)).toEqual([ + { bytes: 11, path: 'entry.ts', sha256: 'e38e581aade78b64cc86f7ac9f3555ca78c2dcca747942a7f1d9b3275a834f75' }, + ]); + }); + + it('refuses an oversized sparse payload before buffering it', async () => { + const stored = await fixture(); + truncateSync(join(stored.directory, 'entry.ts'), 256_001); + await expect(verifyStoredPlugin(stored.directory, stored.digest)).rejects.toMatchObject({ + code: 'plugin_source_drift', + message: expect.stringContaining('expected a bounded regular file'), + }); + }); + + it('refuses an oversized sparse manifest before hashing it', async () => { + const stored = await fixture(); + truncateSync(join(stored.directory, 'manifest.json'), 4_000_001); + const bigint = BigInt; + let poisonCalls = 0; + try { + globalThis.BigInt = (() => { + poisonCalls += 1; + return 1n << 62n; + }) as unknown as BigIntConstructor; + await expect(verifyStoredPlugin(stored.directory, stored.digest)).rejects.toMatchObject({ + code: 'plugin_source_drift', + message: expect.stringContaining('expected a bounded regular file'), + }); + } finally { + globalThis.BigInt = bigint; + } + expect(poisonCalls).toBe(0); + }); + + it('uses captured conversion and allocation intrinsics after size admission', async () => { + const stored = await fixture(); + const number = Number; + const allocUnsafe = Buffer.allocUnsafe; + let poisonCalls = 0; + try { + globalThis.Number = ((value?: unknown) => { + const stack = new Error().stack ?? ''; + const directCaller = stack.split('\n', 3)[2] ?? ''; + if (directCaller.includes('/src/plugin-store.')) { + poisonCalls += 1; + throw new Error('ambient Number must not run'); + } + return number(value); + }) as unknown as NumberConstructor; + Buffer.allocUnsafe = ((size: number) => { + const stack = new Error().stack ?? ''; + const directCaller = stack.split('\n', 3)[2] ?? ''; + if (directCaller.includes('/src/plugin-store.')) { + poisonCalls += 1; + throw new Error('ambient Buffer.allocUnsafe must not run'); + } + return allocUnsafe(size); + }) as typeof Buffer.allocUnsafe; + await expect(verifyStoredPlugin(stored.directory, stored.digest)).resolves.toBeUndefined(); + } finally { + globalThis.Number = number; + Buffer.allocUnsafe = allocUnsafe; + } + expect(poisonCalls).toBe(0); + }); + + it('shadows stored buffers and arrays before async return assimilation', async () => { + const stored = await fixture(); + const prototypes = [Buffer.prototype, Array.prototype]; + const previous = prototypes.map(prototype => Object.getOwnPropertyDescriptor(prototype, 'then')); + let poisonCalls = 0; + try { + for (const prototype of prototypes) { + Object.defineProperty(prototype, 'then', { + configurable: true, + get() { + poisonCalls += 1; + return undefined; + }, + }); + } + const files = await readStoredPluginFiles(stored.directory, stored.digest); + expect(files.map(file => file.path)).toEqual([ + 'manifest.json', 'entry.ts', 'flows-plugin.json', 'nested/value.ts', + ]); + } finally { + for (let index = 0; index < prototypes.length; index += 1) { + const descriptor = previous[index]; + if (descriptor === undefined) delete (prototypes[index]! as { then?: unknown }).then; + else Object.defineProperty(prototypes[index]!, 'then', descriptor); + } + } + expect(poisonCalls).toBe(0); + }); + + it('snapshots manifest entries before reading their fields', async () => { + const stored = await fixture(); + const raw = '[{}]'; + writeFileSync(join(stored.directory, 'manifest.json'), raw); + const previous = Object.getOwnPropertyDescriptor(Object.prototype, 'path'); + let poisonCalls = 0; + try { + Object.defineProperty(Object.prototype, 'path', { + configurable: true, + enumerable: true, + get() { + const caller = (new Error().stack ?? '').split('\n', 3)[2] ?? ''; + if (caller.includes('/src/plugin-store.')) { + poisonCalls += 1; + throw new Error('inherited manifest field must not run'); + } + return undefined; + }, + set(this: object, value: unknown) { + Object.defineProperty(this, 'path', { + configurable: true, + enumerable: true, + value, + writable: true, + }); + }, + }); + await expect(verifyStoredPlugin(stored.directory, sha256(raw))).rejects.toMatchObject({ + code: 'plugin_source_drift', + message: expect.stringContaining('manifest.json lists an invalid file'), + }); + } finally { + if (previous === undefined) delete (Object.prototype as { path?: unknown }).path; + else Object.defineProperty(Object.prototype, 'path', previous); + } + expect(poisonCalls).toBe(0); + }); + + it('uses the module-captured platform while opening stored files', async () => { + const stored = await fixture(); + const descriptor = Object.getOwnPropertyDescriptor(process, 'platform')!; + let poisonCalls = 0; + try { + Object.defineProperty(process, 'platform', { + configurable: descriptor.configurable, + get() { + const caller = (new Error().stack ?? '').split('\n', 3)[2] ?? ''; + if (caller.includes('/src/plugin-store.')) { + poisonCalls += 1; + throw new Error('ambient process.platform must not run'); + } + return descriptor.value; + }, + }); + await expect(verifyStoredPlugin(stored.directory, stored.digest)).resolves.toBeUndefined(); + } finally { + Object.defineProperty(process, 'platform', descriptor); + } + expect(poisonCalls).toBe(0); + }); + + it('ignores inherited store test hooks when production omits them', async () => { + const stored = await fixture(); + const names = ['beforeOpen', 'afterStat', 'beforeDirectoryStat'] as const; + const previous = names.map(name => Object.getOwnPropertyDescriptor(Object.prototype, name)); + let poisonCalls = 0; + try { + for (const name of names) { + Object.defineProperty(Object.prototype, name, { + configurable: true, + value: async () => { + poisonCalls += 1; + throw new Error(`inherited ${name} must not run`); + }, + }); + } + await expect(verifyStoredPlugin(stored.directory, stored.digest)).resolves.toBeUndefined(); + } finally { + for (let index = 0; index < names.length; index += 1) { + const descriptor = previous[index]; + if (descriptor === undefined) delete (Object.prototype as Record)[names[index]!]; + else Object.defineProperty(Object.prototype, names[index]!, descriptor); + } + } + expect(poisonCalls).toBe(0); + }); + + it('bounds a payload that grows after its admitted size was checked', async () => { + const stored = await fixture(); + const raced = join(stored.directory, 'entry.ts'); + await expect( + readStoredPluginFiles(stored.directory, stored.digest, { + afterStat: async path => { + if (path === raced) { + writeFileSync(raced, Buffer.alloc(256_001)); + } + }, + }), + ).rejects.toMatchObject({ + code: 'plugin_source_drift', + message: expect.stringContaining('changed while reading'), + }); + }); + + it.runIf(process.platform === 'linux')('closes a child descriptor when directory stat fails', async () => { + const stored = await fixture(); + const before = readdirSync('/proc/self/fd').length; + for (let attempt = 0; attempt < 50; attempt += 1) { + await expect(readStoredPluginFiles(stored.directory, stored.digest, { + beforeDirectoryStat: async () => { throw new Error('injected directory stat failure'); }, + })).rejects.toMatchObject({ + code: 'plugin_source_drift', + message: expect.stringContaining('nested/value.ts is missing'), + }); + } + expect(readdirSync('/proc/self/fd').length).toBeLessThanOrEqual(before + 2); + }); +}); diff --git a/packages/sdk/tsconfig.tests.json b/packages/sdk/tsconfig.tests.json index cc9298fa2..a8aeec620 100644 --- a/packages/sdk/tsconfig.tests.json +++ b/packages/sdk/tsconfig.tests.json @@ -26,6 +26,12 @@ "tests/authored-flow-operation.test.ts", "tests/authored-flow.test.ts", "tests/authored-agent-permissions.test.ts", + "tests/hosted-extension-isolation.test.ts", + "tests/hosted-extension-routing.test.ts", + "tests/hosted-base-snapshot.test.ts", + "tests/plugin-store-bounds.test.ts", + "tests/hosted-extension-protocol-intrinsics.test.ts", + "tests/hosted-extension-protocol.test.ts", "tests/flow-executor-chain.test.ts", "tests/input-binding.test.ts", "tests/journal-client-loopback.ts", diff --git a/packages/surface/src/cloud.ts b/packages/surface/src/cloud.ts index 38382d9e9..d97ecc0da 100644 --- a/packages/surface/src/cloud.ts +++ b/packages/surface/src/cloud.ts @@ -74,3 +74,30 @@ export interface CloudHelper { journal(input: { runId: string; as: string }): Step; }; } + +/** Delivery-only request accepted by the host-owned native Babysitter adapter. */ +export interface CloudBabysitterTurnDelivery { + readonly deliveryId: string; + readonly provider: 'github'; + readonly eventType: string; + readonly pullRequest: { + readonly owner: string; + readonly repository: string; + readonly number: number; + }; +} + +/** The only successful native-turn outcomes; refusals reject the call. */ +export interface CloudBabysitterTurnReceipt { + readonly receiptId: string; + readonly status: 'queued' | 'duplicate'; +} + +export interface CloudBabysitterTurnCapability { + queue(request: { readonly delivery: CloudBabysitterTurnDelivery }): PromiseLike; +} + +/** Capability ports are injected by a host runtime, never constructed by authored input. */ +export interface CloudCapabilities { + readonly babysitterTurn?: CloudBabysitterTurnCapability; +} diff --git a/packages/surface/src/context.ts b/packages/surface/src/context.ts index a9820ae76..7dd467d54 100644 --- a/packages/surface/src/context.ts +++ b/packages/surface/src/context.ts @@ -1,6 +1,6 @@ import type { Helpers } from "./helpers/index.js"; import type { MemoryHelper } from "./memory.js"; -import type { CloudHelper } from "./cloud.js"; +import type { CloudCapabilities, CloudHelper } from "./cloud.js"; import type { FlowCompletionReason } from "./completion.js"; import type { Step } from "./step.js"; @@ -56,6 +56,8 @@ export interface LlmOptions { * context or execute a step, so all effects remain behind the journal client. */ export interface Ctx extends Helpers { + /** Host-verified ports. Absent from direct/local runs and ordinary authored execution. */ + readonly capabilities?: { readonly cloud?: CloudCapabilities }; readonly mcp: Readonly Step>>>>; /** Command lease: milliseconds or a duration such as "5m"; default 30s, maximum 15m. */ run(command: string, options?: { timeout?: string | number }): Step; diff --git a/packages/surface/src/index.ts b/packages/surface/src/index.ts index 4a6884f60..2daff08e3 100644 --- a/packages/surface/src/index.ts +++ b/packages/surface/src/index.ts @@ -6,6 +6,10 @@ export type { ScheduleState, WorkerSummary, CloudHelper, + CloudCapabilities, + CloudBabysitterTurnCapability, + CloudBabysitterTurnDelivery, + CloudBabysitterTurnReceipt, } from "./cloud.js"; export type { AgentOptions, AgentResult, PermissionsSpec, LlmOptions, Ctx } from "./context.js"; export {