From 7a2daccbd8b8e6764e11e2e8090742758e60b8d4 Mon Sep 17 00:00:00 2001 From: Ricky Schema Cascade Date: Sat, 12 Sep 2026 22:11:21 +0200 Subject: [PATCH] feat: add workforce sandbox session composition Add persona parameter mapping, SDK composition and CLI dispatch with real UNIX-socket coverage. Rename deploy dev to local with compatibility aliases. Record missing Relay SDK exports, Cloud enforcement, and validation blockers explicitly; no agent-relay CLI fallback. --- README.md | 26 ++- docs/plans/deploy-v1-workflow-spec.md | 2 + docs/plans/deploy-v1.md | 2 + examples/linear-shipper/README.md | 2 +- examples/proactive-issue-resolver/README.md | 14 +- .../specs/cloud-mode-stub.md | 2 +- .../proactive-issue-resolver/trigger-issue.sh | 4 +- examples/review-agent/README.md | 2 +- examples/weekly-digest/README.md | 2 +- impl/branch.txt | 1 + impl/summary.md | 1 + impl/upstream-blockers.md | 47 +++++ impl/validation.md | 42 ++++ packages/cli/README.md | 20 ++ packages/cli/src/agent-sandbox.test.ts | 66 ++++++ packages/cli/src/cli-impl.ts | 138 ++++++++++++- packages/cli/src/cli.test.ts | 34 ++- packages/cli/src/deploy-command.test.ts | 12 ++ packages/cli/src/deploy-command.ts | 15 +- packages/cli/src/local-surface-command.ts | 2 +- packages/cli/src/runtime-picker.test.ts | 2 +- packages/cli/src/runtime-picker.ts | 4 +- packages/deploy/README.md | 27 +++ packages/deploy/src/deploy.test.ts | 150 ++++++++------ packages/deploy/src/deploy.ts | 27 ++- packages/deploy/src/index.test.ts | 23 +++ packages/deploy/src/index.ts | 13 +- packages/deploy/src/modes/dev.ts | 194 +----------------- .../deploy/src/modes/input-values.test.ts | 10 +- packages/deploy/src/modes/local.ts | 192 +++++++++++++++++ .../src/modes/sandbox-interactive.e2e.test.ts | 45 ++++ .../src/modes/sandbox-interactive.test.ts | 159 ++++++++++++++ .../deploy/src/modes/sandbox-interactive.ts | 163 +++++++++++++++ .../deploy/src/modes/sandbox-shared.test.ts | 29 +++ packages/deploy/src/modes/sandbox-shared.ts | 34 +++ packages/deploy/src/modes/sandbox.test.ts | 17 ++ packages/deploy/src/modes/sandbox.ts | 49 ++--- packages/deploy/src/types.ts | 17 +- .../src/index.integration.test.ts | 14 +- packages/local-surface/src/index.test.ts | 2 +- packages/local-surface/src/index.ts | 8 +- .../persona-to-sandbox-params.errors.json | 5 + packages/persona-kit/src/index.ts | 2 + .../src/persona-to-sandbox-params.test.ts | 72 +++++++ .../src/persona-to-sandbox-params.ts | 89 ++++++++ packages/runtime/src/runner.ts | 2 +- scripts/deploy-mode-rename.test.mjs | 11 + scripts/no-agent-relay-shell.test.mjs | 32 +++ 48 files changed, 1476 insertions(+), 350 deletions(-) create mode 100644 impl/branch.txt create mode 100644 impl/summary.md create mode 100644 impl/upstream-blockers.md create mode 100644 impl/validation.md create mode 100644 packages/cli/src/agent-sandbox.test.ts create mode 100644 packages/deploy/README.md create mode 100644 packages/deploy/src/index.test.ts create mode 100644 packages/deploy/src/modes/local.ts create mode 100644 packages/deploy/src/modes/sandbox-interactive.e2e.test.ts create mode 100644 packages/deploy/src/modes/sandbox-interactive.test.ts create mode 100644 packages/deploy/src/modes/sandbox-interactive.ts create mode 100644 packages/deploy/src/modes/sandbox-shared.test.ts create mode 100644 packages/deploy/src/modes/sandbox-shared.ts create mode 100644 packages/persona-kit/src/__fixtures__/persona-to-sandbox-params.errors.json create mode 100644 packages/persona-kit/src/persona-to-sandbox-params.test.ts create mode 100644 packages/persona-kit/src/persona-to-sandbox-params.ts create mode 100644 scripts/deploy-mode-rename.test.mjs create mode 100644 scripts/no-agent-relay-shell.test.mjs diff --git a/README.md b/README.md index 976460c6..a1e666bd 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Typed personas can be deployed directly, or compiled first when you need a portable JSON artifact: ```bash -agentworkforce deploy ./examples/review-agent/persona.ts --mode dev --dry-run +agentworkforce deploy ./examples/review-agent/persona.ts --mode local --dry-run agentworkforce persona compile ./examples/review-agent/persona.ts ``` @@ -47,10 +47,10 @@ export DAYTONA_API_KEY=... workforce deploy ./examples/weekly-digest/persona.json --sandbox --byo-sandbox ``` -For local iteration, run it in dev mode: +For local iteration, run it in local mode: ```bash -BRAVE_API_KEY=... workforce deploy ./examples/weekly-digest/persona.json --dev +BRAVE_API_KEY=... workforce deploy ./examples/weekly-digest/persona.json --mode local ``` The example searches Brave on a weekly cron schedule, clusters findings, and @@ -421,3 +421,23 @@ console.log(selection.personaId, selection.tier); For lower-level primitives, see [`packages/workload-router/README.md`](./packages/workload-router/README.md). + +### Interactive sandbox sessions + +`agentworkforce agent --mode local` is the default interactive launch. +`agentworkforce agent --mode sandbox` selects a Cloud sandbox session. +Use `--sandbox-provider daytona|e2b`, `--sandbox-id ` to replay an identity, +`--attach-mode view|drive` (default `drive`), and `--byo-sandbox` for BYO auth. +Run `agentworkforce login` first to select an active workspace. Ctrl-C stops the +session and waits for sandbox cleanup. `agent --mode cloud` is invalid; use +`agentworkforce deploy --mode cloud` for a hosted service. + +Interactive sandbox launch requires the Relay SDK `/fleet` and `/attach` +contracts. They are not yet exported by the published Relay SDK checked during +this implementation; until that dependency ships, the command reports the +missing SDK contract. Read-only mount enforcement also requires Cloud support. + +For hosted services, use `deploy --mode local|sandbox|cloud`. The previous +`deploy --mode dev` spelling still works and warns; it will be removed in the +next minor release. Existing `devLauncher` and `resolvers.modes.dev` library +callers have the same deprecation window. diff --git a/docs/plans/deploy-v1-workflow-spec.md b/docs/plans/deploy-v1-workflow-spec.md index e0dda01e..2b74bdd3 100644 --- a/docs/plans/deploy-v1-workflow-spec.md +++ b/docs/plans/deploy-v1-workflow-spec.md @@ -1,3 +1,5 @@ +> Naming update: deploy mode `dev` is now `local`; `dev` remains a deprecated alias for one minor release. This historical plan retains its original terminology. + # Ricky workflow spec — `workforce deploy` v1 cross-repo work **Status:** ready for Ricky to generate + run a workflow. diff --git a/docs/plans/deploy-v1.md b/docs/plans/deploy-v1.md index 3e5f6e22..dc94d74b 100644 --- a/docs/plans/deploy-v1.md +++ b/docs/plans/deploy-v1.md @@ -1,3 +1,5 @@ +> Naming update: deploy mode `dev` is now `local`; `dev` remains a deprecated alias for one minor release. This historical plan retains its original terminology. + # Plan — `workforce deploy` v1 Status: draft for review diff --git a/examples/linear-shipper/README.md b/examples/linear-shipper/README.md index 02fe5d45..9e98890b 100644 --- a/examples/linear-shipper/README.md +++ b/examples/linear-shipper/README.md @@ -7,7 +7,7 @@ This deployable persona follows the paraglide pattern: a Linear issue triggers a Connect Linear and GitHub before deploying. ```bash -workforce deploy ./examples/linear-shipper/persona.json --mode dev +workforce deploy ./examples/linear-shipper/persona.json --mode local ``` Set the target repository through the persona inputs: `GITHUB_OWNER`, `GITHUB_REPO`, and `REPO_URL`. diff --git a/examples/proactive-issue-resolver/README.md b/examples/proactive-issue-resolver/README.md index 57d76f73..8cafb49f 100644 --- a/examples/proactive-issue-resolver/README.md +++ b/examples/proactive-issue-resolver/README.md @@ -5,7 +5,7 @@ Local agent that turns a GitHub issue into a spec, hands the spec to the SDK to generate + run a workflow that opens a PR via `@agent-relay/github-primitive`, and DMs the result to Slack. -**Important runtime note:** workforce `--mode dev` does NOT subscribe to live +**Important runtime note:** workforce `--mode local` does NOT subscribe to live GitHub events. The runtime reads NDJSON envelopes from stdin (`packages/runtime/src/runner.ts:184`); live event ingress is a `--mode cloud` feature that isn't wired up yet. So v1 is **manually-triggered per-issue** via @@ -23,7 +23,7 @@ gates that go with it) lives in [`SPEC.md`](./SPEC.md). trigger-issue.sh owner repo N → gh api repos/owner/repo/issues/N (fetch real issue) → wrap as github.issues.opened envelope (NDJSON) - → pipe → agentworkforce deploy --mode dev (runner consumes one envelope) + → pipe → agentworkforce deploy --mode local (runner consumes one envelope) → handler claims issue (`gh issue edit --add-label ricky-claimed`) → handler comments :robot: on the issue → claude harness investigates repo + writes spec.md @@ -86,7 +86,7 @@ What happens: 1. `gh` fetches issue #123 and the repo metadata. 2. The script wraps both in a `github.issues.opened` envelope and pipes it to - `agentworkforce deploy ... --mode dev`. + `agentworkforce deploy ... --mode local`. 3. The handler adds the `ricky-claimed` label to issue #123 and acquires a deterministic Git ref lock before dispatch. 4. Handler comments `:robot: Proactive agent picked up #123. Investigating…`. @@ -101,7 +101,7 @@ What happens: REPO_ROOT=$(git rev-parse --show-toplevel) agentworkforce deploy \ "$REPO_ROOT/examples/proactive-issue-resolver/persona.json" \ - --mode dev --dry-run + --mode local --dry-run ``` Should print `ok: proactive-issue-resolver (dry-run)`. The persona is checked @@ -112,14 +112,14 @@ against this output today. - `agentworkforce` CLI v3.0.14 installed at `~/.local/share/mise/installs/node/22.22.1/bin/agentworkforce`. - `~/.agentworkforce/active.json` shows an active workspace. - `gh auth status` shows logged-in `khaliqgant` with `repo` scope. -- `agentworkforce deploy ... --mode dev --dry-run` returns `ok`. +- `agentworkforce deploy ... --mode local --dry-run` returns `ok`. - Handler signature matches `packages/runtime/src/types.ts:259`: `handler((ctx, event) => ...)`. - Envelope shape matches `RawGatewayEnvelope` in `packages/runtime/src/shim.ts:17`; type `github.issues.opened` splits to source=github, type=issues.opened, payload=resource. -- `--mode dev` pipes parent stdin to runner stdin - (`packages/deploy/src/modes/dev.ts:57`), so single-envelope stdin pipe → +- `--mode local` pipes parent stdin to runner stdin + (`packages/deploy/src/modes/local.ts:57`), so single-envelope stdin pipe → single dispatch → runner exits. - esbuild bundles `@agentworkforce/ricky` inline; `@agentworkforce/runtime` stays external (`packages/deploy/src/bundle.ts:62`). diff --git a/examples/proactive-issue-resolver/specs/cloud-mode-stub.md b/examples/proactive-issue-resolver/specs/cloud-mode-stub.md index 5deb753d..614d6fb3 100644 --- a/examples/proactive-issue-resolver/specs/cloud-mode-stub.md +++ b/examples/proactive-issue-resolver/specs/cloud-mode-stub.md @@ -55,7 +55,7 @@ The change must: `ricky.generateCloudWorkflow`, which returns the `runtime-not-wired` stub response; that surfaces as a `:x:` Slack hard-fail with the cloud error payload in the failure detail. No PR is opened. -- `agentworkforce deploy ... --mode dev --dry-run` still returns `ok`. +- `agentworkforce deploy ... --mode local --dry-run` still returns `ok`. ## Out of scope diff --git a/examples/proactive-issue-resolver/trigger-issue.sh b/examples/proactive-issue-resolver/trigger-issue.sh index 5ba819ad..b16cc482 100755 --- a/examples/proactive-issue-resolver/trigger-issue.sh +++ b/examples/proactive-issue-resolver/trigger-issue.sh @@ -2,7 +2,7 @@ # trigger-issue.sh — manually drive the proactive-issue-resolver persona # against a single, named GitHub issue. # -# `agentworkforce deploy --mode dev` reads NDJSON envelopes from stdin. This +# `agentworkforce deploy --mode local` reads NDJSON envelopes from stdin. This # script fetches a real issue via `gh`, wraps it in a `github.issues.opened` # envelope, and pipes it to one deploy invocation. The runner processes the # one envelope, then exits when stdin closes — so the script is short-lived, @@ -98,4 +98,4 @@ ENVELOPE=$(jq -n \ }') echo "→ piping envelope into agentworkforce deploy (cwd=$(pwd))" -printf '%s\n' "$ENVELOPE" | agentworkforce deploy "$PERSONA" --mode dev +printf '%s\n' "$ENVELOPE" | agentworkforce deploy "$PERSONA" --mode local diff --git a/examples/review-agent/README.md b/examples/review-agent/README.md index c98790d8..54035b08 100644 --- a/examples/review-agent/README.md +++ b/examples/review-agent/README.md @@ -9,7 +9,7 @@ Connect GitHub and Slack before deploying. Because `useSubscription` is enabled, ⚠️ **Memory is not wired.** `ctx.memory` is a stub in v1; see `docs/plans/deploy-v1-schema-cascade-spec.md` § Loud hole. Memory wiring lands in a follow-up workflow (not yet specced). ```bash -workforce deploy ./examples/review-agent/persona.json --mode dev +workforce deploy ./examples/review-agent/persona.json --mode local ``` ## Events diff --git a/examples/weekly-digest/README.md b/examples/weekly-digest/README.md index 62395bf7..e99d5e31 100644 --- a/examples/weekly-digest/README.md +++ b/examples/weekly-digest/README.md @@ -47,7 +47,7 @@ workforce deploy ./examples/weekly-digest/persona.json \ # Run locally as a long-lived process; pipe an envelope on stdin to fire # the handler immediately. The runner exits when stdin closes. -workforce deploy ./examples/weekly-digest/persona.json --mode dev +workforce deploy ./examples/weekly-digest/persona.json --mode local ``` ## Firing the handler manually diff --git a/impl/branch.txt b/impl/branch.txt new file mode 100644 index 00000000..236cd012 --- /dev/null +++ b/impl/branch.txt @@ -0,0 +1 @@ +feat/sandbox-session-1789241979891 diff --git a/impl/summary.md b/impl/summary.md new file mode 100644 index 00000000..dd6d0847 --- /dev/null +++ b/impl/summary.md @@ -0,0 +1 @@ +Add the workforce side of interactive persona sandbox sessions: a deterministic persona-to-sandbox parameter mapper, shared authentication and idempotent cleanup helpers, a socket-backed interactive launcher with detach/stop lifecycle handling, CLI sandbox flags and workspace dispatch, and a package-wide guard against invoking the agent-relay executable. Rename hosted deployment mode dev to local while retaining deprecated option/resolver/export aliases, update consumers and documentation, and add mapping, real UNIX-socket, CLI dispatch, and compatibility regression coverage. Production sandbox launch remains blocked on a published Relay SDK exposing the specified fleet/attach contracts and Cloud read-only mount enforcement; no shell fallback or fabricated dependency version is introduced. diff --git a/impl/upstream-blockers.md b/impl/upstream-blockers.md new file mode 100644 index 00000000..ddd91086 --- /dev/null +++ b/impl/upstream-blockers.md @@ -0,0 +1,47 @@ +# Release blockers + +The requested branch contains the workforce changes. It is not ready for the +SPEC's production definition-of-done or a ready-to-merge PR. + +1. The npm registry queried on 2026-09-12 reports both `@agent-relay/cloud` and + `@agent-relay/sdk` latest as `12.1.0`. Neither package exports `./fleet` or + `./attach`. The existing workforce dependency remains `^10.1.0`; there is + no verified release to bump to. `pnpm install` leaves the lockfile unchanged. + `sandbox-interactive.ts` has an explicit, lazy SDK dependency boundary so + local and hosted commands remain loadable; production interactive launch + throws an actionable error until the upstream exports are published. Its + local types describe the requested contract; they are not evidence of a + working published SDK. Replace this boundary with typed direct imports and + the verified release bump as part of the upstream integration gate. +2. The local Relay checkout's `FleetNodeAttachProxy` actually exposes + `brokerUrl`, `apiKey`, `requestTimeoutMs`, and `close()`. It does not expose + `socketPath` or `finished`. A thin re-export alone cannot implement the + SPEC: Relay must supply an actual terminal-to-UNIX-socket adapter, harness + execution/exit handling, and the fleet spawn wrapper before publication. +3. The plan assigns Relay SDK implementation, its tests, and the 1630 live + roundtrip to a separate Relay PR that must merge and publish first. Those + changes have not been made in this workforce checkout or published. +4. Cloud must accept and enforce `readonlyPaths` with chmod-444 semantics. + See `packages/persona-repo-router/skills/persona-relayfile-mount.md:66-72`. + Forwarding the paths in contract tests does not establish live enforcement. +5. The installed `flows check` rejects the supplied `wire-up.flow.ts` as + invalid YAML/JSON. That is a tool/flow format incompatibility outside this + checkout. The check was attempted; it did not pass. +6. The full repository test gate is not green: Node 22 fails existing runtime + version requirements; a supplemental Node 26 run also failed unchanged + runtime tests and was stopped. See `impl/validation.md`. +7. No live sandbox/read-only roundtrip, cloud service rollout, landing-page + update, remote CI run, or PR creation is claimed. The surrounding wire-up + flow assigns PR publication to a separate deterministic shipper step. + +Before rollout: publish and verify Relay's exports and request/response types, +bump both workforce consumers and the lockfile, verify real persona terminal +execution and auth modes, pass the opt-in smoke and Relay 1630 live test, +resolve the flow checker format, and obtain green CI. + +Implementation clarification: the existing sandbox launcher executes a Node +handler, and `PersonaSpec` explicitly permits such handlers to omit `harness` +or set `sandbox: false` for their internal runtime. Applying interactive +eligibility validation to them would be a regression. A shared pure +`personaToSandboxContext` supplies the same identity/env derivation to those +handlers; interactive launches still reject all three specified invalid shapes. diff --git a/impl/validation.md b/impl/validation.md new file mode 100644 index 00000000..24cb9509 --- /dev/null +++ b/impl/validation.md @@ -0,0 +1,42 @@ +# Validation + +- `pnpm install`: passed; lockfile unchanged because no published SDK release + satisfies the required exports. +- `pnpm -r build`: passed. +- `pnpm run typecheck`: passed, including examples. +- `pnpm run lint`: passed. +- Persona mapper focused suite: 11 passed. +- Complete deploy suite after final fixes: 292 passed, 1 skipped (opt-in live + Daytona smoke). Includes real UNIX-socket byte piping, attach timing, + stop/detach, cleanup errors, and unchanged Node handler mint payload. +- Complete local-surface suite: 14 passed. +- Focused CLI/parser/dispatch/runtime-picker suites: 127 passed. +- The complete CLI suite under Node 22 also failed 30 existing invocation/ + permission tests plus the existing config-directory whitespace test; the Node + 26 root rerun stopped earlier in runtime, so it did not reach the full CLI suite. +- Root static tests include a package-wide no-agent-relay-shell scan and a + non-vacuous matcher test. They pass. +- Initial `pnpm run test` under default Node 22.22.2 failed in 14 existing + runtime local-preview tests, which require Node >=26.3.1. A supplemental + rerun used the already-installed Node 26.8.2 with a command-local PATH + override. Its release-workflow checks passed, but unchanged local-preview + and broker-log tests also failed; the slower rerun was stopped after those + failures were confirmed. The repository test gate is NOT green. The root + agent-card E2E stage was not reached. +- Installed Relay `/fleet` import check: fails with + `ERR_PACKAGE_PATH_NOT_EXPORTED`, consistent with registry metadata for + latest `@agent-relay/cloud@12.1.0` and `@agent-relay/sdk@12.1.0` lacking both + required subpaths. +- `flows check ../flows/examples/agentworkforce-sandbox-session/wire-up.flow.ts`: + failed with `REFUSED [invalid_spec] ... contains invalid YAML or JSON`. +- Veto diff review: `warn`; host-supplied specialist review identified the + missing published SDK as a high-severity release dependency and unverified + Cloud read-only enforcement as a medium-severity gate. No live secrets found. + MCP sampling was unavailable; this was a host review submitted through Veto, + not an independent agent review. No subagents were spawned. +- Final deploy typecheck includes the updated live-smoke credential gate. +- `git diff --check` and staged diff whitespace check: passed. + +Live sandbox execution and Relay's 1630 readonly roundtrip have not run. No +production rollout, remote CI success, or PR creation is claimed. See +`impl/upstream-blockers.md` for the concrete remaining cross-repo requirements. diff --git a/packages/cli/README.md b/packages/cli/README.md index b92e688d..dc723b8b 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1244,3 +1244,23 @@ If a persona uses MCP, use `claude` or `codex` tiers. - **Local file silently missing from the list** — Scroll up for a `warning: [layer] file.json: …` line. Common causes: invalid JSON, `id` missing, or `extends` pointing at something that isn't in a lower layer. + +### Interactive sandbox sessions + +`agentworkforce agent --mode local` is the default interactive launch. +`agentworkforce agent --mode sandbox` selects a Cloud sandbox session. +Use `--sandbox-provider daytona|e2b`, `--sandbox-id ` to replay an identity, +`--attach-mode view|drive` (default `drive`), and `--byo-sandbox` for BYO auth. +Run `agentworkforce login` first to select an active workspace. Ctrl-C stops the +session and waits for sandbox cleanup. `agent --mode cloud` is invalid; use +`agentworkforce deploy --mode cloud` for a hosted service. + +Interactive sandbox launch requires the Relay SDK `/fleet` and `/attach` +contracts. They are not yet exported by the published Relay SDK checked during +this implementation; until that dependency ships, the command reports the +missing SDK contract. Read-only mount enforcement also requires Cloud support. + +For hosted services, use `deploy --mode local|sandbox|cloud`. The previous +`deploy --mode dev` spelling still works and warns; it will be removed in the +next minor release. Existing `devLauncher` and `resolvers.modes.dev` library +callers have the same deprecation window. diff --git a/packages/cli/src/agent-sandbox.test.ts b/packages/cli/src/agent-sandbox.test.ts new file mode 100644 index 00000000..2280e7ca --- /dev/null +++ b/packages/cli/src/agent-sandbox.test.ts @@ -0,0 +1,66 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; +import type { InteractiveSandboxInput } from '@agentworkforce/deploy'; +import { parseAgentArgs, runAgentSandbox } from './cli-impl.js'; + +const persona: InteractiveSandboxInput['persona'] = { id: 'demo', intent: 'documentation', description: '', tags: [], skills: [], harness: 'claude', harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 } }; +function fixture() { + const exits: number[] = []; + const processLike = Object.assign(new EventEmitter(), { + stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough(), exit: (code: number) => { exits.push(code); }, + }); + let stops = 0; + const calls: InteractiveSandboxInput[] = []; + const deps = { + processLike, + resolveWorkspace: async (): Promise => 'workspace', + launchInteractiveSandbox: async (input: InteractiveSandboxInput) => { + calls.push(input); + return { sandboxId: 'sb', nodeId: 'node', attached: Promise.resolve(), finished: Promise.resolve(3), detach: async () => {}, stop: async () => { stops++; } }; + }, + }; + return { deps, calls, exits, stops: () => stops }; +} +for (const byo of [false, true]) { + test(`agent sandbox dispatch forwards stdio and options (${byo ? 'BYO' : 'managed'})`, async () => { + const f = fixture(); + const { flags } = parseAgentArgs(['--mode=sandbox', '--sandbox-provider=e2b', '--sandbox-id=sb', '--attach-mode=view', ...(byo ? ['--byo-sandbox'] : [])]); + await runAgentSandbox(persona, flags, f.deps); + assert.equal(f.calls.length, 1); + const call = f.calls[0]; + assert.equal(call.persona, persona); assert.equal(call.workspace, 'workspace'); + assert.equal(call.authMode, byo ? 'byo' : 'managed'); assert.equal(call.provider, 'e2b'); + assert.equal(call.sandboxId, 'sb'); assert.equal(call.attachMode, 'view'); + for (const stream of ['stdin', 'stdout', 'stderr'] as const) assert.equal(call.stdio[stream], f.deps.processLike[stream]); + assert.deepEqual(f.exits, [3]); assert.equal(f.stops(), 1); + assert.equal(f.deps.processLike.listenerCount('SIGINT'), 0); + }); +} +test('agent local dispatch never launches a sandbox or resolves workspace', async () => { + const f = fixture(); + f.deps.resolveWorkspace = async () => { throw new Error('must not resolve'); }; + await runAgentSandbox(persona, parseAgentArgs([]).flags, f.deps); + assert.equal(f.calls.length, 0); +}); +test('agent sandbox requires active workspace with login hint', async () => { + const f = fixture(); f.deps.resolveWorkspace = async () => undefined; + await assert.rejects(runAgentSandbox(persona, parseAgentArgs(['--mode=sandbox']).flags, f.deps), /agentworkforce login/); + assert.equal(f.calls.length, 0); +}); +test('agent sandbox signal stops once and awaits cleanup even if finished settles first', async () => { + const f = fixture(); + let finish!: (code: number) => void; + const finished = new Promise(resolve => { finish = resolve; }); + let deleted = false; let stopCalls = 0; + f.deps.launchInteractiveSandbox = async () => { + setImmediate(() => { f.deps.processLike.emit('SIGINT'); f.deps.processLike.emit('SIGTERM'); }); + return { sandboxId: 'sb', nodeId: 'n', attached: Promise.resolve(), finished, detach: async () => {}, stop: async () => { + stopCalls++; finish(3); await new Promise(resolve => setImmediate(resolve)); deleted = true; + } }; + }; + f.deps.processLike.exit = code => { assert.equal(deleted, true); f.exits.push(code); }; + await runAgentSandbox(persona, parseAgentArgs(['--mode=sandbox']).flags, f.deps); + assert.equal(stopCalls, 1); assert.equal(f.exits.length, 1); +}); diff --git a/packages/cli/src/cli-impl.ts b/packages/cli/src/cli-impl.ts index e90f8ae2..fdc5d23e 100644 --- a/packages/cli/src/cli-impl.ts +++ b/packages/cli/src/cli-impl.ts @@ -165,6 +165,14 @@ Commands: Run a persona. Drops into an interactive harness session. Flags: + --mode local|sandbox + Local interactive session (default), + or a Cloud sandbox session. + --byo-sandbox Force BYO sandbox authentication. + --sandbox-provider

+ daytona|e2b (Cloud picks by default). + --sandbox-id Replay a prior sandbox identity. + --attach-mode view|drive (default: drive). --install-in-repo Disengage the sandbox mount and install skills into the repo's harness-conventional directory @@ -313,7 +321,7 @@ Commands: Deploy a persona as a managed agent. may be prebuilt persona.json or authored persona.ts/js. Modes: - --mode dev run the persona locally (default if + --mode local run the persona locally (default if no Daytona/workspace creds resolve) --mode sandbox run inside a Daytona sandbox (default when creds resolve) @@ -336,7 +344,7 @@ Commands: Run a persona on this machine, triggered by real provider webhooks routed through the fleet/relaycast infrastructure — no public IP, tunnel, or manual token - wiring. Runs in \`--mode dev\`; local credential + wiring. Runs in \`--mode local\`; local credential mirroring is not supported, so this targets cron/timer- only or webhook-shape-only personas. See \`agentworkforce local-surface --help\` for flags. @@ -3543,6 +3551,17 @@ async function runAgentSelector( perfMark('runAgentSelector: start'); const target = parseSelector(selector); perfMark('runAgentSelector: persona resolved'); + if (flags.mode === 'sandbox') { + if (flags.dryRun) { + const { personaToSandboxParams } = await import('@agentworkforce/persona-kit'); + const params = personaToSandboxParams(target.spec, { workspace: '(dry-run)', inputs: inputValues }); + process.stdout.write(JSON.stringify({ mode: 'sandbox', persona: target.spec.id, cli: params.cli, relayfilePaths: params.relayfilePaths, readonlyPaths: params.readonlyPaths }, null, 2) + '\n'); + process.exit(0); + } + await runAgentSandbox(target.spec, flags); + process.exit(0); + } + const selection = { ...buildSelection(target.spec, target.kind), ...(inputValues ? { inputValues } : {}) @@ -3848,6 +3867,7 @@ export async function resumeFastSession(fast: FastLaunch): Promise { target, capture, flags: { + mode: 'local', byoSandbox: false, attachMode: 'drive', installInRepo: false, noLaunchMetadata: false, dryRun: false, @@ -4892,6 +4912,9 @@ async function runInteractivePicker(): Promise { process.exit(130); } await runAgentSelector(selected, { + mode: 'local', + byoSandbox: false, + attachMode: 'drive', installInRepo: false, noLaunchMetadata: false, dryRun: false, @@ -5046,6 +5069,7 @@ async function runPick(args: readonly string[]): Promise { await runAgentSelector( CREATE_SELECTOR, { + mode: 'local', byoSandbox: false, attachMode: 'drive', installInRepo: false, noLaunchMetadata: false, dryRun: false, @@ -5253,6 +5277,11 @@ export async function main(): Promise { } export interface AgentFlags { + mode: 'local' | 'sandbox'; + byoSandbox: boolean; + sandboxProvider?: 'daytona' | 'e2b'; + sandboxId?: string; + attachMode: 'view' | 'drive'; installInRepo: boolean; noLaunchMetadata: boolean; dryRun: boolean; @@ -5276,6 +5305,9 @@ export function parseAgentArgs(args: readonly string[]): { positional: string[]; } { const flags: AgentFlags = { + mode: 'local', + byoSandbox: false, + attachMode: 'drive', installInRepo: false, noLaunchMetadata: false, dryRun: false, @@ -5286,7 +5318,9 @@ export function parseAgentArgs(args: readonly string[]): { }; const positional: string[] = []; let seenDoubleDash = false; - for (const arg of args) { + let sandboxFlagsUsed = false; + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]; if (seenDoubleDash) { positional.push(arg); continue; @@ -5295,6 +5329,34 @@ export function parseAgentArgs(args: readonly string[]): { seenDoubleDash = true; continue; } + if (arg === '--byo-sandbox') { + flags.byoSandbox = true; + continue; + } + const name = arg.split('=', 1)[0]; + if (['--mode', '--sandbox-provider', '--sandbox-id', '--attach-mode'].includes(name)) { + const value = arg.includes('=') ? arg.slice(name.length + 1) : args[++i]; + if (!value || value.startsWith('--')) die(`${name}: requires a value.`, false); + switch (name) { + case '--mode': + if (value !== 'local' && value !== 'sandbox') { + die(`--mode: expected one of local|sandbox; got "${value}"${value === 'cloud' ? '. Use deploy --mode cloud for a hosted service.' : ''}`, false); + } + flags.mode = value; + break; + case '--sandbox-provider': + if (value !== 'daytona' && value !== 'e2b') die(`--sandbox-provider: expected one of daytona|e2b; got "${value}"`, false); + flags.sandboxProvider = value; + break; + case '--sandbox-id': flags.sandboxId = value; break; + case '--attach-mode': + if (value !== 'view' && value !== 'drive') die(`--attach-mode: expected one of view|drive; got "${value}"`, false); + flags.attachMode = value; + break; + } + sandboxFlagsUsed ||= name !== '--mode'; + continue; + } if (arg === '--install-in-repo') { flags.installInRepo = true; continue; @@ -5329,6 +5391,9 @@ export function parseAgentArgs(args: readonly string[]): { } positional.push(arg); } + if (flags.mode === 'local' && (sandboxFlagsUsed || flags.byoSandbox)) { + die('Sandbox flags require --mode sandbox.', false); + } return { flags, positional }; } @@ -5338,6 +5403,9 @@ export function parseCreateArgs(args: readonly string[]): { inputValues: Record; } { const flags: CreateFlags = { + mode: 'local', + byoSandbox: false, + attachMode: 'drive', installInRepo: false, noLaunchMetadata: false, dryRun: false, @@ -5367,6 +5435,9 @@ export function parseCreateArgs(args: readonly string[]): { seenDoubleDash = true; continue; } + if (arg === '--mode' || arg.startsWith('--mode=') || arg === '--byo-sandbox' || arg.startsWith('--sandbox-') || arg.startsWith('--attach-mode')) { + die('create: sandbox flags are only supported by agent --mode sandbox.', false); + } if (arg === '--install-in-repo') { flags.installInRepo = true; continue; @@ -5421,3 +5492,64 @@ export function parseCreateArgs(args: readonly string[]): { // The CLI entry (self-run detection + fast-path dispatch) lives in cli.ts; // this module is imported by it and never self-runs. + + +interface AgentSandboxProcess { + stdin: NodeJS.ReadableStream; + stdout: NodeJS.WritableStream; + stderr: NodeJS.WritableStream; + once(signal: 'SIGINT' | 'SIGTERM', listener: () => void): unknown; + removeListener(signal: 'SIGINT' | 'SIGTERM', listener: () => void): unknown; + exit(code: number): void; +} +interface AgentSandboxDependencies { + launchInteractiveSandbox?: typeof import('@agentworkforce/deploy').launchInteractiveSandbox; + processLike?: AgentSandboxProcess; + resolveWorkspace?: () => Promise; +} + +export async function runAgentSandbox( + persona: PersonaSpec, + flags: AgentFlags, + deps: AgentSandboxDependencies = {}, +): Promise { + if (flags.mode !== 'sandbox') return; + const processLike = deps.processLike ?? process; + const deploy = await import('@agentworkforce/deploy'); + let workspace: string | undefined; + try { + workspace = deps.resolveWorkspace ? await deps.resolveWorkspace() : (await deploy.resolveWorkspaceToken({ + cloudUrl: deploy.resolveCloudUrl(), io: deploy.createTerminalIO(), noPrompt: true, + })).workspace; + } catch (err) { + throw new Error(`agent --mode sandbox requires an active workspace. Run agentworkforce login. ${err instanceof Error ? err.message : String(err)}`, { cause: err }); + } + if (!workspace?.trim()) throw new Error('agent --mode sandbox requires an active workspace. Run agentworkforce login.'); + const handle = await (deps.launchInteractiveSandbox ?? deploy.launchInteractiveSandbox)({ + persona, + workspace, + authMode: flags.byoSandbox ? 'byo' : 'managed', + provider: flags.sandboxProvider, + sandboxId: flags.sandboxId, + attachMode: flags.attachMode, + stdio: { stdin: processLike.stdin, stdout: processLike.stdout, stderr: processLike.stderr }, + }); + let stopping: Promise | undefined; + const stop = () => stopping ??= handle.stop(); + let signalExit!: (code: number) => void; + const interrupted = new Promise(resolve => { signalExit = resolve; }); + const onInterrupt = () => { void stop().then(() => signalExit(130)); }; + const onTerminate = () => { void stop().then(() => signalExit(143)); }; + processLike.once('SIGINT', onInterrupt); + processLike.once('SIGTERM', onTerminate); + let code: number; + try { + code = await Promise.race([handle.finished, interrupted]); + } finally { + // Await deletion before process.exit: proxy close may settle finished first. + await stop(); + processLike.removeListener('SIGINT', onInterrupt); + processLike.removeListener('SIGTERM', onTerminate); + } + processLike.exit(code); +} diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts index b4f43fb7..0837c09f 100644 --- a/packages/cli/src/cli.test.ts +++ b/packages/cli/src/cli.test.ts @@ -893,7 +893,7 @@ export default defineAgent({ try { const { stderr, stdout, exitCode } = await runCliCapturingStderr( - ['deploy', personaPath, '--mode', 'dev', '--dry-run'], + ['deploy', personaPath, '--mode', 'local', '--dry-run'], { AGENT_WORKFORCE_HOME: workforceHome } ); assert.equal(exitCode, 0); @@ -1813,3 +1813,35 @@ test('acquireSkillCacheLock: steals a lock held by a dead pid', async () => { rmSync(dir, { recursive: true, force: true }); } }); + +test('agent sandbox parser defaults and full argument forwarding', () => { + const defaults = parseAgentArgs(['p']).flags; + assert.equal(defaults.mode, 'local'); assert.equal(defaults.attachMode, 'drive'); + assert.equal(defaults.byoSandbox, false); + const { flags, positional } = parseAgentArgs(['--mode', 'sandbox', '--sandbox-provider', 'e2b', '--sandbox-id', 'sb_1', '--attach-mode', 'view', '--byo-sandbox', 'p']); + assert.equal(flags.mode, 'sandbox'); assert.equal(flags.sandboxProvider, 'e2b'); + assert.equal(flags.sandboxId, 'sb_1'); assert.equal(flags.attachMode, 'view'); assert.equal(flags.byoSandbox, true); + assert.deepEqual(positional, ['p']); + assert.equal(parseAgentArgs(['--mode=sandbox', 'p']).flags.mode, 'sandbox'); +}); +for (const [args, expected] of [ + [['--mode', 'cloud'], /local\|sandbox.*deploy --mode cloud/], + [['--sandbox-provider', 'foo'], /daytona\|e2b/], + [['--attach-mode', 'foo'], /view\|drive/], + [['--sandbox-id', 'x'], /require --mode sandbox/], + [['--attach-mode', 'drive'], /require --mode sandbox/], + [['--byo-sandbox'], /require --mode sandbox/], + [['--mode'], /requires a value/], + [['--sandbox-id='], /requires a value/], +] as [string[], RegExp][]) { + test(`agent sandbox parser rejects ${args.join(' ')}`, () => { + const trap = trapExit(); + try { assert.throws(() => parseAgentArgs(args), /__exit_trap__/); assert.match(trap.stderr, expected); } + finally { trap.restore(); } + }); +} +test('create rejects sandbox mode before creating a target directory', () => { + const trap = trapExit(); + try { assert.throws(() => parseCreateArgs(['--mode=sandbox']), /__exit_trap__/); assert.match(trap.stderr, /only supported by agent/); } + finally { trap.restore(); } +}); diff --git a/packages/cli/src/deploy-command.test.ts b/packages/cli/src/deploy-command.test.ts index f81d9f30..820e557e 100644 --- a/packages/cli/src/deploy-command.test.ts +++ b/packages/cli/src/deploy-command.test.ts @@ -510,3 +510,15 @@ test('runLogin canonicalizes origin.agentrelay.cloud apiUrl before resolving the restoreDeps(); } }); + +test('deploy mode local and deprecated dev alias', () => { + assert.equal(parseDeployArgs(['p.json', '--mode', 'local']).mode, 'local'); + assert.equal(parseDeployArgs(['p.json', '--mode=local']).mode, 'local'); + const trap = trapExit(); + try { + assert.equal(parseDeployArgs(['p.json', '--mode', 'dev']).mode, 'local'); + assert.match(trap.stderr, /--mode dev is deprecated; use --mode local/); + assert.throws(() => parseDeployArgs(['p.json', '--mode', 'foo']), /__exit_trap__/); + assert.match(trap.stderr, /local\|sandbox\|cloud/); + } finally { trap.restore(); } +}); diff --git a/packages/cli/src/deploy-command.ts b/packages/cli/src/deploy-command.ts index 06bb60af..02ef5e4d 100644 --- a/packages/cli/src/deploy-command.ts +++ b/packages/cli/src/deploy-command.ts @@ -256,7 +256,7 @@ export async function runLogout(args: readonly string[]): Promise { const DEPLOY_USAGE = `usage: agentworkforce deploy [flags] Flags: - --mode dev|sandbox|cloud Pick a run mode (prompts in an interactive terminal) + --mode local|sandbox|cloud Pick a run mode (prompts in an interactive terminal) --workspace Workforce workspace; defaults to the active workspace --no-connect Skip integration-connect prompts; fail if any are missing --reconnect Force a fresh connect flow even if already connected, @@ -328,12 +328,15 @@ export function parseDeployArgs(args: readonly string[]): DeployOptions { if (a === '-h' || a === '--help') { process.stdout.write(DEPLOY_USAGE); process.exit(0); - } else if (a === '--mode') { - const v = args[++i]; - if (v !== 'dev' && v !== 'sandbox' && v !== 'cloud') { - die(`--mode: expected one of dev|sandbox|cloud; got "${v ?? ''}"`); + } else if (a === '--mode' || a.startsWith('--mode=')) { + const v = a === '--mode' ? args[++i] : a.slice('--mode='.length); + if (v !== 'local' && v !== 'dev' && v !== 'sandbox' && v !== 'cloud') { + die(`--mode: expected one of local|sandbox|cloud; got "${v ?? ''}"`); } - mode = v; + if (v === 'dev') { + process.stderr.write('agentworkforce: --mode dev is deprecated; use --mode local (alias removed in the next minor)\n'); + } + mode = v === 'dev' ? 'local' : v; } else if (a === '--workspace') { workspace = expectValue('--workspace', args[++i]); } else if (a === '--no-connect') { diff --git a/packages/cli/src/local-surface-command.ts b/packages/cli/src/local-surface-command.ts index 1007c0b4..ead942bc 100644 --- a/packages/cli/src/local-surface-command.ts +++ b/packages/cli/src/local-surface-command.ts @@ -22,7 +22,7 @@ export const LOCAL_SURFACE_USAGE = `usage: agentworkforce local-surface { assert.equal(await pickRuntime({ input: input('1\n'), output: output() }), 'cloud'); assert.equal(await pickRuntime({ input: input('2\n'), output: output() }), 'sandbox'); - assert.equal(await pickRuntime({ input: input('3\n'), output: output() }), 'dev'); + assert.equal(await pickRuntime({ input: input('3\n'), output: output() }), 'local'); }); test('pickRuntime defaults to cloud and returns docs for build-your-own', async () => { diff --git a/packages/cli/src/runtime-picker.ts b/packages/cli/src/runtime-picker.ts index 9e725d46..294d7d06 100644 --- a/packages/cli/src/runtime-picker.ts +++ b/packages/cli/src/runtime-picker.ts @@ -17,7 +17,7 @@ export async function pickRuntime(opts: { 'Which runtime should this persona run on?', ' [1] AgentRelay (recommended) - managed cloud, schedules + integrations + memory wired', ' [2] Local sandbox - runs in a local Daytona container', - ' [3] Local dev - runs directly on your machine', + ' [3] Local - runs directly on your machine', ' [4] Build your own - docs at https://agentrelay.com/docs/runtimes', '' ].join('\n') @@ -32,7 +32,7 @@ export async function pickRuntime(opts: { case '2': return 'sandbox'; case '3': - return 'dev'; + return 'local'; case '4': return 'docs'; default: diff --git a/packages/deploy/README.md b/packages/deploy/README.md new file mode 100644 index 00000000..65e024a1 --- /dev/null +++ b/packages/deploy/README.md @@ -0,0 +1,27 @@ +# @agentworkforce/deploy + +Deploy event-driven personas with `deploy({ personaPath, mode })`. Modes are +`local`, `sandbox`, and `cloud`. `localLauncher` runs the bundle on the local +machine. `devLauncher`, `mode: 'dev'`, and `resolvers.modes.dev` remain deprecated +aliases for one minor release; `deploy()` warns when legacy options are used. + +`launchInteractiveSandbox({ persona, workspace, stdio, inputs?, authMode?, +provider?, sandboxId?, attachMode?, task? })` composes Relay's fleet and attach +SDK primitives. `authMode` defaults to `managed`, and `attachMode` to `drive`. +`attached` resolves after the UNIX socket connects. `finished` carries the +harness exit code. `detach()` closes the local connection while leaving the +sandbox running; idempotent `stop()` also deletes the sandbox. Stdio streams +belong to the caller and stdout is never ended by the launcher. + +The persona mapper in `@agentworkforce/persona-kit` converts explicit subtree +allow-lists into Relayfile paths, copies read-only paths, and rejects personas +without a supported harness or with `sandbox: false`. Unscoped mounts omit +`relayfilePaths` from the SDK request because ensure rejects an empty array. + +Release dependency: published `@agent-relay/cloud@12.1.0` does not yet export +`/fleet` or `/attach`. This implementation loads those SDK exports on demand +and fails explicitly until the upstream contract ships. No CLI fallback is +provided. Before enabling production use, bump and verify the published SDK, +run the opt-in live smoke, and verify Cloud materializes read-only files with +chmod-444 semantics (see `persona-repo-router/skills/persona-relayfile-mount.md`). +The read-only payload alone does not prove enforcement. diff --git a/packages/deploy/src/deploy.test.ts b/packages/deploy/src/deploy.test.ts index 6267eff1..67120beb 100644 --- a/packages/deploy/src/deploy.test.ts +++ b/packages/deploy/src/deploy.test.ts @@ -511,7 +511,7 @@ test('deploy --dry-run rejects useSubscription when cloud mode is not selected', const io = createBufferedIO(); try { await assert.rejects( - deploy({ personaPath, mode: 'dev', dryRun: true, io }), + deploy({ personaPath, mode: 'local', dryRun: true, io }), /requires --mode cloud/ ); assert.ok(!io.messages.find((m) => m.message.startsWith('workspace:'))); @@ -685,7 +685,7 @@ test('deploy fails clearly when integration is not connected and --no-connect is try { await assert.rejects( deploy( - { personaPath, mode: 'dev', noConnect: true, io }, + { personaPath, mode: 'local', noConnect: true, io }, { workspaceAuth, integrations } ), /failed to connect/ @@ -726,12 +726,12 @@ test('deploy connects each missing persona integration before launch', async () try { const result = await deploy( - { personaPath, mode: 'dev', io }, + { personaPath, mode: 'local', io }, { workspaceAuth, integrations, bundle: successfulBundleStager(), - modes: { dev: successfulDevLauncher(() => { launched = true; }) } + modes: { local: successfulDevLauncher(() => { launched = true; }) } } ); @@ -769,7 +769,7 @@ test('deploy forwards the Supabase project ref into the OAuth connect flow', asy await deploy( { personaPath, - mode: 'dev', + mode: 'local', io, supabaseMcpProjectRef: 'BVZZCAFZOYSEZUMRDVIF' }, @@ -777,7 +777,7 @@ test('deploy forwards the Supabase project ref into the OAuth connect flow', asy workspaceAuth, integrations, bundle: successfulBundleStager(), - modes: { dev: successfulDevLauncher() } + modes: { local: successfulDevLauncher() } } ); @@ -902,7 +902,7 @@ test('deploy activates optional integrations from supplied persona inputs', asyn return successfulBundleStager().stage(input); } }; - const devLauncher: ModeLauncher = { + const localLauncher: ModeLauncher = { async launch(input: ModeLaunchInput) { launchedTriggerKeys = Object.keys(input.agent.triggers ?? {}); return { @@ -920,7 +920,7 @@ test('deploy activates optional integrations from supplied persona inputs', asyn const result = await deploy( { personaPath, - mode: 'dev', + mode: 'local', io, inputs: { SLACK_CHANNEL: 'C1' } }, @@ -928,7 +928,7 @@ test('deploy activates optional integrations from supplied persona inputs', asyn workspaceAuth, integrations, bundle, - modes: { dev: devLauncher } + modes: { local: localLauncher } } ); @@ -1003,7 +1003,7 @@ test('deploy collects picker-backed input before pruning an optional integration return successfulBundleStager().stage(input); } }; - const devLauncher: ModeLauncher = { + const localLauncher: ModeLauncher = { async launch(input: ModeLaunchInput) { launchedTriggerKeys = Object.keys(input.agent.triggers ?? {}); launchedInputs = input.inputs; @@ -1021,13 +1021,13 @@ test('deploy collects picker-backed input before pruning an optional integration try { await withProcessEnv({ SLACK_CHANNEL: undefined, TELEGRAM_CHAT: undefined }, async () => { const result = await deploy( - { personaPath, mode: 'dev', io }, + { personaPath, mode: 'local', io }, { workspaceAuth, integrations, integrationOptions, bundle, - modes: { dev: devLauncher } + modes: { local: localLauncher } } ); @@ -1145,7 +1145,7 @@ test('deploy refuses an agent whose optional integration inputs leave no active try { await withProcessEnv({ SLACK_CHANNEL: undefined, TELEGRAM_CHAT: undefined }, async () => assert.rejects( - deploy({ personaPath, mode: 'dev', io: createBufferedIO() }), + deploy({ personaPath, mode: 'local', io: createBufferedIO() }), /no active listeners after optional integrations were applied/ ) ); @@ -1154,7 +1154,7 @@ test('deploy refuses an agent whose optional integration inputs leave no active } }); -test('deploy dev mode injects runtime credentials for a detected writeback trigger without provider-token leakage', async () => { +test('deploy local mode injects runtime credentials for a detected writeback trigger without provider-token leakage', async () => { const providerTokenSentinel = 'WORKFORCE_PROVIDER_TOKEN_SHOULD_NOT_LEAK'; const integrations = { github: {} @@ -1208,7 +1208,7 @@ test('deploy dev mode injects runtime credentials for a detected writeback trigg const result = await deploy( { personaPath, - mode: 'dev', + mode: 'local', noPrompt: true, cloudUrl: 'https://cloud.example.test', io @@ -1221,7 +1221,7 @@ test('deploy dev mode injects runtime credentials for a detected writeback trigg }, bundle: successfulBundleStager(), modes: { - dev: { + local: { async launch(input) { launched = true; launchedEnv = input.env; @@ -1262,7 +1262,7 @@ test('deploy dev mode injects runtime credentials for a detected writeback trigg } }); -test('deploy dev mode preserves no-trigger null-token runtime credentials', async () => { +test('deploy local mode preserves no-trigger null-token runtime credentials', async () => { const { personaPath, cleanup } = await withTempPersona( basePersonaJson({ integrations: { github: {} } }) ); @@ -1302,7 +1302,7 @@ test('deploy dev mode preserves no-trigger null-token runtime credentials', asyn await deploy( { personaPath, - mode: 'dev', + mode: 'local', noPrompt: true, cloudUrl: 'https://cloud.example.test', io @@ -1315,7 +1315,7 @@ test('deploy dev mode preserves no-trigger null-token runtime credentials', asyn }, bundle: successfulBundleStager(), modes: { - dev: { + local: { async launch(input) { launchedEnv = input.env; return { @@ -1341,7 +1341,7 @@ test('deploy dev mode preserves no-trigger null-token runtime credentials', asyn } }); -test('deploy dev mode preserves env-only provider token fallback without runtime credential masking', async () => { +test('deploy local mode preserves env-only provider token fallback without runtime credential masking', async () => { const providerTokenSentinel = 'WORKFORCE_ENV_ONLY_PROVIDER_TOKEN'; const { personaPath, cleanup } = await withTempPersona( basePersonaJson({ @@ -1372,7 +1372,7 @@ test('deploy dev mode preserves env-only provider token fallback without runtime await deploy( { personaPath, - mode: 'dev', + mode: 'local', noPrompt: true, cloudUrl: 'https://cloud.example.test', io @@ -1385,7 +1385,7 @@ test('deploy dev mode preserves env-only provider token fallback without runtime }, bundle: successfulBundleStager(), modes: { - dev: { + local: { async launch(input) { launched = true; launchedEnv = input.env; @@ -1418,7 +1418,7 @@ test('deploy dev mode preserves env-only provider token fallback without runtime } }); -test('deploy dev mode runtime credential eligibility preserves legacy workspace fallback semantics', async () => { +test('deploy local mode runtime credential eligibility preserves legacy workspace fallback semantics', async () => { const { personaPath, cleanup } = await withTempPersona( basePersonaJson({ integrations: { @@ -1456,7 +1456,7 @@ test('deploy dev mode runtime credential eligibility preserves legacy workspace await deploy( { personaPath, - mode: 'dev', + mode: 'local', noPrompt: true, cloudUrl: 'https://cloud.example.test', io @@ -1469,7 +1469,7 @@ test('deploy dev mode runtime credential eligibility preserves legacy workspace }, bundle: successfulBundleStager(), modes: { - dev: { + local: { async launch(input) { launchedEnv = input.env; return { @@ -1502,7 +1502,7 @@ test('deploy dev mode runtime credential eligibility preserves legacy workspace } }); -test('deploy dev mode runtime credential eligibility preserves expected provider config key semantics', async () => { +test('deploy local mode runtime credential eligibility preserves expected provider config key semantics', async () => { const providerTokenSentinel = 'WORKFORCE_ENV_CONFIG_MISMATCH_TOKEN'; const { personaPath, cleanup } = await withTempPersona( basePersonaJson({ @@ -1536,7 +1536,7 @@ test('deploy dev mode runtime credential eligibility preserves expected provider await deploy( { personaPath, - mode: 'dev', + mode: 'local', noPrompt: true, cloudUrl: 'https://cloud.example.test', io @@ -1555,7 +1555,7 @@ test('deploy dev mode runtime credential eligibility preserves expected provider }, bundle: successfulBundleStager(), modes: { - dev: { + local: { async launch(input) { launchedEnv = input.env; launchedProcessProviderToken = process.env.WORKFORCE_INTEGRATION_GITHUB_TOKEN; @@ -1586,7 +1586,7 @@ test('deploy dev mode runtime credential eligibility preserves expected provider } }); -test('deploy dev mode ignores catalog config keys for CLI-captured daytona runtime credentials', async () => { +test('deploy local mode ignores catalog config keys for CLI-captured daytona runtime credentials', async () => { const { personaPath, cleanup } = await withTempPersona( basePersonaJson({ integrations: { @@ -1639,7 +1639,7 @@ test('deploy dev mode ignores catalog config keys for CLI-captured daytona runti await deploy( { personaPath, - mode: 'dev', + mode: 'local', noPrompt: true, cloudUrl: 'https://cloud.example.test', io @@ -1659,7 +1659,7 @@ test('deploy dev mode ignores catalog config keys for CLI-captured daytona runti }, bundle: successfulBundleStager(), modes: { - dev: { + local: { async launch(input) { launchedEnv = input.env; return { @@ -1691,7 +1691,7 @@ test('deploy dev mode ignores catalog config keys for CLI-captured daytona runti } }); -test('deploy dev mode rejects malformed runtime credential tokens before launch', async () => { +test('deploy local mode rejects malformed runtime credential tokens before launch', async () => { const { personaPath, cleanup } = await withTempPersona( basePersonaJson({ integrations: { @@ -1724,7 +1724,7 @@ test('deploy dev mode rejects malformed runtime credential tokens before launch' deploy( { personaPath, - mode: 'dev', + mode: 'local', noPrompt: true, cloudUrl: 'https://cloud.example.test', io @@ -1736,7 +1736,7 @@ test('deploy dev mode rejects malformed runtime credential tokens before launch' } }, bundle: successfulBundleStager(), - modes: { dev: successfulDevLauncher(() => { launched = true; }) } + modes: { local: successfulDevLauncher(() => { launched = true; }) } } ), /runtime-credentials returned a token without expected relay_pa_ prefix/ @@ -1748,7 +1748,7 @@ test('deploy dev mode rejects malformed runtime credential tokens before launch' } }); -test('deploy dev mode rejects mismatched relayfile workspace ids before launch', async () => { +test('deploy local mode rejects mismatched relayfile workspace ids before launch', async () => { const { personaPath, cleanup } = await withTempPersona( basePersonaJson({ integrations: { @@ -1781,7 +1781,7 @@ test('deploy dev mode rejects mismatched relayfile workspace ids before launch', deploy( { personaPath, - mode: 'dev', + mode: 'local', noPrompt: true, cloudUrl: 'https://cloud.example.test', io @@ -1797,7 +1797,7 @@ test('deploy dev mode rejects mismatched relayfile workspace ids before launch', } }, bundle: successfulBundleStager(), - modes: { dev: successfulDevLauncher(() => { launched = true; }) } + modes: { local: successfulDevLauncher(() => { launched = true; }) } } ), /runtime-credentials returned relayfile workspace rf-stale, expected rf-canonical/ @@ -1809,7 +1809,7 @@ test('deploy dev mode rejects mismatched relayfile workspace ids before launch', } }); -test('deploy dev mode rejects runtime credential tokens without mount paths before launch', async () => { +test('deploy local mode rejects runtime credential tokens without mount paths before launch', async () => { const { personaPath, cleanup } = await withTempPersona( basePersonaJson({ integrations: { @@ -1842,7 +1842,7 @@ test('deploy dev mode rejects runtime credential tokens without mount paths befo deploy( { personaPath, - mode: 'dev', + mode: 'local', noPrompt: true, cloudUrl: 'https://cloud.example.test', io @@ -1854,7 +1854,7 @@ test('deploy dev mode rejects runtime credential tokens without mount paths befo } }, bundle: successfulBundleStager(), - modes: { dev: successfulDevLauncher(() => { launched = true; }) } + modes: { local: successfulDevLauncher(() => { launched = true; }) } } ), /runtime-credentials returned a token without relayfile mount paths/ @@ -1866,7 +1866,7 @@ test('deploy dev mode rejects runtime credential tokens without mount paths befo } }); -test('deploy dev mode fails closed before runtime credentials when workspace token is missing', async () => { +test('deploy local mode fails closed before runtime credentials when workspace token is missing', async () => { const { personaPath, cleanup } = await withTempPersona( basePersonaJson({ integrations: { @@ -1886,7 +1886,7 @@ test('deploy dev mode fails closed before runtime credentials when workspace tok deploy( { personaPath, - mode: 'dev', + mode: 'local', noPrompt: true, cloudUrl: 'https://cloud.example.test', io: createBufferedIO() @@ -1901,7 +1901,7 @@ test('deploy dev mode fails closed before runtime credentials when workspace tok } }, bundle: successfulBundleStager(), - modes: { dev: successfulDevLauncher() } + modes: { local: successfulDevLauncher() } } ), /workspace token is required for deploy/ @@ -1913,7 +1913,7 @@ test('deploy dev mode fails closed before runtime credentials when workspace tok } }); -test('deploy dev mode still fails fast for genuinely unconnected workspace integrations with --no-prompt', async () => { +test('deploy local mode still fails fast for genuinely unconnected workspace integrations with --no-prompt', async () => { const { personaPath, cleanup } = await withTempPersona( basePersonaJson({ integrations: { @@ -1939,7 +1939,7 @@ test('deploy dev mode still fails fast for genuinely unconnected workspace integ deploy( { personaPath, - mode: 'dev', + mode: 'local', noPrompt: true, cloudUrl: 'https://cloud.example.test', io @@ -1951,7 +1951,7 @@ test('deploy dev mode still fails fast for genuinely unconnected workspace integ } }, bundle: successfulBundleStager(), - modes: { dev: successfulDevLauncher() } + modes: { local: successfulDevLauncher() } } ), /deploy aborted: 1 integration\(s\) failed to connect: github/ @@ -1991,12 +1991,12 @@ test('deploy aborts cleanly when one missing integration connect fails', async ( try { await assert.rejects( deploy( - { personaPath, mode: 'dev', io }, + { personaPath, mode: 'local', io }, { workspaceAuth, integrations, bundle: successfulBundleStager(), - modes: { dev: successfulDevLauncher(() => { launched = true; }) } + modes: { local: successfulDevLauncher(() => { launched = true; }) } } ), /deploy aborted: 1 integration\(s\) failed to connect: notion/ @@ -2039,7 +2039,7 @@ test('deploy treats --no-prompt as fail-fast for missing integration connects', try { await assert.rejects( deploy( - { personaPath, mode: 'dev', noPrompt: true, io }, + { personaPath, mode: 'local', noPrompt: true, io }, { workspaceAuth, integrations } ), /deploy aborted: 1 integration\(s\) failed to connect: github/ @@ -2157,7 +2157,7 @@ test('deploy stages a bundle and hands off to the resolved launcher', async () = }; let launched = 0; - const devLauncher: ModeLauncher = { + const localLauncher: ModeLauncher = { async launch(input) { launched += 1; assert.equal(input.persona.id, 'demo'); @@ -2187,15 +2187,15 @@ test('deploy stages a bundle and hands off to the resolved launcher', async () = try { const result = await deploy( - { personaPath, mode: 'dev', io }, - { workspaceAuth, integrations, bundle: bundleStager, modes: { dev: devLauncher } } + { personaPath, mode: 'local', io }, + { workspaceAuth, integrations, bundle: bundleStager, modes: { local: localLauncher } } ); assert.equal(launched, 1); - assert.equal(result.mode, 'dev'); + assert.equal(result.mode, 'local'); assert.equal(result.workspace, 'ws-test'); assert.ok(result.bundleDir.startsWith(dir)); assert.equal(stagedTo, result.bundleDir); - assert.ok(io.messages.find((m) => m.message.includes('launched: dev/pid-1'))); + assert.ok(io.messages.find((m) => m.message.includes('launched: local/pid-1'))); } finally { await cleanup(); } @@ -2207,7 +2207,7 @@ test('deploy --bundle-out emits to the supplied dir and skips launch', async () const io = createBufferedIO(); let launched = false; - const devLauncher: ModeLauncher = { + const localLauncher: ModeLauncher = { async launch() { launched = true; throw new Error('launch should not run with --bundle-out'); @@ -2238,7 +2238,7 @@ test('deploy --bundle-out emits to the supplied dir and skips launch', async () try { const result = await deploy( - { personaPath, mode: 'dev', io, bundleOut: outDir }, + { personaPath, mode: 'local', io, bundleOut: outDir }, { workspaceAuth: { async resolveWorkspace() { @@ -2254,7 +2254,7 @@ test('deploy --bundle-out emits to the supplied dir and skips launch', async () } }, bundle: bundleStager, - modes: { dev: devLauncher } + modes: { local: localLauncher } } ); assert.equal(launched, false); @@ -2412,10 +2412,10 @@ test('deploy: default auth resolver honors env credentials without a workspaceAu await withWorkspaceEnv({ workspace: 'env-ws', token: 'env-tok' }, async () => { let launched = false; const result = await deploy( - { personaPath, mode: 'dev', noConnect: true, io: createBufferedIO() }, + { personaPath, mode: 'local', noConnect: true, io: createBufferedIO() }, { bundle: successfulBundleStager(), - modes: { dev: successfulDevLauncher(() => { launched = true; }) } + modes: { local: successfulDevLauncher(() => { launched = true; }) } } ); assert.equal(result.workspace, 'env-ws'); @@ -2438,8 +2438,8 @@ test('deploy: clear error when nothing resolves and noPrompt is set', async () = await withAgentRelayHome(async () => { await assert.rejects( deploy( - { personaPath, mode: 'dev', noConnect: true, noPrompt: true, io: createBufferedIO() }, - { bundle: successfulBundleStager(), modes: { dev: successfulDevLauncher() } } + { personaPath, mode: 'local', noConnect: true, noPrompt: true, io: createBufferedIO() }, + { bundle: successfulBundleStager(), modes: { local: successfulDevLauncher() } } ), /No active Agent Relay workspace found/ ); @@ -2605,3 +2605,31 @@ test('cloud deploy advertises the trigger URL in both addressing forms', async ( await cleanup(); } }); + +test('deploy accepts legacy dev mode with one deprecation warning', async () => { + const { personaPath, cleanup } = await withTempPersona(basePersonaJson()); + const io = createBufferedIO(); + try { + const result = await deploy({ personaPath, mode: 'dev', dryRun: true, io }); + assert.equal(result.mode, 'local'); + const warnings = io.messages.filter(m => m.level === 'warn' && m.message.includes('deprecated')); + assert.equal(warnings.length, 1); assert.match(warnings[0].message, /--mode local/); + } finally { await cleanup(); } +}); + +test('deploy honors deprecated modes.dev resolver', async () => { + const { personaPath, cleanup } = await withTempPersona(basePersonaJson()); + const io = createBufferedIO(); + let launches = 0; + try { + const result = await deploy({ personaPath, mode: 'local', io }, { + workspaceAuth: { resolveWorkspace: async () => ({ workspace: 'ws', token: 'token' }) }, + bundle: successfulBundleStager(), + modes: { dev: { launch: async () => { + launches++; return { id: 'legacy', done: Promise.resolve({ code: 0 }), stop: async () => {} }; + } } }, + }); + assert.equal(result.mode, 'local'); assert.equal(launches, 1); + assert.equal(io.messages.filter(m => m.level === 'warn' && m.message.includes('resolvers.modes.dev')).length, 1); + } finally { await cleanup(); } +}); diff --git a/packages/deploy/src/deploy.ts b/packages/deploy/src/deploy.ts index 3323f6d2..bac9b319 100644 --- a/packages/deploy/src/deploy.ts +++ b/packages/deploy/src/deploy.ts @@ -31,7 +31,7 @@ import { resolveWorkspaceToken, type WorkspaceAuth } from './login.js'; -import { devLauncher } from './modes/dev.js'; +import { localLauncher } from './modes/local.js'; import { sandboxLauncher } from './modes/sandbox.js'; import { cloudLauncher, @@ -70,7 +70,7 @@ export interface DeployResolvers { */ integrationOptions?: IntegrationOptionsResolver; bundle?: BundleStager; - modes?: Partial>; + modes?: Partial> & { /** @deprecated Use local. */ dev?: ModeLauncher }; /** Deterministic override for transient integration GET retry delays. */ networkRetrySleep?: (ms: number) => Promise; } @@ -91,19 +91,19 @@ export interface CloudAuthRecoveryResolver { * - Otherwise `--mode sandbox` is the default when Daytona creds resolve * (BYO env or workforce-managed both count as "resolved" here; the * sandbox launcher itself decides which auth path to use). - * - Otherwise fall back to `--mode dev`. + * - Otherwise fall back to `--mode local`. * * The orchestrator doesn't probe the cloud endpoint here — `--mode cloud` * stays opt-in until the M4 endpoint is live. */ export function pickMode(opts: DeployOptions): DeployMode { - if (opts.mode) return opts.mode; + if (opts.mode) return opts.mode === 'dev' ? 'local' : opts.mode; // Daytona credential probe: BYO env var, or assume workforce-managed via // the active workspace (the sandbox launcher gates on its own auth). if (process.env.DAYTONA_API_KEY || process.env.WORKFORCE_WORKSPACE_TOKEN) { return 'sandbox'; } - return 'dev'; + return 'local'; } /** @@ -127,11 +127,20 @@ export function pickMode(opts: DeployOptions): DeployMode { export async function deploy(opts: DeployOptions, resolvers: DeployResolvers = {}): Promise { const io = opts.io ?? createTerminalIO(); const warnings: string[] = []; + if (opts.mode === 'dev') { + io.warn('--mode dev is deprecated; use --mode local (alias removed in the next minor)'); + opts = { ...opts, mode: 'local' }; + } + if (resolvers.modes?.dev && !resolvers.modes.local) { + io.warn('resolvers.modes.dev is deprecated; use resolvers.modes.local (alias removed in the next minor)'); + resolvers = { ...resolvers, modes: { ...resolvers.modes, local: resolvers.modes.dev } }; + } + io.info(`workforce deploy → ${opts.personaPath}`); const preflight = await preflightPersona(opts.personaPath); - const mode: DeployMode = opts.mode ?? pickMode(opts); + const mode = pickMode(opts); warnings.push(...preflight.warnings); for (const w of preflight.warnings) io.warn(w); const canCollectPickerInputs = @@ -559,8 +568,8 @@ function resolveLauncher(mode: DeployMode, resolvers: DeployResolvers): ModeLaun const supplied = resolvers.modes?.[mode]; if (supplied) return supplied; switch (mode) { - case 'dev': - return devLauncher; + case 'local': + return localLauncher; case 'sandbox': return sandboxLauncher; case 'cloud': @@ -738,7 +747,7 @@ function shouldRequestRuntimeCredentials(args: { byoSandbox: boolean; }): boolean { if (args.mode === 'cloud') return false; - if (args.mode === 'dev') return true; + if (args.mode === 'local') return true; return args.byoSandbox || hasByoSandboxEnv(); } diff --git a/packages/deploy/src/index.test.ts b/packages/deploy/src/index.test.ts new file mode 100644 index 00000000..735447dd --- /dev/null +++ b/packages/deploy/src/index.test.ts @@ -0,0 +1,23 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { devLauncher, localLauncher, pickMode, type DeployMode } from './index.js'; + +test('local launcher exports preserve deprecated identity', () => { + assert.equal(devLauncher, localLauncher); + assert.equal(typeof localLauncher.launch, 'function'); + // @ts-expect-error dev is accepted only as a legacy option, never as DeployMode. + const invalid: DeployMode = 'dev'; + void invalid; +}); +test('pickMode defaults to local and normalizes legacy dev', () => { + const old = [process.env.DAYTONA_API_KEY, process.env.WORKFORCE_WORKSPACE_TOKEN]; + try { + delete process.env.DAYTONA_API_KEY; delete process.env.WORKFORCE_WORKSPACE_TOKEN; + assert.equal(pickMode({ personaPath: 'unused' }), 'local'); + assert.equal(pickMode({ personaPath: 'unused', mode: 'dev' }), 'local'); + } finally { + ['DAYTONA_API_KEY', 'WORKFORCE_WORKSPACE_TOKEN'].forEach((key, i) => { + if (old[i] === undefined) delete process.env[key]; else process.env[key] = old[i]; + }); + } +}); diff --git a/packages/deploy/src/index.ts b/packages/deploy/src/index.ts index 816d0183..c3f996d8 100644 --- a/packages/deploy/src/index.ts +++ b/packages/deploy/src/index.ts @@ -5,7 +5,7 @@ import { type DeployResolvers } from './deploy.js'; import { preflightPersona } from './preflight.js'; -import { devLauncher } from './modes/dev.js'; +import { localLauncher } from './modes/local.js'; import { sandboxLauncher } from './modes/sandbox.js'; import { cloudLauncher } from './modes/cloud/index.js'; import type { @@ -84,7 +84,9 @@ export { extractAgentSpec, type ExtractedAgent } from './extract-agent.js'; -export { devLauncher } from './modes/dev.js'; +export { localLauncher } from './modes/local.js'; +/** @deprecated Use localLauncher. */ +export { localLauncher as devLauncher } from './modes/local.js'; export { sandboxLauncher, resolveSandboxAuth, type SandboxAuth } from './modes/sandbox.js'; export { cloudLauncher } from './modes/cloud/index.js'; @@ -94,6 +96,7 @@ export type { BundleStager, DeployIO, DeployMode, + LegacyDeployMode, DeployOptions, DeployPreflight, DeployResult, @@ -145,7 +148,9 @@ function wrapInputResolvers( return { ...resolvers, modes: { - dev: wrapLauncher(resolvers.modes?.dev ?? devLauncher, inputs, cloudUrl), + ...(resolvers.modes?.dev && !resolvers.modes.local + ? { dev: wrapLauncher(resolvers.modes.dev, inputs, cloudUrl) } + : { local: wrapLauncher(resolvers.modes?.local ?? localLauncher, inputs, cloudUrl) }), sandbox: wrapLauncher(resolvers.modes?.sandbox ?? sandboxLauncher, inputs, cloudUrl), cloud: wrapLauncher(resolvers.modes?.cloud ?? cloudLauncher, inputs, cloudUrl) } @@ -182,3 +187,5 @@ function toInputEnv(inputs: Record): Record { Object.entries(inputs).map(([key, value]) => [`${INPUT_ENV_PREFIX}${key}`, value]) ); } + +export { launchInteractiveSandbox, type InteractiveSandboxInput, type InteractiveSandboxHandle } from './modes/sandbox-interactive.js'; diff --git a/packages/deploy/src/modes/dev.ts b/packages/deploy/src/modes/dev.ts index edd52628..1c04ded9 100644 --- a/packages/deploy/src/modes/dev.ts +++ b/packages/deploy/src/modes/dev.ts @@ -1,192 +1,2 @@ -import { spawn } from 'node:child_process'; -import { createRequire } from 'node:module'; -import { mkdir, rm, symlink } from 'node:fs/promises'; -import path from 'node:path'; -import type { Readable } from 'node:stream'; -import type { - ModeLaunchInput, - ModeLaunchHandle, - ModeLauncher -} from '../types.js'; -import { runtimeContextEnv } from '../runtime-context.js'; - -const SIGTERM_TO_SIGKILL_MS = 5_000; - -const RUNTIME_PACKAGES = ['@agentworkforce/runtime', '@agentworkforce/persona-kit'] as const; - -/** - * Local dev-mode launcher. Spawns `node ` as a child - * process, forwards line-buffered stdout/stderr through the supplied - * DeployIO, and resolves `done` when the child exits. - * - * `stop()` sends SIGTERM and escalates to SIGKILL after 5s if the child - * hasn't exited cleanly. The parent's SIGINT/SIGTERM are forwarded too - * so Ctrl-C in `--mode dev` produces an orderly shutdown. - */ -export const devLauncher: ModeLauncher = { - async launch(input: ModeLaunchInput): Promise { - const runnerPath = input.bundle.runnerPath; - const cwd = path.dirname(runnerPath); - - // The generated runner imports `@agentworkforce/runtime`. In dev - // mode we resolve the package out of the parent workforce install - // and symlink it into the bundle's local node_modules so node's - // ESM resolver finds it without an `npm install` step. The link is - // idempotent: stale links are replaced on every launch. - await linkRuntimePackages(cwd); - - const env: NodeJS.ProcessEnv = { - ...process.env, - ...(input.env ?? {}), - ...runtimeContextEnv(input.persona, input.env, input.agent), - WORKFORCE_WORKSPACE_ID: input.workspace, - WORKFORCE_PERSONA_ID: input.persona.id - }; - - const child = spawn(process.execPath, [runnerPath], { - cwd, - env, - stdio: ['pipe', 'pipe', 'pipe'] - }); - - // Bridge the parent process's stdin into the child runner. The - // runner reads NDJSON envelopes from its stdin, so any envelopes the - // user pipes into `workforce deploy --mode dev` flow straight into - // the runner without an intermediate file. - // - // `input.bridged` opts out of this: a caller driving the runner via the - // returned handle's `write()` (e.g. a long-lived fleet-node host process) - // owns the child's stdin lifecycle itself. Without this opt-out, the - // HOST process's own stdin ending (the normal case for anything - // non-interactive/daemonized) would end `child.stdin` out from under - // that caller — every subsequent `write()` then throws - // ERR_STREAM_WRITE_AFTER_END, uncaught, crashing the host process. - if (child.stdin && !input.bridged) { - process.stdin.pipe(child.stdin); - // When the parent's stdin closes (EOF / piped input drained), end - // the child's stdin too so the runner's for-await loop terminates. - process.stdin.once('end', () => { - child.stdin?.end(); - }); - } - // A write after the child's stdin has ended (crashed/exiting child, - // or a `bridged` caller racing `stop()`) throws — without a listener, - // Node treats that as an uncaught exception and crashes the whole - // process. Surface it through `io.warn` instead, in every mode. - child.stdin?.on('error', (err) => { - input.io.warn(`[runtime] stdin write error: ${err instanceof Error ? err.message : String(err)}`); - }); - - if (child.pid === undefined) { - throw new Error('dev launcher: failed to spawn runner (no pid assigned)'); - } - - forwardLines(child.stdout, (line) => input.io.info(`[runtime] ${line}`)); - forwardLines(child.stderr, (line) => input.io.warn(`[runtime] ${line}`)); - - const done = new Promise<{ code: number }>((resolveDone) => { - child.once('exit', (code, signal) => { - const exitCode = typeof code === 'number' ? code : signal ? signalExit(signal) : 0; - resolveDone({ code: exitCode }); - }); - }); - - let stopping = false; - const stop = async (): Promise => { - if (stopping) { - await done; - return; - } - stopping = true; - child.stdin?.end(); - child.kill('SIGTERM'); - // `child.killed` flips true the moment `kill()` delivers the signal, - // regardless of whether the child has actually exited. To detect a - // stuck child we have to look at the real lifecycle markers — both - // exitCode and signalCode stay null until the OS reaps the process. - const escalation = setTimeout(() => { - if (child.exitCode === null && child.signalCode === null) { - child.kill('SIGKILL'); - } - }, SIGTERM_TO_SIGKILL_MS).unref(); - try { - await done; - } finally { - clearTimeout(escalation); - } - }; - - // Bridge parent-process signal handlers so Ctrl-C in --mode dev - // produces a clean child shutdown (and not an orphaned runner). - const forwardParentSignal = (signal: NodeJS.Signals) => { - void stop().catch(() => { - /* stop already drained */ - }); - process.off('SIGINT', forwardParentSignal as never); - process.off('SIGTERM', forwardParentSignal as never); - // Re-raise so the parent's normal exit semantics take over after - // the child closes down. - process.kill(process.pid, signal); - }; - process.once('SIGINT', forwardParentSignal); - process.once('SIGTERM', forwardParentSignal); - - return { - id: `pid:${child.pid}`, - stop, - done, - write: (line: string) => { - child.stdin?.write(line); - } - }; - } -}; - -function forwardLines(stream: Readable, write: (line: string) => void): void { - let buffered = ''; - stream.setEncoding('utf8'); - stream.on('data', (chunk: string) => { - buffered += chunk; - let nl = buffered.indexOf('\n'); - while (nl !== -1) { - const line = buffered.slice(0, nl).replace(/\r$/, ''); - buffered = buffered.slice(nl + 1); - if (line.length > 0) write(line); - nl = buffered.indexOf('\n'); - } - }); - stream.on('end', () => { - const tail = buffered.trim(); - if (tail.length > 0) write(tail); - }); -} - -async function linkRuntimePackages(bundleDir: string): Promise { - const nodeModulesDir = path.join(bundleDir, 'node_modules'); - const scopeDir = path.join(nodeModulesDir, '@agentworkforce'); - await mkdir(scopeDir, { recursive: true }); - - // Resolve each package's installed root by asking node where its - // `package.json` lives, then symlink that root into our bundle's - // node_modules. Using `require.resolve` guarantees we point at the - // package the deploy package itself imports — no env var dance. - const localRequire = createRequire(import.meta.url); - for (const pkg of RUNTIME_PACKAGES) { - const manifestPath = localRequire.resolve(`${pkg}/package.json`); - const packageRoot = path.dirname(manifestPath); - const linkPath = path.join(scopeDir, pkg.slice('@agentworkforce/'.length)); - await rm(linkPath, { recursive: true, force: true }); - await symlink(packageRoot, linkPath, 'dir'); - } -} - -function signalExit(signal: NodeJS.Signals): number { - // Match the POSIX convention for terminated children. - const SIGNAL_MAP: Partial> = { - SIGINT: 130, - SIGTERM: 143, - SIGKILL: 137, - SIGHUP: 129 - }; - return SIGNAL_MAP[signal] ?? 1; -} +/** @deprecated Use localLauncher. */ +export { localLauncher as devLauncher } from './local.js'; diff --git a/packages/deploy/src/modes/input-values.test.ts b/packages/deploy/src/modes/input-values.test.ts index a1903732..059915b8 100644 --- a/packages/deploy/src/modes/input-values.test.ts +++ b/packages/deploy/src/modes/input-values.test.ts @@ -125,7 +125,7 @@ test('deploy inputs validate against persona spec and forward to mode env', asyn await deploy( { personaPath, - mode: 'dev', + mode: 'local', io, inputs: { TOPIC: 'Deploy v1', REGION: 'us-east-1' } }, @@ -133,7 +133,7 @@ test('deploy inputs validate against persona spec and forward to mode env', asyn workspaceAuth: testWorkspaceAuth(), integrations: connectedIntegrations(), bundle: testBundleStager(), - modes: { dev: launcher } + modes: { local: launcher } } ); @@ -161,7 +161,7 @@ test('deploy inputs reach the dev launcher child process env', async () => { const result = await deploy( { personaPath, - mode: 'dev', + mode: 'local', io: createBufferedIO(), inputs: { TOPIC: 'Deploy v1', REGION: 'eu-west-1' } }, @@ -191,7 +191,7 @@ test('deploy inputs reject undeclared keys with declared input list', async () = await assert.rejects( deploy({ personaPath, - mode: 'dev', + mode: 'local', io: createBufferedIO(), inputs: { UNKNOWN: 'x' } }), @@ -208,7 +208,7 @@ test('deploy inputs reject non-string values with clean error', async () => { await assert.rejects( deploy({ personaPath, - mode: 'dev', + mode: 'local', io: createBufferedIO(), inputs: { TOPIC: 42 } as unknown as Record }), diff --git a/packages/deploy/src/modes/local.ts b/packages/deploy/src/modes/local.ts new file mode 100644 index 00000000..111f3e7e --- /dev/null +++ b/packages/deploy/src/modes/local.ts @@ -0,0 +1,192 @@ +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { mkdir, rm, symlink } from 'node:fs/promises'; +import path from 'node:path'; +import type { Readable } from 'node:stream'; +import type { + ModeLaunchInput, + ModeLaunchHandle, + ModeLauncher +} from '../types.js'; +import { runtimeContextEnv } from '../runtime-context.js'; + +const SIGTERM_TO_SIGKILL_MS = 5_000; + +const RUNTIME_PACKAGES = ['@agentworkforce/runtime', '@agentworkforce/persona-kit'] as const; + +/** + * Local-mode launcher. Spawns `node ` as a child + * process, forwards line-buffered stdout/stderr through the supplied + * DeployIO, and resolves `done` when the child exits. + * + * `stop()` sends SIGTERM and escalates to SIGKILL after 5s if the child + * hasn't exited cleanly. The parent's SIGINT/SIGTERM are forwarded too + * so Ctrl-C in `--mode local` produces an orderly shutdown. + */ +export const localLauncher: ModeLauncher = { + async launch(input: ModeLaunchInput): Promise { + const runnerPath = input.bundle.runnerPath; + const cwd = path.dirname(runnerPath); + + // The generated runner imports `@agentworkforce/runtime`. In local mode + // mode we resolve the package out of the parent workforce install + // and symlink it into the bundle's local node_modules so node's + // ESM resolver finds it without an `npm install` step. The link is + // idempotent: stale links are replaced on every launch. + await linkRuntimePackages(cwd); + + const env: NodeJS.ProcessEnv = { + ...process.env, + ...(input.env ?? {}), + ...runtimeContextEnv(input.persona, input.env, input.agent), + WORKFORCE_WORKSPACE_ID: input.workspace, + WORKFORCE_PERSONA_ID: input.persona.id + }; + + const child = spawn(process.execPath, [runnerPath], { + cwd, + env, + stdio: ['pipe', 'pipe', 'pipe'] + }); + + // Bridge the parent process's stdin into the child runner. The + // runner reads NDJSON envelopes from its stdin, so any envelopes the + // user pipes into `workforce deploy --mode local` flow straight into + // the runner without an intermediate file. + // + // `input.bridged` opts out of this: a caller driving the runner via the + // returned handle's `write()` (e.g. a long-lived fleet-node host process) + // owns the child's stdin lifecycle itself. Without this opt-out, the + // HOST process's own stdin ending (the normal case for anything + // non-interactive/daemonized) would end `child.stdin` out from under + // that caller — every subsequent `write()` then throws + // ERR_STREAM_WRITE_AFTER_END, uncaught, crashing the host process. + if (child.stdin && !input.bridged) { + process.stdin.pipe(child.stdin); + // When the parent's stdin closes (EOF / piped input drained), end + // the child's stdin too so the runner's for-await loop terminates. + process.stdin.once('end', () => { + child.stdin?.end(); + }); + } + // A write after the child's stdin has ended (crashed/exiting child, + // or a `bridged` caller racing `stop()`) throws — without a listener, + // Node treats that as an uncaught exception and crashes the whole + // process. Surface it through `io.warn` instead, in every mode. + child.stdin?.on('error', (err) => { + input.io.warn(`[runtime] stdin write error: ${err instanceof Error ? err.message : String(err)}`); + }); + + if (child.pid === undefined) { + throw new Error('local launcher: failed to spawn runner (no pid assigned)'); + } + + forwardLines(child.stdout, (line) => input.io.info(`[runtime] ${line}`)); + forwardLines(child.stderr, (line) => input.io.warn(`[runtime] ${line}`)); + + const done = new Promise<{ code: number }>((resolveDone) => { + child.once('exit', (code, signal) => { + const exitCode = typeof code === 'number' ? code : signal ? signalExit(signal) : 0; + resolveDone({ code: exitCode }); + }); + }); + + let stopping = false; + const stop = async (): Promise => { + if (stopping) { + await done; + return; + } + stopping = true; + child.stdin?.end(); + child.kill('SIGTERM'); + // `child.killed` flips true the moment `kill()` delivers the signal, + // regardless of whether the child has actually exited. To detect a + // stuck child we have to look at the real lifecycle markers — both + // exitCode and signalCode stay null until the OS reaps the process. + const escalation = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL'); + } + }, SIGTERM_TO_SIGKILL_MS).unref(); + try { + await done; + } finally { + clearTimeout(escalation); + } + }; + + // Bridge parent-process signal handlers so Ctrl-C in --mode local + // produces a clean child shutdown (and not an orphaned runner). + const forwardParentSignal = (signal: NodeJS.Signals) => { + void stop().catch(() => { + /* stop already drained */ + }); + process.off('SIGINT', forwardParentSignal as never); + process.off('SIGTERM', forwardParentSignal as never); + // Re-raise so the parent's normal exit semantics take over after + // the child closes down. + process.kill(process.pid, signal); + }; + process.once('SIGINT', forwardParentSignal); + process.once('SIGTERM', forwardParentSignal); + + return { + id: `pid:${child.pid}`, + stop, + done, + write: (line: string) => { + child.stdin?.write(line); + } + }; + } +}; + +function forwardLines(stream: Readable, write: (line: string) => void): void { + let buffered = ''; + stream.setEncoding('utf8'); + stream.on('data', (chunk: string) => { + buffered += chunk; + let nl = buffered.indexOf('\n'); + while (nl !== -1) { + const line = buffered.slice(0, nl).replace(/\r$/, ''); + buffered = buffered.slice(nl + 1); + if (line.length > 0) write(line); + nl = buffered.indexOf('\n'); + } + }); + stream.on('end', () => { + const tail = buffered.trim(); + if (tail.length > 0) write(tail); + }); +} + +async function linkRuntimePackages(bundleDir: string): Promise { + const nodeModulesDir = path.join(bundleDir, 'node_modules'); + const scopeDir = path.join(nodeModulesDir, '@agentworkforce'); + await mkdir(scopeDir, { recursive: true }); + + // Resolve each package's installed root by asking node where its + // `package.json` lives, then symlink that root into our bundle's + // node_modules. Using `require.resolve` guarantees we point at the + // package the deploy package itself imports — no env var dance. + const localRequire = createRequire(import.meta.url); + for (const pkg of RUNTIME_PACKAGES) { + const manifestPath = localRequire.resolve(`${pkg}/package.json`); + const packageRoot = path.dirname(manifestPath); + const linkPath = path.join(scopeDir, pkg.slice('@agentworkforce/'.length)); + await rm(linkPath, { recursive: true, force: true }); + await symlink(packageRoot, linkPath, 'dir'); + } +} + +function signalExit(signal: NodeJS.Signals): number { + // Match the POSIX convention for terminated children. + const SIGNAL_MAP: Partial> = { + SIGINT: 130, + SIGTERM: 143, + SIGKILL: 137, + SIGHUP: 129 + }; + return SIGNAL_MAP[signal] ?? 1; +} diff --git a/packages/deploy/src/modes/sandbox-interactive.e2e.test.ts b/packages/deploy/src/modes/sandbox-interactive.e2e.test.ts new file mode 100644 index 00000000..f8d46258 --- /dev/null +++ b/packages/deploy/src/modes/sandbox-interactive.e2e.test.ts @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { PassThrough } from 'node:stream'; +import { setTimeout as delay } from 'node:timers/promises'; +import { ensureCloudSession, readStoredAuth } from '@agent-relay/cloud'; +import { launchInteractiveSandbox } from './sandbox-interactive.js'; + +// Explicit opt-in; workspace must be the Cloud UUID accepted by the fleet API. +const enabled = process.env.WORKFORCE_E2E_SANDBOX === '1'; +const workspace = process.env.WORKFORCE_E2E_SANDBOX_WORKSPACE; +test('live Daytona: task output, exit and fleet deletion', { + skip: !enabled || !workspace ? 'Set WORKFORCE_E2E_SANDBOX=1 and WORKFORCE_E2E_SANDBOX_WORKSPACE with a logged-in Relay session.' : false, + timeout: 600_000, +}, async t => { + if (!(await readStoredAuth())) { t.skip('No stored Relay credentials available.'); return; } + // Once credentials exist, auth/network errors fail the live gate rather than skipping it. + const session = await ensureCloudSession({ interactive: false }); + const stdout = new PassThrough(); + let output = ''; + stdout.on('data', chunk => { output += String(chunk); }); + const handle = await launchInteractiveSandbox({ + persona: { id: 'sandbox-smoke', intent: 'documentation', description: 'Sandbox smoke test', tags: [], skills: [], harness: 'claude', harnessSettings: { reasoning: 'medium', timeoutSeconds: 120 } }, + workspace: workspace!, provider: 'daytona', task: 'echo hello && exit 0', + stdio: { stdin: new PassThrough(), stdout, stderr: new PassThrough() }, + }); + t.after(handle.stop); + const roster = async () => { + const response = await session.client.fetch(`/api/v1/fleet/nodes?workspaceId=${encodeURIComponent(workspace!)}`, { method: 'GET' }); + assert.equal(response.ok, true, `Fleet lookup failed (${response.status})`); + const body = await response.json() as { nodes?: Array<{ id: string }> }; + assert.ok(Array.isArray(body.nodes), 'Fleet response must contain a nodes array'); + return body.nodes; + }; + await handle.attached; + assert.ok((await roster()).some(node => node.id === handle.nodeId), 'Live resource must exist before deletion'); + assert.equal(await handle.finished, 0); + assert.match(output, /hello/); + await handle.stop(); + for (let attempt = 0; attempt < 30; attempt++) { + const nodes = await roster(); + if (!nodes.some(node => node.id === handle.nodeId || JSON.stringify(node).includes(handle.sandboxId))) return; + await delay(1000); + } + assert.fail(`Sandbox ${handle.sandboxId} is still present in the live fleet after stop()`); +}); diff --git a/packages/deploy/src/modes/sandbox-interactive.test.ts b/packages/deploy/src/modes/sandbox-interactive.test.ts new file mode 100644 index 00000000..98275fde --- /dev/null +++ b/packages/deploy/src/modes/sandbox-interactive.test.ts @@ -0,0 +1,159 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer, connect, type Socket } from 'node:net'; +import { once } from 'node:events'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough } from 'node:stream'; +import { launchInteractiveSandbox, type InteractiveSandboxDependencies, type InteractiveSandboxInput } from './sandbox-interactive.js'; +import { PersonaNotSandboxableError } from '@agentworkforce/persona-kit'; + +function input(): InteractiveSandboxInput { + return { + persona: { id: 'demo', intent: 'documentation', description: '', tags: [], skills: [], harness: 'claude', harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 } }, + workspace: 'ws', stdio: { stdin: new PassThrough(), stdout: new PassThrough(), stderr: new PassThrough() }, + }; +} +async function fake() { + // Keep UNIX socket names below macOS's 104-byte limit. + const dir = await mkdtemp(join(tmpdir(), 'wf-')); + const socketPath = join(dir, 's'); + const sockets = new Set(); + const server = createServer(socket => { sockets.add(socket); socket.on('error', () => {}); socket.on('close', () => sockets.delete(socket)); }); + server.listen(socketPath); + await once(server, 'listening'); + let finish!: (code: number) => void; + const finished = new Promise(resolve => { finish = resolve; }); + const spawnCalls: Parameters[0][] = []; + const attachCalls: Parameters[0][] = []; + let destroys = 0; + let closes = 0; + const deps: InteractiveSandboxDependencies = { + spawnFleetSandbox: async request => { spawnCalls.push(request); return { sandboxId: 'sb_1', nodeId: 'node_1', destroy: async () => { destroys++; } }; }, + startFleetNodeAttachProxy: async opts => { attachCalls.push(opts); return { socketPath, finished, close: async () => { closes++; } }; }, + connect, + }; + return { deps, server, finish, spawnCalls, attachCalls, counts: () => ({ destroys, closes }), cleanup: async () => { + for (const socket of sockets) socket.destroy(); + await new Promise(resolve => server.close(() => resolve())); + await rm(dir, { recursive: true, force: true }); + } }; +} + +test('interactive sandbox: real bidirectional socket, attached, exit and idempotent stop', { timeout: 5000 }, async t => { + const relay = await fake(); t.after(relay.cleanup); + const request = input(); + const connection = once(relay.server, 'connection'); + const handle = await launchInteractiveSandbox(request, relay.deps); t.after(handle.stop); + let attached = false; + void handle.attached.then(() => { attached = true; }); + assert.equal(attached, false); + const [peer] = await connection as [Socket]; + await handle.attached; + const received = once(peer, 'data'); + (request.stdio.stdin as PassThrough).write('inbound'); + assert.equal(String((await received)[0]), 'inbound'); + const output = once(request.stdio.stdout, 'data'); + peer.write('outbound'); + assert.equal(String((await output)[0]), 'outbound'); + relay.finish(7); + assert.equal(await handle.finished, 7); + await Promise.all([handle.stop(), handle.stop()]); + assert.deepEqual(relay.counts(), { destroys: 1, closes: 1 }); + assert.equal(request.stdio.stdin.listenerCount('data'), 0); + assert.equal((request.stdio.stdout as PassThrough).writableEnded, false); +}); + +test('interactive sandbox: detach closes the proxy and leaves the sandbox running', { timeout: 5000 }, async t => { + const relay = await fake(); t.after(relay.cleanup); + const request = input(); + const handle = await launchInteractiveSandbox(request, relay.deps); + await handle.attached; + await handle.detach(); await handle.detach(); + assert.deepEqual(relay.counts(), { destroys: 0, closes: 1 }); + assert.equal(request.stdio.stdin.listenerCount('data'), 0); +}); + +test('interactive sandbox: mount and option forwarding, defaults and omitted empty paths', { timeout: 5000 }, async t => { + const relay = await fake(); t.after(relay.cleanup); + const plain = await launchInteractiveSandbox(input(), relay.deps); + await plain.attached; await plain.stop(); + assert.equal(Object.hasOwn(relay.spawnCalls[0], 'relayfilePaths'), false); + assert.equal(relay.spawnCalls[0].authMode, 'managed'); + assert.equal(relay.attachCalls[0].mode, 'drive'); + const request = input(); + request.persona.mount = { ignoredPatterns: ['/*', '!web', '!web/**'], readonlyPatterns: ['docs/**'] }; + Object.assign(request, { provider: 'e2b', sandboxId: 'sb_replay', task: 'hello', authMode: 'byo', attachMode: 'view' }); + const scoped = await launchInteractiveSandbox(request, relay.deps); + await scoped.attached; await scoped.stop(); + const call = relay.spawnCalls[1]; + assert.deepEqual(call.relayfilePaths, ['/web/**']); + assert.deepEqual(call.readonlyPaths, ['docs/**']); + assert.notEqual(call.readonlyPaths, request.persona.mount.readonlyPatterns); + assert.equal(call.provider, 'e2b'); assert.equal(call.sandboxId, 'sb_replay'); + assert.equal(call.task, 'hello'); assert.equal(call.authMode, 'byo'); + assert.deepEqual(relay.attachCalls[1], { nodeId: 'node_1', mode: 'view' }); +}); + +test('interactive sandbox: failed attach destroys and preserves original error', async () => { + let destroys = 0; + const failure = new Error('attach refused'); + await assert.rejects(launchInteractiveSandbox(input(), { + spawnFleetSandbox: async () => ({ sandboxId: 's', nodeId: 'n', destroy: async () => { destroys++; throw new Error('cleanup'); } }), + startFleetNodeAttachProxy: async () => { throw failure; }, connect, + }), err => err === failure); + assert.equal(destroys, 1); +}); + +test('interactive sandbox: invalid persona is rejected before spawn', async () => { + let spawns = 0; + const request = input(); request.persona.sandbox = false; + await assert.rejects(launchInteractiveSandbox(request, { + spawnFleetSandbox: async () => { spawns++; throw new Error('unreachable'); }, + startFleetNodeAttachProxy: async () => { throw new Error('unreachable'); }, connect, + }), PersonaNotSandboxableError); + assert.equal(spawns, 0); +}); + +test('interactive sandbox: connection failure rejects attached and finished and destroys', { timeout: 5000 }, async () => { + let destroys = 0; + const handle = await launchInteractiveSandbox(input(), { + spawnFleetSandbox: async () => ({ sandboxId: 's', nodeId: 'n', destroy: async () => { destroys++; } }), + startFleetNodeAttachProxy: async () => ({ socketPath: '/nonexistent-wf-socket', finished: new Promise(() => {}), close: async () => {} }), connect, + }); + await assert.rejects(handle.attached, /ENOENT/); + await assert.rejects(handle.finished, /ENOENT/); + await handle.stop(); assert.equal(destroys, 1); +}); + +test('interactive sandbox: explicit detach never destroys when proxy close rejects finished', { timeout: 5000 }, async t => { + const relay = await fake(); t.after(relay.cleanup); + let rejectFinished!: (err: Error) => void; + const finished = new Promise((_, reject) => { rejectFinished = reject; }); + const attach = relay.deps.startFleetNodeAttachProxy; + relay.deps.startFleetNodeAttachProxy = async opts => { + const proxy = await attach(opts); + return { ...proxy, finished, close: async () => { await proxy.close(); rejectFinished(new Error('detached')); } }; + }; + const handle = await launchInteractiveSandbox(input(), relay.deps); + await handle.attached; + await handle.detach(); + await assert.rejects(handle.finished, /detached/); + assert.equal(relay.counts().destroys, 0); +}); + +test('interactive sandbox: failed proxy close cannot prevent sandbox deletion', { timeout: 5000 }, async t => { + const relay = await fake(); t.after(relay.cleanup); + const attach = relay.deps.startFleetNodeAttachProxy; + relay.deps.startFleetNodeAttachProxy = async opts => { + const proxy = await attach(opts); + return { ...proxy, close: async () => { await proxy.close(); throw new Error('close failed'); } }; + }; + const request = input(); let warnings = ''; + request.stdio.stderr.on('data', value => { warnings += value; }); + const handle = await launchInteractiveSandbox(request, relay.deps); + await handle.attached; await handle.stop(); + assert.equal(relay.counts().destroys, 1); + assert.match(warnings, /cleanup failed: close failed/); +}); diff --git a/packages/deploy/src/modes/sandbox-interactive.ts b/packages/deploy/src/modes/sandbox-interactive.ts new file mode 100644 index 00000000..2c8bf196 --- /dev/null +++ b/packages/deploy/src/modes/sandbox-interactive.ts @@ -0,0 +1,163 @@ +import { connect, type Socket } from 'node:net'; +import { personaToSandboxParams, type PersonaSpec, type PersonaSandboxParams } from '@agentworkforce/persona-kit'; +import { createIdempotentStop, destroyOnFailure } from './sandbox-shared.js'; + +export interface InteractiveSandboxInput { + persona: PersonaSpec; + workspace: string; + inputs?: Record; + authMode?: 'byo' | 'managed'; + provider?: 'daytona' | 'e2b'; + sandboxId?: string; + attachMode?: 'view' | 'drive'; + task?: string; + stdio: { + stdin: NodeJS.ReadableStream; + stdout: NodeJS.WritableStream; + stderr: NodeJS.WritableStream; + }; +} + +export interface InteractiveSandboxHandle { + sandboxId: string; + nodeId: string; + attached: Promise; + finished: Promise; + detach(): Promise; + stop(): Promise; +} + +interface SpawnRequest extends Omit { + workspace: string; + relayfilePaths?: string[]; + authMode: 'byo' | 'managed'; + provider?: 'daytona' | 'e2b'; + sandboxId?: string; + task?: string; +} +interface Sandbox { + sandboxId: string; + nodeId: string; + destroy(): Promise; +} +interface AttachProxy { + socketPath: string; + finished: Promise; + close(): Promise; +} +/** @internal Injection boundary until Relay publishes the specified subpaths. */ +export interface InteractiveSandboxDependencies { + spawnFleetSandbox(request: SpawnRequest): Promise; + startFleetNodeAttachProxy(options: { nodeId: string; mode: 'view' | 'drive' }): Promise; + connect(path: string): Socket; +} + +async function relayDependencies(): Promise { + // Keep unrelated deploy/local commands loadable with the current Relay release. + // These names are deliberately resolved at runtime: no CLI or private-file fallback. + const fleetPath = '@agent-relay/cloud/fleet'; + const attachPath = '@agent-relay/cloud/attach'; + const [fleet, attach] = await Promise.all([import(fleetPath), import(attachPath)]).catch(cause => { + throw new Error('Interactive sandbox requires a published @agent-relay/cloud release with /fleet and /attach exports. The installed Relay SDK does not provide that contract.', { cause }); + }); + if (typeof fleet.spawnFleetSandbox !== 'function' || typeof attach.startFleetNodeAttachProxy !== 'function') { + throw new Error('Relay SDK is missing spawnFleetSandbox or startFleetNodeAttachProxy.'); + } + return { spawnFleetSandbox: fleet.spawnFleetSandbox, startFleetNodeAttachProxy: attach.startFleetNodeAttachProxy, connect }; +} + +export async function launchInteractiveSandbox( + input: InteractiveSandboxInput, + deps?: InteractiveSandboxDependencies, +): Promise { + const { relayfilePaths, ...params } = personaToSandboxParams(input.persona, input); + const relay = deps ?? await relayDependencies(); + const sandbox = await relay.spawnFleetSandbox({ + ...params, + ...(relayfilePaths.length ? { relayfilePaths } : {}), + workspace: input.workspace, + authMode: input.authMode ?? 'managed', + ...(input.provider ? { provider: input.provider } : {}), + ...(input.sandboxId ? { sandboxId: input.sandboxId } : {}), + ...(input.task !== undefined ? { task: input.task } : {}), + }); + let proxy: AttachProxy; + let socket: Socket; + try { + proxy = await relay.startFleetNodeAttachProxy({ nodeId: sandbox.nodeId, mode: input.attachMode ?? 'drive' }); + // An incompatible adapter must never result in net.connect(undefined). + if (typeof proxy.socketPath !== 'string' || !proxy.socketPath || !proxy.finished || typeof proxy.close !== 'function') { + const error = new Error('Relay attach adapter does not expose the required UNIX socket/finished contract.'); + return await destroyOnFailure(() => typeof proxy.close === 'function' ? proxy.close() : Promise.resolve(), error); + } + try { socket = relay.connect(proxy.socketPath); } + catch (err) { return await destroyOnFailure(() => proxy.close(), err); } + } catch (err) { + return destroyOnFailure(() => sandbox.destroy(), err); + } + + let detached = false; + let connected = false; + let disconnected: Promise | undefined; + const disconnect = (): Promise => disconnected ??= (async () => { + input.stdio.stdin.unpipe(socket); + socket.unpipe(input.stdio.stdout); + socket.end(); + socket.destroy(); + await proxy.close(); + })(); + const stop = createIdempotentStop(async () => { + try { await disconnect(); } finally { await sandbox.destroy(); } + }, { warn: message => { input.stdio.stderr.write(`${message}\n`); } }); + + let resolveAttached!: () => void; + let rejectAttached!: (error: unknown) => void; + const attached = new Promise((resolve, reject) => { resolveAttached = resolve; rejectAttached = reject; }); + let rejectTransport!: (error: unknown) => void; + const transportFailure = new Promise((_, reject) => { rejectTransport = reject; }); + socket.once('connect', () => { + connected = true; + input.stdio.stdin.pipe(socket); + socket.pipe(input.stdio.stdout, { end: false }); + resolveAttached(); + }); + socket.on('error', err => { + rejectAttached(err); + rejectTransport(err); + if (!detached) void stop(); + }); + socket.once('close', () => { + if (!connected) { + const error = new Error('Sandbox socket closed before connecting.'); + rejectAttached(error); + rejectTransport(error); + } + }); + // Observe failures immediately even if a caller awaits finished before attached. + void attached.catch(() => undefined); + const finished = Promise.race([proxy.finished, transportFailure]).then(async code => { + rejectAttached(new Error('Sandbox attach ended before the socket connected.')); + await disconnect(); + return code; + }, async err => { + rejectAttached(err); + if (!detached) await stop(); + throw err; + }); + void finished.catch(() => undefined); + return { + sandboxId: sandbox.sandboxId, + nodeId: sandbox.nodeId, + attached, + finished, + detach: async () => { + detached = true; + rejectAttached(new Error('Sandbox detached before the socket connected.')); + await disconnect(); + }, + stop: () => { + rejectAttached(new Error('Sandbox stopped before the socket connected.')); + return stop(); + }, + }; +} diff --git a/packages/deploy/src/modes/sandbox-shared.test.ts b/packages/deploy/src/modes/sandbox-shared.test.ts new file mode 100644 index 00000000..b8726ec0 --- /dev/null +++ b/packages/deploy/src/modes/sandbox-shared.test.ts @@ -0,0 +1,29 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createIdempotentStop, destroyOnFailure, resolveSandboxAuthMode } from './sandbox-shared.js'; + +test('sandbox auth retains exact errors and mode selection', () => { + const keys = ['DAYTONA_API_KEY', 'DAYTONA_JWT_TOKEN', 'WORKFORCE_WORKSPACE_TOKEN']; + const saved = keys.map(key => process.env[key]); + try { + for (const key of keys) delete process.env[key]; + assert.throws(() => resolveSandboxAuthMode({}, { forceByo: true }), { message: 'sandbox launcher: --byo-sandbox requested but no Daytona credentials are in env. Set DAYTONA_API_KEY (or DAYTONA_JWT_TOKEN + DAYTONA_ORGANIZATION_ID).' }); + assert.throws(() => resolveSandboxAuthMode({}), { message: 'sandbox launcher: no Daytona credentials and no workforce workspace token. Either export DAYTONA_API_KEY, or run `workforce login` (sets WORKFORCE_WORKSPACE_TOKEN) so we can mint a workforce-managed sandbox.' }); + assert.equal(resolveSandboxAuthMode({ workspaceToken: 'token' }), 'managed'); + process.env.DAYTONA_API_KEY = 'key'; + assert.equal(resolveSandboxAuthMode({ workspaceToken: 'token' }), 'byo'); + } finally { + keys.forEach((key, i) => { if (saved[i] === undefined) delete process.env[key]; else process.env[key] = saved[i]; }); + } +}); +test('sandbox stop returns the same promise and warns once on cleanup failure', async () => { + let calls = 0; const warnings: string[] = []; + const stop = createIdempotentStop(async () => { calls++; throw new Error('failed'); }, { warn: value => warnings.push(value) }); + const first = stop(); assert.equal(stop(), first); + await first; + assert.equal(calls, 1); assert.deepEqual(warnings, ['sandbox: cleanup failed: failed']); +}); +test('sandbox destroyOnFailure preserves the original failure', async () => { + const original = new Error('original'); + await assert.rejects(destroyOnFailure(async () => { throw new Error('cleanup'); }, original), err => err === original); +}); diff --git a/packages/deploy/src/modes/sandbox-shared.ts b/packages/deploy/src/modes/sandbox-shared.ts new file mode 100644 index 00000000..70de73da --- /dev/null +++ b/packages/deploy/src/modes/sandbox-shared.ts @@ -0,0 +1,34 @@ +import type { DeployIO, ModeLaunchInput } from '../types.js'; + +export function formatSandboxError(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +export function resolveSandboxAuthMode( + input: Partial>, + { forceByo = false }: { forceByo?: boolean } = {}, +): 'byo' | 'managed' { + const byoAvailable = Boolean(process.env.DAYTONA_API_KEY?.trim() || process.env.DAYTONA_JWT_TOKEN?.trim()); + if (forceByo || byoAvailable) { + if (!byoAvailable) { + throw new Error('sandbox launcher: --byo-sandbox requested but no Daytona credentials are in env. Set DAYTONA_API_KEY (or DAYTONA_JWT_TOKEN + DAYTONA_ORGANIZATION_ID).'); + } + return 'byo'; + } + if (!(input.workspaceToken?.trim() || process.env.WORKFORCE_WORKSPACE_TOKEN?.trim())) { + throw new Error('sandbox launcher: no Daytona credentials and no workforce workspace token. Either export DAYTONA_API_KEY, or run `workforce login` (sets WORKFORCE_WORKSPACE_TOKEN) so we can mint a workforce-managed sandbox.'); + } + return 'managed'; +} + +export function createIdempotentStop(destroyFn: () => Promise, io: Pick): () => Promise { + let stopping: Promise | undefined; + return () => stopping ??= Promise.resolve().then(destroyFn).catch(err => { + io.warn(`sandbox: cleanup failed: ${formatSandboxError(err)}`); + }); +} + +export async function destroyOnFailure(destroyFn: () => Promise, err: unknown): Promise { + try { await destroyFn(); } catch { /* Preserve the original launch failure. */ } + throw err; +} diff --git a/packages/deploy/src/modes/sandbox.test.ts b/packages/deploy/src/modes/sandbox.test.ts index 2659d333..1c80b8ae 100644 --- a/packages/deploy/src/modes/sandbox.test.ts +++ b/packages/deploy/src/modes/sandbox.test.ts @@ -266,3 +266,20 @@ test('resolveSandboxClient with forceByo and no BYO env throws a clear error', ( } ); }); + +test('sandboxLauncher mapper migration preserves the mint label and env bytes', async () => { + const calls = await launchWithProxySandbox(input().persona); + const body = calls[0].body as { label: string; env: Record }; + assert.equal(body.label, 'wf-demo'); + assert.equal(JSON.stringify(body.env), JSON.stringify({ + WORKFORCE_AGENT_CONTEXT: JSON.stringify({ id: 'demo', deployedName: 'demo', spawnedByAgentId: null }), + WORKFORCE_DEPLOYMENT_CONTEXT: JSON.stringify({ id: 'demo', triggerKind: 'clock', parentDeploymentId: null }), + WORKFORCE_WORKSPACE_ID: 'ws-demo', + WORKFORCE_PERSONA_ID: 'demo', + })); +}); + +test('sandboxLauncher preserves Node handler support without an interactive harness', async () => { + const calls = await launchWithProxySandbox({ ...input().persona, harness: undefined, sandbox: false }); + assert.equal((calls[0].body as { label: string }).label, 'wf-demo'); +}); diff --git a/packages/deploy/src/modes/sandbox.ts b/packages/deploy/src/modes/sandbox.ts index 4f598bae..425d6042 100644 --- a/packages/deploy/src/modes/sandbox.ts +++ b/packages/deploy/src/modes/sandbox.ts @@ -1,3 +1,5 @@ +import { personaToSandboxParams, personaToSandboxContext } from '@agentworkforce/persona-kit'; +import { createIdempotentStop, destroyOnFailure, formatSandboxError, resolveSandboxAuthMode } from './sandbox-shared.js'; import type { ModeLaunchInput, ModeLaunchHandle, @@ -42,13 +44,21 @@ export const sandboxLauncher: ModeLauncher = { async launch(input: ModeLaunchInput): Promise { const client = resolveSandboxClient(input, input.byoSandbox ? { forceByo: true } : {}); const integrations = input.persona.integrations; + const context = { workspace: input.workspace, inputs: input.inputs }; + // This launcher executes a Node handler, not an interactive harness. Keep + // existing no-harness / sandbox:false handlers deployable; interactive + // eligibility is enforced only by personaToSandboxParams. + const params = input.persona.harness && input.persona.sandbox !== false + ? personaToSandboxParams(input.persona, context) + : personaToSandboxContext(input.persona, context); + const { WORKFORCE_WORKSPACE_ID, WORKFORCE_PERSONA_ID } = params.env; const handle = await client.mint({ - label: `wf-${input.persona.id}`, + label: params.label, env: { ...(input.env ?? {}), ...runtimeContextEnv(input.persona, input.env, input.agent), - WORKFORCE_WORKSPACE_ID: input.workspace, - WORKFORCE_PERSONA_ID: input.persona.id + WORKFORCE_WORKSPACE_ID, + WORKFORCE_PERSONA_ID }, ...(integrations && Object.keys(integrations).length > 0 ? { integrations } : {}) }); @@ -59,21 +69,14 @@ export const sandboxLauncher: ModeLauncher = { // If upload fails the sandbox is unrecoverable for this deploy. // Tear it down so we don't leak Daytona resources or charge for // an idle workforce-managed sandbox. - await client.destroy(handle).catch(() => undefined); - throw err; + return destroyOnFailure(() => client.destroy(handle), err); } let stopping = false; - const stop = async (): Promise => { - if (stopping) return; + const cleanup = createIdempotentStop(() => client.destroy(handle), input.io); + const stop = (): Promise => { stopping = true; - try { - await client.destroy(handle); - } catch (err) { - input.io.warn( - `sandbox: cleanup failed: ${err instanceof Error ? err.message : String(err)}` - ); - } + return cleanup(); }; const done = (async () => { @@ -87,7 +90,7 @@ export const sandboxLauncher: ModeLauncher = { } catch (err) { if (!stopping) { input.io.error( - `sandbox: runner exec failed: ${err instanceof Error ? err.message : String(err)}` + `sandbox: runner exec failed: ${formatSandboxError(err)}` ); } return { code: 1 }; @@ -120,14 +123,7 @@ export function resolveSandboxClient( const apiKey = process.env.DAYTONA_API_KEY?.trim(); const jwtToken = process.env.DAYTONA_JWT_TOKEN?.trim(); const organizationId = process.env.DAYTONA_ORGANIZATION_ID?.trim(); - const byoAvailable = Boolean(apiKey || jwtToken); - - if (overrides.forceByo || byoAvailable) { - if (!byoAvailable) { - throw new Error( - 'sandbox launcher: --byo-sandbox requested but no Daytona credentials are in env. Set DAYTONA_API_KEY (or DAYTONA_JWT_TOKEN + DAYTONA_ORGANIZATION_ID).' - ); - } + if (resolveSandboxAuthMode(input, overrides) === 'byo') { return createByoSandboxClient({ ...(apiKey ? { apiKey } : {}), ...(jwtToken ? { jwtToken } : {}), @@ -136,16 +132,11 @@ export function resolveSandboxClient( } const workspaceToken = input.workspaceToken?.trim() || process.env.WORKFORCE_WORKSPACE_TOKEN?.trim(); - if (!workspaceToken) { - throw new Error( - 'sandbox launcher: no Daytona credentials and no workforce workspace token. Either export DAYTONA_API_KEY, or run `workforce login` (sets WORKFORCE_WORKSPACE_TOKEN) so we can mint a workforce-managed sandbox.' - ); - } const cloudUrl = (input.cloudUrl?.trim() || process.env.WORKFORCE_CLOUD_URL?.trim() || DEFAULT_CLOUD_URL).replace(/\/$/, ''); return createProxySandboxClient({ cloudUrl, workspaceId: input.workspace, - workspaceToken, + workspaceToken: workspaceToken!, personaId: input.persona.id }); } diff --git a/packages/deploy/src/types.ts b/packages/deploy/src/types.ts index 4e6834cb..2f10a727 100644 --- a/packages/deploy/src/types.ts +++ b/packages/deploy/src/types.ts @@ -1,13 +1,14 @@ import type { AgentSpec, PersonaSpec } from '@agentworkforce/persona-kit'; import type { CompiledAgentV1 } from '@agentworkforce/runtime'; -export type DeployMode = 'dev' | 'sandbox' | 'cloud'; +export type DeployMode = 'local' | 'sandbox' | 'cloud'; +export type LegacyDeployMode = 'dev'; export interface DeployOptions { /** Path to the persona JSON file or authored persona source module. Required. */ personaPath: string; - /** Run mode. Defaults to `sandbox` if Daytona creds resolve, else `dev`. */ - mode?: DeployMode; + /** Run mode. Defaults to `sandbox` if Daytona creds resolve, else `local`. */ + mode?: DeployMode | LegacyDeployMode; /** Workforce workspace to deploy into. Defaults to the active workspace. */ workspace?: string; /** Skip the integration-connect prompts; fail if any declared integration is missing. */ @@ -23,7 +24,7 @@ export interface DeployOptions { /** * The caller drives the runner's envelope stdin itself via the returned * `ModeLaunchHandle.write()`, instead of the runner's stdin passing - * through this process's own stdin (`dev` mode only; ignored otherwise). + * through this process's own stdin (`local` mode only; ignored otherwise). * Set this when calling `deploy()` from a long-lived host process (e.g. a * fleet-node bridge) whose own stdin lifecycle must not end the runner's. */ @@ -86,7 +87,7 @@ export interface DeployResult { connectedIntegrations: string[]; /** Schedules registered with the runtime. */ schedules: string[]; - /** Run-mode-specific handle. `dev` returns a child process handle; `sandbox` a Daytona sandbox id; `cloud` a server-side deployment id. */ + /** Run-mode-specific handle. `local` returns a child process handle; `sandbox` a Daytona sandbox id; `cloud` a server-side deployment id. */ runHandle?: unknown; /** Non-fatal warnings collected during deploy. */ warnings: string[]; @@ -119,7 +120,7 @@ export interface BundleResult { /** * Contract each run-mode launcher implements. The defaults live next - * to this file: `modes/dev.ts` (local child_process), `modes/sandbox.ts` + * to this file: `modes/local.ts` (local child_process), `modes/sandbox.ts` * (Daytona), and `modes/cloud/index.ts` (workforce-cloud hosted, opt-in once * the cloud deployments endpoint ships). Callers swap individual modes * via `DeployResolvers.modes` — useful for tests and custom runtimes. @@ -143,7 +144,7 @@ export interface ModeLaunchInput { detach?: boolean; /** * The caller drives the runner's envelope stdin itself via the returned - * `ModeLaunchHandle.write()`. `dev` mode only — see `DeployOptions.bridged`; + * `ModeLaunchHandle.write()`. `local` mode only — see `DeployOptions.bridged`; * other modes ignore. */ bridged?: boolean; @@ -191,7 +192,7 @@ export interface ModeLaunchHandle { done: Promise<{ code: number }>; /** * Write a raw line directly to the runner's envelope stdin, bypassing the - * `process.stdin` passthrough. Only `dev` mode implements this today — it + * `process.stdin` passthrough. Only `local` mode implements this today — it * lets a long-lived host process (e.g. a fleet-node bridge) that owns the * `deploy()` call feed one `RawGatewayEnvelope` per message without a real * piped parent stdin. Absent when the mode has no addressable stdin. diff --git a/packages/local-surface/src/index.integration.test.ts b/packages/local-surface/src/index.integration.test.ts index d4a4ded2..988fe479 100644 --- a/packages/local-surface/src/index.integration.test.ts +++ b/packages/local-surface/src/index.integration.test.ts @@ -9,8 +9,8 @@ import { __setDeployForTest, defineWorkforcePersonaNode, type RunEventInput, typ /** * Real (non-mocked) regression coverage for the bug shadow-workforce-reviewer - * caught in PR review: `dev.ts`'s default `process.stdin.pipe(child.stdin)` - * passthrough is active for every `deploy({mode:'dev'})` call, including + * caught in PR review: `local.ts`'s default `process.stdin.pipe(child.stdin)` + * passthrough is active for every `deploy({mode:'local'})` call, including * ours. The fleet-node host process (`relay node up`) is meant to be * always-on/daemonized — the moment ITS OWN stdin ends (the normal case for * anything non-interactive), that passthrough would end the persona child's @@ -20,7 +20,7 @@ import { __setDeployForTest, defineWorkforcePersonaNode, type RunEventInput, typ * envelope after the first host-stdin EOF. * * This spawns a REAL persona child process through the REAL `deploy()` → - * `devLauncher` → `child_process.spawn()` pipeline (only the outer + * `localLauncher` → `child_process.spawn()` pipeline (only the outer * `deploy()` call is wrapped, transparently, to capture the real * `ModeLaunchHandle` for teardown — every envelope still travels through the * real `write()` → real `child.stdin.write()` path). It proves @@ -137,7 +137,7 @@ test( // Simulate the fleet-node host process's own stdin reaching EOF — the // normal case for a non-interactive/daemonized `relay node up`. This is - // the exact event `dev.ts`'s (now-skipped, because bridged:true) legacy + // the exact event `local.ts`'s (now-skipped, because bridged:true) legacy // passthrough listens for to end the child's stdin. process.stdin.emit('end'); @@ -164,10 +164,10 @@ test( ); // Regression guard for the underlying bug report, at the lower level: with -// `bridged` unset (the pre-existing CLI/legacy contract), dev.ts's stdin +// `bridged` unset (the pre-existing CLI/legacy contract), local.ts's stdin // passthrough IS attached, so `write()` must still work for a NORMAL // (non-EOF'd) parent stdin — this isn't a behavior change for existing -// `workforce deploy --mode dev` users piping envelopes via real stdin. +// `workforce deploy --mode local` users piping envelopes via real stdin. test( 'defineWorkforcePersonaNode always passes bridged:true so the legacy stdin passthrough never attaches for the bridge', async () => { @@ -176,7 +176,7 @@ test( seen.push({ bridged: opts.bridged }); return { deploymentId: 'demo', - mode: 'dev', + mode: 'local', workspace: 'ws', bundleDir: '/tmp', connectedIntegrations: [], diff --git a/packages/local-surface/src/index.test.ts b/packages/local-surface/src/index.test.ts index df5670d2..a0ac7067 100644 --- a/packages/local-surface/src/index.test.ts +++ b/packages/local-surface/src/index.test.ts @@ -131,7 +131,7 @@ test('run-event handler lazily launches the persona once and writes each event t let capturedToken: string | undefined; __setDeployForTest(async (opts, resolvers) => { deployCalls += 1; - assert.equal(opts.mode, 'dev'); + assert.equal(opts.mode, 'local'); assert.equal(opts.detach, true); assert.equal(opts.workspace, 'ws_1'); const resolved = await resolvers?.workspaceAuth?.resolveWorkspace({ io: undefined as never }); diff --git a/packages/local-surface/src/index.ts b/packages/local-surface/src/index.ts index 35a28334..244a804a 100644 --- a/packages/local-surface/src/index.ts +++ b/packages/local-surface/src/index.ts @@ -46,7 +46,7 @@ export interface WorkforcePersonaNodeConnection { } export interface DefineWorkforcePersonaNodeOptions { - /** Path to the persona JSON/source file to run in `--mode dev`. */ + /** Path to the persona JSON/source file to run in `--mode local`. */ personaPath: string; /** Relaycast channel the local-surface webhook consumer posts events into. */ channel: string; @@ -87,7 +87,7 @@ const RUN_EVENT_ACTION = 'run-event'; * `relay node up --config `. * * On the first matched message, lazily launches the persona via the existing - * `deploy()` orchestrator in `--mode dev --detach`, keeping the child process + * `deploy()` orchestrator in `--mode local --detach`, keeping the child process * alive across subsequent messages. Each message is mapped back into a * `RawGatewayEnvelope` (mirroring cloud's real gateway construction — see * `buildEnvelope`/`buildPayload` in @@ -147,7 +147,7 @@ export function defineWorkforcePersonaNode(options: DefineWorkforcePersonaNodeOp function writeEnvelope(runner: ModeLaunchHandle | undefined, envelope: RawGatewayEnvelope): void { if (!runner || typeof runner.write !== 'function') { throw new Error( - 'local-surface: persona runner has no writable stdin — expected `--mode dev` (the only mode `defineWorkforcePersonaNode` supports)' + 'local-surface: persona runner has no writable stdin — expected `--mode local` (the only mode `defineWorkforcePersonaNode` supports)' ); } runner.write(`${JSON.stringify(envelope)}\n`); @@ -162,7 +162,7 @@ async function launchPersonaRunner(input: { const result = await deployImpl( { personaPath, - mode: 'dev', + mode: 'local', detach: true, // The fleet-node host process (`relay node up`) is meant to be // always-on/daemonized; the moment ITS OWN stdin ends — the normal diff --git a/packages/persona-kit/src/__fixtures__/persona-to-sandbox-params.errors.json b/packages/persona-kit/src/__fixtures__/persona-to-sandbox-params.errors.json new file mode 100644 index 00000000..1e13fa4e --- /dev/null +++ b/packages/persona-kit/src/__fixtures__/persona-to-sandbox-params.errors.json @@ -0,0 +1,5 @@ +{ + "no-harness": "Persona \"demo\" cannot run in an interactive sandbox: no harness is configured.", + "sandbox-disabled": "Persona \"demo\" cannot run in an interactive sandbox: sandbox is disabled.", + "unsupported-harness": "Persona \"demo\" cannot run in an interactive sandbox: the harness is not supported in a sandbox." +} diff --git a/packages/persona-kit/src/index.ts b/packages/persona-kit/src/index.ts index 5719af61..c2e4b6b8 100644 --- a/packages/persona-kit/src/index.ts +++ b/packages/persona-kit/src/index.ts @@ -292,3 +292,5 @@ export { type PersonaSkillsHandle, type RunSkillInstallsOptions } from './skill-runner.js'; + +export { personaToSandboxParams, personaToSandboxContext, PersonaNotSandboxableError, type PersonaSandboxParams } from './persona-to-sandbox-params.js'; diff --git a/packages/persona-kit/src/persona-to-sandbox-params.test.ts b/packages/persona-kit/src/persona-to-sandbox-params.test.ts new file mode 100644 index 00000000..5a60f17e --- /dev/null +++ b/packages/persona-kit/src/persona-to-sandbox-params.test.ts @@ -0,0 +1,72 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync } from 'node:fs'; +import { personaToSandboxParams, PersonaNotSandboxableError } from './persona-to-sandbox-params.js'; +import type { PersonaSpec, PersonaMount } from './types.js'; + +const persona: PersonaSpec = { + id: 'demo', intent: 'documentation', description: '', tags: [], skills: [], + harness: 'claude', harnessSettings: { reasoning: 'medium', timeoutSeconds: 300 }, +}; +for (const [name, mount, expected] of [ + ['no mount', undefined, []], + ['one subtree', { ignoredPatterns: ['/*', '!web', '!web/**'] }, ['/web/**']], + ['sorted and deduplicated', { ignoredPatterns: ['/*', '!web', '!web/**', '!api', '!api/**', '!web/**'] }, ['/api/**', '/web/**']], + ['disabled', { enabled: false, ignoredPatterns: ['/*', '!web', '!web/**'] }, []], + ['ordinary ignore', { ignoredPatterns: ['node_modules', '*.log'] }, []], + ['mixed ignores', { ignoredPatterns: ['/*', '!web', '!web/**', 'web/private'] }, []], + ['missing parent', { ignoredPatterns: ['/*', '!web/**'] }, []], + ['nested subtree', { ignoredPatterns: ['/*', '!apps', '!apps/web', '!apps/web/**'] }, ['/apps/web/**']], +] as [string, PersonaMount | undefined, string[]][]) { + test(`sandbox params: ${name}`, () => { + const result = personaToSandboxParams({ ...persona, mount }, { workspace: 'ws' }); + assert.deepEqual(result.relayfilePaths, expected); + assert.deepEqual(result.readonlyPaths, []); + }); +} +test('sandbox params: readonly, inputs, integration env, permissions and determinism', () => { + const source = { + ...persona, + mount: { readonlyPatterns: ['docs/**'] }, + env: { Z: 'last', A: 'first' }, + permissions: { allow: ['Read'] }, + integrations: { github: { env: { GH_TOKEN: 'provided-token' } } }, + }; + const before = structuredClone(source); + const ctx = { workspace: 'ws', inputs: { TOPIC: 'x' } }; + const a = personaToSandboxParams(source, ctx); + const b = personaToSandboxParams(source, ctx); + assert.equal(JSON.stringify(a), JSON.stringify(b)); + assert.deepEqual(source, before); + assert.deepEqual(Object.keys(a.env), Object.keys(a.env).sort()); + assert.equal(a.env.WORKFORCE_INPUT_TOPIC, 'x'); + assert.equal(a.env.WORKFORCE_WORKSPACE_ID, 'ws'); + assert.equal(a.env.WORKFORCE_PERSONA_ID, 'demo'); + assert.equal(a.env.GH_TOKEN, 'provided-token'); + assert.equal(a.label, 'wf-demo'); + assert.equal(a.permissions, source.permissions); + assert.deepEqual(a.readonlyPaths, ['docs/**']); + assert.notEqual(a.readonlyPaths, source.mount.readonlyPatterns); +}); +test('sandbox params: deterministic error snapshots', () => { + const fixture = new URL('../src/__fixtures__/persona-to-sandbox-params.errors.json', import.meta.url); + const actual: Record = {}; + for (const [reason, overrides] of [ + ['no-harness', { harness: undefined }], + ['sandbox-disabled', { sandbox: false }], + ['unsupported-harness', { harness: 'unknown' as PersonaSpec['harness'] }], + ] as const) { + assert.throws(() => personaToSandboxParams({ ...persona, ...overrides }, { workspace: 'ws' }), err => { + assert.ok(err instanceof PersonaNotSandboxableError); + assert.equal(err.reason, reason); + actual[reason] = err.message; + return true; + }); + } + if (process.env.UPDATE_SANDBOX_SNAPSHOTS === '1') writeFileSync(fixture, JSON.stringify(actual, null, 2) + '\n'); + assert.deepEqual(actual, JSON.parse(readFileSync(fixture, 'utf8'))); +}); +test('sandbox params: source purity', () => { + const source = readFileSync(new URL('../src/persona-to-sandbox-params.ts', import.meta.url), 'utf8'); + assert.doesNotMatch(source, /node:fs|node:net|process\.env|\bDate\b/); +}); diff --git a/packages/persona-kit/src/persona-to-sandbox-params.ts b/packages/persona-kit/src/persona-to-sandbox-params.ts new file mode 100644 index 00000000..c7e23d79 --- /dev/null +++ b/packages/persona-kit/src/persona-to-sandbox-params.ts @@ -0,0 +1,89 @@ +import type { PersonaPermissions, PersonaSpec } from './types.js'; + +export interface PersonaSandboxParams { + cli: string; + relayfilePaths: string[]; + readonlyPaths: string[]; + env: Record; + permissions?: PersonaPermissions; + label: string; +} + +export class PersonaNotSandboxableError extends Error { + constructor( + readonly reason: 'no-harness' | 'sandbox-disabled' | 'unsupported-harness', + personaId: string, + ) { + const explanation = { + 'no-harness': 'no harness is configured', + 'sandbox-disabled': 'sandbox is disabled', + 'unsupported-harness': 'the harness is not supported in a sandbox', + }[reason]; + super(`Persona "${personaId}" cannot run in an interactive sandbox: ${explanation}.`); + this.name = 'PersonaNotSandboxableError'; + } +} + +/** Convert the explicit gitignore subtree allow-list idiom into Cloud paths. */ +function mountPaths(persona: PersonaSpec): string[] { + const mount = persona.mount; + if (!mount || mount.enabled === false) return []; + const patterns = mount.ignoredPatterns ?? []; + if (patterns[0] !== '/*' || patterns.slice(1).some(p => !p.startsWith('!'))) return []; + const paths = new Set(); + for (const pattern of patterns.slice(1)) { + const value = pattern.slice(1).replace(/^\//, ''); + if (!value.endsWith('/**')) continue; + const subtree = value.slice(0, -3); + if (!subtree || /[*?\[\]{}!]/.test(subtree) || subtree.split('/').some(p => !p || p === '.' || p === '..')) return []; + // Gitignore needs each excluded parent directory reopened before its contents. + const parts = subtree.split('/'); + if (!parts.every((_, i) => { + const parent = parts.slice(0, i + 1).join('/'); + return patterns.includes(`!${parent}`) || patterns.includes(`!/${parent}`) || patterns.includes(`!${parent}/`) || patterns.includes(`!/${parent}/`); + })) return []; + paths.add(`/${subtree}/**`); + } + return [...paths].sort(); +} + +export function personaToSandboxParams( + persona: PersonaSpec, + ctx: { workspace: string; inputs?: Record }, +): PersonaSandboxParams { + if (!persona.harness) throw new PersonaNotSandboxableError('no-harness', persona.id); + if (persona.sandbox === false) throw new PersonaNotSandboxableError('sandbox-disabled', persona.id); + if (!['claude', 'codex', 'gemini', 'grok', 'opencode'].includes(persona.harness)) { + throw new PersonaNotSandboxableError('unsupported-harness', persona.id); + } + return { + cli: persona.harness, + relayfilePaths: mountPaths(persona), + readonlyPaths: [...(persona.mount?.readonlyPatterns ?? [])], + ...personaToSandboxContext(persona, ctx), + ...(persona.permissions ? { permissions: persona.permissions } : {}), + }; +} + +/** @internal Shared identity/env for Node handler deployments, which need no CLI. */ +export function personaToSandboxContext( + persona: PersonaSpec, + ctx: { workspace: string; inputs?: Record }, +): Pick { + const env: Record = { ...persona.env }; + for (const key of Object.keys(persona.integrations ?? {}).sort()) { + const integrationEnv = persona.integrations?.[key]?.env; + if (integrationEnv && typeof integrationEnv === 'object' && !Array.isArray(integrationEnv)) { + for (const [name, value] of Object.entries(integrationEnv)) { + if (typeof value === 'string') env[name] = value; + } + } + } + for (const [key, value] of Object.entries(ctx.inputs ?? {})) env[`WORKFORCE_INPUT_${key}`] = value; + env.WORKFORCE_WORKSPACE_ID = ctx.workspace; + env.WORKFORCE_PERSONA_ID = persona.id; + return { + env: Object.fromEntries(Object.keys(env).sort().map(key => [key, env[key]])), + label: `wf-${persona.id}`, + }; +} diff --git a/packages/runtime/src/runner.ts b/packages/runtime/src/runner.ts index df200a37..65fb160f 100644 --- a/packages/runtime/src/runner.ts +++ b/packages/runtime/src/runner.ts @@ -59,7 +59,7 @@ export interface StartRunnerOptions { /** * Source of raw envelopes to dispatch. The default reads NDJSON from * stdin so a parent process can write `RawGatewayEnvelope` lines and - * read structured logs back on stdout — useful both in `--mode dev` and + * read structured logs back on stdout — useful both in `--mode local` and * inside a Daytona sandbox where stdin/stdout are the simplest contract. */ envelopes?: AsyncIterable; diff --git a/scripts/deploy-mode-rename.test.mjs b/scripts/deploy-mode-rename.test.mjs new file mode 100644 index 00000000..e23a74dc --- /dev/null +++ b/scripts/deploy-mode-rename.test.mjs @@ -0,0 +1,11 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; + +test('canonical deploy mode and launchers use local', () => { + const read = name => readFileSync(new URL(`../${name}`, import.meta.url), 'utf8'); + assert.match(read('packages/deploy/src/types.ts'), /export type DeployMode = 'local' \| 'sandbox' \| 'cloud';/); + for (const path of ['packages/cli/src/runtime-picker.ts', 'packages/local-surface/src/index.ts', 'packages/deploy/src/modes/local.ts']) { + assert.doesNotMatch(read(path), /['"]dev['"]|--mode dev/); + } +}); diff --git a/scripts/no-agent-relay-shell.test.mjs b/scripts/no-agent-relay-shell.test.mjs new file mode 100644 index 00000000..e4b36b98 --- /dev/null +++ b/scripts/no-agent-relay-shell.test.mjs @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = fileURLToPath(new URL('../', import.meta.url)); +const matcher = /\b(?:spawn|spawnSync|exec|execSync|execFile|execFileSync)\s*\(\s*(['"`])agent-relay\1/g; +function violations(source) { + return [...source.matchAll(matcher)].map(match => source.slice(0, match.index).split('\n').length); +} +function* files(dir) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (['dist', 'node_modules'].includes(entry.name)) continue; + const path = join(dir, entry.name); + if (entry.isDirectory()) yield* files(path); + else if (/\.(?:ts|mjs|js)$/.test(entry.name)) yield path; + } +} +test('no-agent-relay-shell matcher detects every process invocation variant', () => { + for (const fn of ['spawn', 'spawnSync', 'exec', 'execSync', 'execFile', 'execFileSync']) { + for (const quote of ["'", '"', '`']) assert.deepEqual(violations(`${fn}(${quote}agent-relay${quote}, ['fleet'])`), [1]); + } + assert.deepEqual(violations("spawn('node', ['agent-relay.js'])"), []); +}); +test('workforce packages compose Relay SDK rather than invoking its CLI', () => { + const found = []; + for (const file of files(join(root, 'packages'))) { + for (const line of violations(readFileSync(file, 'utf8'))) found.push(`${relative(root, file)}:${line}`); + } + assert.deepEqual(found, [], `Forbidden agent-relay process invocation:\n${found.join('\n')}`); +});