diff --git a/.workflow-artifacts/release-propagation-279/iteration-1/codex-shadow-review.md b/.workflow-artifacts/release-propagation-279/iteration-1/codex-shadow-review.md new file mode 100644 index 00000000..f9b45322 --- /dev/null +++ b/.workflow-artifacts/release-propagation-279/iteration-1/codex-shadow-review.md @@ -0,0 +1,92 @@ +# Codex adversarial shadow review — workforce #279 + +VERDICT: FINDINGS + +## Review state + +Final adversarial review completed against draft PR #280 head `3a1fc1eae2572f2c240ba20e4848318e581595d3`. The principal implementation is correctly wired and its normal, duplicate-version, pnpm, valid-workspace-symlink, transport, and runtime-log paths are proven. One fail-closed edge remains incorrect and prevents comprehensive satisfaction. + +## Root cause established from the baseline + +`packages/deploy/src/bundle.ts` asks esbuild to bundle the persona handler and externalizes only Node builtins plus `@agentworkforce/runtime` and `@agentworkforce/runtime/raw`. Consequently, third-party and transitive package source that reaches the output is frozen into `agent.bundle.mjs`. The staged `package.json`, however, is built independently from esbuild's resolved graph and contains only the separately installed external runtime version. Neither `BundleResult`, the generated runner, sandbox upload, cloud request, `runner.started`, nor the deployments-list JSON surface carries an inventory of the package code actually inlined into the artifact. + +This creates two distinct facts that the baseline conflates or omits: + +1. `dependencies.@agentworkforce/runtime` describes code intentionally external to `agent.bundle.mjs` and installed at execution time. +2. Bundled-package attribution must describe package-owned inputs that contributed bytes to the actual esbuild output, including transitives and possibly multiple versions of the same package name. + +An author dependency range or the repository lockfile alone cannot prove either fact for an already-staged artifact. Restarting the runner cannot change inlined bytes, and publishing a newer upstream dependency cannot propagate without a new resolution and bundle. + +## Risky assumptions to disprove + +- **Requested/imported is not actually bundled.** Metafile inputs that are fully tree-shaken, external imports, or packages used only by the external runtime must not be reported as inlined code. Evidence should be tied to the output contribution (`bytesInOutput > 0` or an equally strong graph fact), not to `package.json` declarations. +- **Direct is not complete.** The manifest must include transitive package inputs that contribute code, not only the persona's direct dependencies or a hard-coded `@relayfile/*` allowlist. +- **One name does not imply one version.** Nested dependency graphs can bundle two versions of the same package. A name-to-single-version object risks false attribution or last-write-wins nondeterminism; the schema and logs must preserve distinct `(name, version)` identities. +- **Filesystem path is not operator evidence.** pnpm's content-addressed `.pnpm` layout, symlinked workspaces, nested `node_modules`, scoped packages, conditional exports, and realpath-vs-symlink paths must resolve to the package manifest that owns each bundled input. Absolute source paths, pnpm store paths, user names, temp roots, and symlink targets must never enter generated metadata or logs. +- **Nearest manifest is not automatically the owner.** An upward package.json search must stop at defensible package boundaries and verify a valid name/version; it must not accidentally attribute persona-local source to the workspace root, a parent application, or a neighboring package. +- **Workspace version can overclaim provenance.** A workspace package's `package.json` version may label unpublished or locally modified source. If workspace packages are inventoried, the field must remain explicitly a resolved package version, not a claim that the bytes equal the published npm artifact. +- **Missing metadata must not become a lie.** Package-owned bundled code whose manifest is absent, unreadable, invalid, or has no name/version needs a deterministic, tested policy (fail closed or explicit unresolved evidence). Silently omitting it while describing the list as complete is unsafe. +- **Object traversal order is not a contract.** Manifest and log ordering must be explicitly sorted and stable across identical builds, platforms, symlink layouts, and repeated runs. +- **An auxiliary file can go stale or disappear.** The stager deliberately leaves auxiliary files from prior runs untouched; sandbox upload currently names exactly four files; cloud upload serializes only runner, agent, and package JSON. Any sibling manifest must be overwritten, represented in `BundleResult`, uploaded in every mode, and protected from stale residue. Embedding metadata in the already-uploaded package JSON avoids some, but not all, stale-artifact risks. +- **Runtime logging must describe this artifact.** `runner.started` must receive immutable metadata from the staged artifact/runner, not re-resolve the current filesystem or read the author project at cold start. It must remain structured, deterministic, bounded, and free of paths, environment values, tokens, source-map contents, or other secrets. +- **Presence in a staged file is not fleet queryability.** `--bundle-out` makes local package JSON queryable, but deployed fleet queryability requires cloud persistence/response wiring or another supported JSON surface. If cloud changes are outside this repository/iteration, that limitation must be explicit rather than implied complete. +- **Unit mocks can erase the defect.** Tests that replace `BundleStager`, feed a hand-written package JSON, or assert only a mocked cloud POST do not prove the real esbuild graph. Required proof must stage a real bundle from a realistic installed/package layout, inspect the emitted JS and metadata together, exercise transitive and workspace/symlink cases, and show the runtime's actual `runner.started` record. + +## Deterministic baseline evidence + +- Review base and HEAD before implementation: `533b9669bcad1f06e31c8fa6644f7228e12fc510`; `git diff origin/main...HEAD` was empty. +- Issue #279 states that a deployed artifact inlines `@relayfile/relay-helpers` and `@relayfile/adapter-core`, while generated package JSON pins only `@agentworkforce/runtime`. +- Baseline `packages/deploy/src/bundle.ts` calls esbuild without `metafile: true`, writes only `agent.bundle.mjs`, `runner.mjs`, `persona.json`, and `package.json`, and resolves only the external runtime version. +- Baseline `packages/runtime/src/runner.ts` logs `runner.started` with persona, workspace, schedules, triggers, and integrations only. +- Baseline sandbox upload enumerates exactly those four staged files. Baseline cloud upload includes runner source, agent source, and parsed package JSON; deployments-list parsing exposes no bundle inventory. +- Lockfile evidence illustrates why graph attribution matters: the workspace contains both `@relayfile/adapter-core@0.5.1` and transitive `@relayfile/adapter-core@0.5.6`; a single version inferred from a direct declaration would be false for some bundle graphs. + +## End-to-end wiring assessment + +The main path is end-to-end wired: + +1. esbuild emits a metafile and the stager selects only input entries with `bytesInOutput > 0` for `agent.bundle.mjs`. +2. Valid package owners become a deterministic array of distinct `(name, version)` pairs. Deduplication uses the full pair, so `@relayfile/adapter-core@0.5.1` and `@relayfile/adapter-core@0.5.6` coexist. +3. The generated `package.json` retains the exact external `@agentworkforce/runtime` pin and embeds the path-free `bundleManifest` as the sole persisted source of truth. +4. The generated runner reads that exact object from its adjacent package JSON and passes it to `startRunner`; `runner.started` emits it unchanged. +5. Sandbox transport uploads the package JSON and cloud transport places the parsed package JSON under `bundle.packageJson`. Local operators can query a staged artifact with `jq .bundleManifest /package.json`. + +Fleet-wide `deployments list --json` queryability still depends on cloud persistence/response work and is explicitly not claimed. That limitation is acceptable under the iteration's “artifact or supported JSON surface” acceptance wording, but it remains an operational follow-up. + +The missing-metadata workspace-symlink path breaks the otherwise correct build step: logical dependency identity is erased by `realpath()` before ownership is determined, allowing bundled dependency bytes to be silently omitted from the manifest. + +## Findings + +### WF279-SHADOW-001 — workspace symlink without its own package metadata is silently omitted + +- Severity: HIGH +- File: `packages/deploy/src/bundle.ts` (`findPackageOwnership`, `findPackageOwnershipFromDirectory`, and the `entryOwner` exclusion in `buildBundleManifest`) +- Issue: Ownership discovery immediately realpaths each esbuild input. For a logical `node_modules/missing-workspace` symlink whose target is inside the author project and lacks `package.json`, the search walks from the real target into the author project's package manifest. That owner equals `entryOwner`, so the contributing dependency input is skipped. The bundle contains the dependency bytes while `bundleManifest.packages` is empty. A target beneath a different ancestor package can analogously be falsely attributed to that ancestor. This contradicts the implementation report's fail-closed claim and acceptance focus #5. +- Deterministic reproduction: Create an author project with `{name:"author-project",version:"1.0.0"}`, symlink `node_modules/missing-workspace` to `packages/missing-workspace`, omit a package manifest at the target, import its exported marker from the agent, and stage with the real built `bundleStager`. Observed output at pushed head: `{"stage":"SUCCEEDED","manifest":{"schemaVersion":1,"packages":[]},"bundleContainsMarker":true,"leaksPath":false}`. +- Exact fix: Preserve the logical esbuild input path alongside its realpath. Detect and retain the logical `node_modules` package boundary before dereferencing symlinks; require that dependency boundary/target to supply its own valid package name and version, and never walk above that package root into the consumer or an unrelated ancestor. Continue using real roots for deduplication/cache identity and keep all failure messages path-free. +- Required test: Add a real `bundleStager`/esbuild regression with an author-project package manifest and a `node_modules` workspace symlink to an in-project target that has code but no `package.json`. Assert staging rejects, the error contains a stable inferred dependency label but no temp/absolute path, and the same fixture succeeds with the correct `(name, version)` only after a valid target manifest is added. Add the analogous protection against attribution to a different valid ancestor package. + +## Deterministic final evidence + +- Exact review target: local `HEAD`, branch remote, and PR #280 `headRefOid` all resolved to `3a1fc1eae2572f2c240ba20e4848318e581595d3`; base was `533b9669bcad1f06e31c8fa6644f7228e12fc510`. +- Scope diff contains only the two iteration artifacts, README/changelogs, deploy bundle/tests/transport tests, and runtime runner/tests. The reviewer artifact is the only additional untracked file. +- `pnpm --filter @agentworkforce/deploy test`: 230 passed, 0 failed. These include real esbuild fixtures for output-only attribution, unused declaration exclusion, deterministic sorting, valid workspace and pnpm symlinks, versionless metadata rejection, and simultaneous `@relayfile/adapter-core@0.5.1` plus `@0.5.6` representation. +- `node --test packages/runtime/dist/runner.test.js`: 13 passed, 0 failed. +- `git diff --check`: passed. +- Real repository graph probe using `packages/delivery/src/index.ts`: emitted `@relayfile/adapter-core@0.5.6`, `@relayfile/adapter-linear@0.4.6`, `@relayfile/relay-helpers@0.4.7`, and `@relayfile/sdk@0.7.40`; retained runtime `4.1.26`; left no external `@relayfile/*` imports; serialized no working-directory path. +- Actual generated-runner probe: staged a real `shadow-probe@7.8.9` package, executed generated `runner.mjs`, observed exit 0, and confirmed byte-for-byte JSON parity between `package.json.bundleManifest` and the structured `runner.started.bundleManifest`; stdout/artifact metadata contained no temp root. +- Adversarial missing-metadata workspace-symlink probe reproduced WF279-SHADOW-001 exactly as documented above. +- PR #280 remained draft. CI `check` was in progress at review time. CodeRabbit's draft-skip notice is informational only and provides no substantive review evidence. + +## Scope-matrix status + +- `workforce` #279: draft PR #280 delivered at `3a1fc1e`; main wiring is complete, but WF279-SHADOW-001 requires repair and regression proof. +- `relayfile-adapters` #248: deferred dependency-only context; no changes authorized in this iteration. +- `cloud` #2678: deferred; no changes authorized in this iteration. + +## Remaining risks + +- Until WF279-SHADOW-001 is fixed, a workspace-linked dependency with absent metadata can ship bundled bytes with no corresponding manifest entry or can inherit an unrelated ancestor identity. +- The manifest records a workspace package's declared resolved version, not proof that locally modified bytes match the published artifact; README correctly states this limitation. +- Fleet-wide version queries remain unavailable until the cloud service persists and returns `bundle.packageJson.bundleManifest`; this workforce iteration provides artifact and startup-log queryability only. +- CI completion was not yet available at the review timestamp and must be rechecked before any readiness decision. diff --git a/.workflow-artifacts/release-propagation-279/iteration-1/implementation.md b/.workflow-artifacts/release-propagation-279/iteration-1/implementation.md new file mode 100644 index 00000000..e7a2700c --- /dev/null +++ b/.workflow-artifacts/release-propagation-279/iteration-1/implementation.md @@ -0,0 +1,136 @@ +# Implementation: workforce #279 + +## Outcome + +- `bundleStager` requests an esbuild metafile and derives the manifest from the + inputs that contributed bytes to `agent.bundle.mjs`, rather than from declared + dependencies. +- The generated `package.json` retains the exact `@agentworkforce/runtime` pin + and adds a versioned, deterministic `bundleManifest` containing only package + names and exact versions. +- Manifest entries are deduplicated by the full `(name, version)` pair and + sorted by name then version, so two installed versions of the same package + remain visible. +- Package ownership follows real paths, covering workspace and pnpm symlinks. + A bundled dependency with missing, malformed, or versionless package metadata + fails staging with a path-free error instead of producing an incomplete + manifest. +- The generated runner reads that same manifest from `package.json`, passes it + unchanged to `startRunner`, and the runtime emits it in structured + `runner.started` evidence. +- Existing cloud and sandbox transports already carry `package.json`; regression + assertions now prove the manifest survives both the hosted bundle request and + the sandbox file upload without a second source of truth. +- No secret, absolute path, pnpm store path, or other machine-specific locator is + serialized into the manifest. + +## Changed files + +- `README.md` — documents the operator-queryable artifact and runtime signal. +- `packages/deploy/CHANGELOG.md` — records the deploy artifact change. +- `packages/deploy/src/bundle.ts` — derives, writes, and wires the exact bundle + manifest while preserving the runtime pin. +- `packages/deploy/src/bundle.test.ts` — real esbuild/staging regressions for + actual inputs, deterministic ordering, duplicate package versions, + workspace/pnpm symlinks, absent metadata, runtime pin preservation, and path + redaction. +- `packages/deploy/src/modes/cloud.test.ts` — proves the hosted deployment JSON + carries the manifest unchanged. +- `packages/deploy/src/modes/sandbox-client.test.ts` — proves sandbox upload + carries the manifest unchanged. +- `packages/runtime/CHANGELOG.md` — records the startup evidence change. +- `packages/runtime/src/runner.ts` — adds the typed manifest input and emits it + on `runner.started`. +- `packages/runtime/src/runner.test.ts` — asserts exact structured startup + evidence. +- `.workflow-artifacts/release-propagation-279/iteration-1/preflight.md` — input + contract supplied for this iteration. +- `.workflow-artifacts/release-propagation-279/iteration-1/implementation.md` — + this implementation and verification record. + +## Red-first evidence + +After lockfile installation and prerequisite workspace builds, these commands +failed on the new regressions before implementation: + +```bash +pnpm --filter @agentworkforce/runtime test +``` + +- Expected red: TypeScript rejected the new `bundleManifest` runner option. + +```bash +pnpm --filter @agentworkforce/deploy test +``` + +- Expected red: the generated runner did not read/pass the manifest and the + staged `package.json` had no `bundleManifest`. + +## Verification commands + +```bash +pnpm install --frozen-lockfile +pnpm --filter @agentworkforce/events build +pnpm --filter @agentworkforce/persona-kit build +pnpm --filter @agentworkforce/deploy test +node --test packages/runtime/dist/runner.test.js +node --test packages/runtime/dist/clients/index.test.js packages/runtime/dist/cloud-defaults.test.js packages/runtime/dist/cloud-llm.test.js packages/runtime/dist/cron.test.js packages/runtime/dist/ctx.test.js packages/runtime/dist/define-agent.test.js packages/runtime/dist/harness-process.test.js packages/runtime/dist/proactive.test.js packages/runtime/dist/relay-mcp.test.js packages/runtime/dist/relay.test.js packages/runtime/dist/run-contracts.test.js packages/runtime/dist/runner.test.js packages/runtime/dist/shim.contract.test.js packages/runtime/dist/shim.test.js packages/runtime/dist/simulate/simulate.test.js packages/runtime/dist/to-agent-event.test.js packages/runtime/dist/trajectory.test.js +pnpm run build +pnpm run lint +pnpm run typecheck +git diff --check +``` + +Results: + +- Deploy package suite: 230 passed, 0 failed. +- Runtime runner suite: 13 passed, 0 failed. +- All runtime suites executable on this host, excluding the unrelated + permission-boundary file: 120 passed, 0 failed. +- Workspace build: passed. +- Workspace lint: passed. +- Workspace typecheck, including examples: passed. +- Diff whitespace validation: passed. + +The unfiltered runtime command was also run: + +```bash +pnpm --filter @agentworkforce/runtime test +``` + +It compiled successfully and reported 121 passing tests plus 13 failures, all +in `local-preview.test.js`. Those tests explicitly require the patched Node +permission API at Node >=26.3.1; this worktree host is Node 25.8.1. No failing +test touched bundle manifest or runner behavior. + +## Real installed graph probe + +The built stager was also exercised against the repository's real +`@agentworkforce/delivery` source graph. It produced this path-free manifest, +confirming resolution through installed pnpm inputs rather than fixture +declarations: + +```json +{"schemaVersion":1,"packages":[{"name":"@relayfile/adapter-core","version":"0.5.6"},{"name":"@relayfile/adapter-linear","version":"0.4.6"},{"name":"@relayfile/relay-helpers","version":"0.4.7"},{"name":"@relayfile/sdk","version":"0.7.40"}]} +``` + +## Safety + +- No merge, publish, release, deployment, production mutation, or rollback was + performed. +- External writes are limited to the issue branch push and draft PR described + below after all local gates are green. + +## Remaining limitation + +This repository can guarantee the staged/package artifact, sandbox upload, +hosted deploy request, and runtime startup log. Fleet-wide querying through +`deployments list --json` additionally requires the cloud service to persist and +return `bundle.packageJson.bundleManifest`; that optional cross-repository API +work is not claimed by this iteration. + +## Delivery + +- Branch: `codex/issue-279-bundle-versions` +- Implementation commit: `790ee7b` (`feat(deploy): record bundled package versions`) +- Draft PR: https://github.com/AgentWorkforce/workforce/pull/280 diff --git a/.workflow-artifacts/release-propagation-279/iteration-1/preflight.md b/.workflow-artifacts/release-propagation-279/iteration-1/preflight.md new file mode 100644 index 00000000..cb9775c2 --- /dev/null +++ b/.workflow-artifacts/release-propagation-279/iteration-1/preflight.md @@ -0,0 +1,33 @@ +# Preflight: workforce #279 + +- Repository: `/Users/khaliqgant/Projects/AgentWorkforce/.worktrees/workforce-279` +- Base: `origin/main` at `533b966` +- Branch: `codex/issue-279-bundle-versions` +- Issue: `AgentWorkforce/workforce#279` +- Declared write scope: workforce source/tests/docs and this workflow artifact directory only. +- External writes allowed: commit, push, PR creation, PR comments, CI/review repair. +- External writes prohibited by contract: merge, npm publish/release, deployment, production mutation, rollback. +- Codex probe: `codex exec --ephemeral --json --sandbox read-only -m gpt-5.4` returned `OK`. +- Claude CLI: present at `/opt/homebrew/bin/claude`; supervisor is read-only unless explicitly assigned a finding fix. +- Agent Relay broker: healthy at kickoff. + +## Acceptance focus + +1. Record resolved versions for packages actually bundled into `agent.bundle.mjs`, especially `@relayfile/*`. +2. Keep the generated runtime dependency pin intact. +3. Surface the same manifest at runtime in structured `runner.started` evidence without secrets or nondeterministic paths. +4. Provide an operator-queryable artifact or supported JSON surface for determining deployed versions. +5. Prove recorded versions match the bundle inputs, including workspace/symlink and missing-package edge cases. +6. Do not publish or deploy during this granted phase. + +## Cross-repository scope matrix + +| repo | branch | issue | surface | expected change | status | owner | +| --- | --- | --- | --- | --- | --- | --- | +| workforce | codex/issue-279-bundle-versions | #279 | deploy bundle/build metadata/runtime observability | record and surface exact bundled versions | implementing | Workforce279CodexImpl | +| relayfile-adapters | pending | #248 | relay helpers create receipt semantics | dependency-only for this iteration | deferred until G2 | future squad | +| cloud | pending | #2678 | update-time watch glob derivation/backfill | intentionally deferred until G2/G3 | deferred | future squad | + +## Reviewer verdict contract + +Reviewers must use `VERDICT: COMPREHENSIVELY_SATISFIED | FINDINGS | BLOCKED` and include deterministic evidence, end-to-end wiring assessment, scope-matrix status, and remaining risks. Findings require stable ids, severity, file, exact fix, and required test. diff --git a/.workflow-artifacts/release-propagation-279/iteration-1/review-fix-report.md b/.workflow-artifacts/release-propagation-279/iteration-1/review-fix-report.md new file mode 100644 index 00000000..9270b4f9 --- /dev/null +++ b/.workflow-artifacts/release-propagation-279/iteration-1/review-fix-report.md @@ -0,0 +1,104 @@ +# Review fix report: workforce #279 iteration 1 + +## Scope + +- Finding repaired: `WF279-SHADOW-001` only. +- Branch: `codex/issue-279-bundle-versions`. +- Draft PR: #280. +- Starting repair head: `3a1fc1eae2572f2c240ba20e4848318e581595d3`. +- No ready-for-review transition, merge, publish, release, or deployment was performed. + +## Real-esbuild reproduction + +A red regression was added before the implementation change. Its author project +has a valid `package.json`, imports `missing-workspace` through a real +`node_modules/missing-workspace` symlink, and points that symlink at an +in-project `packages/missing-workspace/index.js` containing a bundled marker but +no target `package.json`. + +The first run of: + +```bash +pnpm --filter @agentworkforce/deploy test +``` + +reproduced the pushed-head defect with real esbuild: both new staging assertions +reported `Missing expected rejection`. The suite result was 230 passed and 2 +failed. The first fixture therefore staged bundled marker bytes without failing +closed; the analogous fixture also accepted a metadata-less target below a +different valid ancestor package. + +The completed regression now proves all of the following through the real +`bundleStager` and esbuild: + +- a metadata-less in-project workspace target rejects with only the stable + logical package name; +- malformed target JSON rejects with the same path-free package-only error; +- neither the author project path, symlink target path, nor unrelated ancestor + label appears in the error; +- an unrelated valid ancestor is never accepted as the dependency owner; +- adding a valid target manifest produces exactly + `missing-workspace@2.4.6`, and the output still contains the marker bytes; +- a versionless dependency removed from the actual output remains omitted + rather than becoming an eagerly reported package. + +## Repair + +`packages/deploy/src/bundle.ts` now traces bare-package resolution during the +normal esbuild build. This retains each logical `node_modules` package name and +its canonical real package root without enabling esbuild's preserve-symlinks +mode, so pnpm's realpath-based transitive resolution behavior is unchanged. + +Manifest ownership checks use the longest matching traced real root and read +metadata only at that package root. Dependency ownership never walks upward +from a symlink target into the consumer or another ancestor. Missing, +malformed, nameless, or versionless contributing package metadata fails closed +with a deterministic package-name-only error. Malformed metadata that prevents +esbuild resolution is normalized through the same path-free error surface. + +The existing metafile `bytesInOutput > 0` filter, full `(name, version)` +deduplication, deterministic sorting, runtime pin, embedded manifest transport, +and runner wiring are unchanged. + +## Changed files + +- `packages/deploy/src/bundle.ts` — retain logical dependency boundaries across + esbuild realpath resolution and anchor metadata at the dependency root. +- `packages/deploy/src/bundle.test.ts` — add real-esbuild missing/malformed + workspace-target, unrelated-ancestor, valid-recovery, and tree-shaken control + regressions. +- `.workflow-artifacts/release-propagation-279/iteration-1/codex-shadow-review.md` + — adversarial review input supplied for this repair. +- `.workflow-artifacts/release-propagation-279/iteration-1/review-fix-report.md` + — this reproduction and verification record. + +## Exact verification evidence + +```text +pnpm --filter @agentworkforce/deploy test +tests 233; pass 233; fail 0 + +pnpm run build +19 of 20 workspace projects; passed + +pnpm run lint +19 of 20 workspace projects; passed + +pnpm run typecheck +19 of 20 workspace projects plus examples/tsconfig.json; passed + +git diff --check +passed +``` + +The built stager was also exercised against the repository's real +`packages/delivery/src/delivery.ts` installed graph after the repair. It emitted +the same deterministic, path-free manifest as the original implementation: + +```json +{"schemaVersion":1,"packages":[{"name":"@relayfile/adapter-core","version":"0.5.6"},{"name":"@relayfile/adapter-linear","version":"0.4.6"},{"name":"@relayfile/relay-helpers","version":"0.4.7"},{"name":"@relayfile/sdk","version":"0.7.40"}]} +``` + +This preserves duplicate-version, workspace/pnpm symlink, deterministic-order, +path-redaction, cloud/sandbox transport, and runner-manifest parity coverage in +the passing deploy suite. diff --git a/.workflow-artifacts/release-propagation-279/iteration-2/codex-review.md b/.workflow-artifacts/release-propagation-279/iteration-2/codex-review.md new file mode 100644 index 00000000..8ac48b2f --- /dev/null +++ b/.workflow-artifacts/release-propagation-279/iteration-2/codex-review.md @@ -0,0 +1,70 @@ +# Codex adversarial review — workforce #279, iteration 2 + +VERDICT: FINDINGS + +## Review target and scope + +- Exact reviewed SHA: `af1bca99bf06b488f5c1b6f3098b1c9264426dea`. +- Draft PR: workforce #280, `codex/issue-279-bundle-versions` into `main`. +- Issue: workforce #279. +- Local `HEAD`, `origin/codex/issue-279-bundle-versions`, and PR `headRefOid` all matched the reviewed SHA. Base was `533b9669bcad1f06e31c8fa6644f7228e12fc510`. +- The worktree was clean before review. No repository `AGENTS.md` exists. +- PR remained open and draft. CI `check` was complete/successful at the reviewed head; CodeRabbit's status was successful but its draft-skip comment contained no substantive review. +- Review was read-only except for this required artifact. No source edit, commit, push, PR-state change, merge, publish, deploy, or production mutation was performed. + +## Fresh implementation assessment + +The iteration-1 symlink repair is real and correct for the defect it targets. `traceLogicalDependencyRoots` observes bare-package resolution during the same esbuild build, retains logical names plus canonical real package roots, and `buildBundleManifest` selects only metafile inputs with `bytesInOutput > 0`. Ownership is anchored at the traced package root, so a metadata-less workspace symlink can no longer walk upward into the author project or an unrelated ancestor. + +The normal end-to-end path is also correctly wired: + +1. The manifest is derived from the actual `agent.bundle.mjs` output contribution map, not author dependency declarations. +2. Distinct `(name, version)` pairs are deduplicated by the full pair and sorted deterministically, so duplicate installed versions survive. +3. The exact external `@agentworkforce/runtime` pin remains under `dependencies`; `bundleManifest` is embedded in the same generated `package.json`, so there is no sibling manifest that can independently go stale. +4. Sandbox upload includes that exact `package.json`; cloud launch parses the same file into `bundle.packageJson`. +5. Generated `runner.mjs` reads the adjacent `package.json` and passes the object unchanged to `startRunner`; `runner.started` emits it as structured evidence. + +The new repair tests are meaningful, non-mocked filesystem/esbuild tests. They create real `node_modules` symlinks, bundle marker bytes, assert missing/malformed target metadata rejects without paths, prevent unrelated-ancestor attribution, recover after adding valid metadata, and retain a tree-shaken control. `git show af1bca9^..af1bca9` confirms those regressions and the ownership repair were added together; the prior report records the expected 230-pass/2-fail red run, while the current exact-head run is independently green. + +## Finding + +### WF279-CODEX-ITER2-001 — semantic-invalid package metadata is accepted and can serialize absolute/store paths + +- Severity: HIGH +- Files: `packages/deploy/src/bundle.ts:403` and `packages/deploy/src/bundle.ts:413`; duplicated fallback validation at `packages/deploy/src/bundle.ts:434` and `packages/deploy/src/bundle.ts:440`. +- Contract contradicted: `README.md:194` promises exact resolved package versions and `README.md:196` promises no build-machine paths. +- Cause: both metadata readers accept any nonempty string as `name` and `version`. They do not validate an npm package name or an exact semantic version. Therefore malformed values such as ranges, workspace/file locators, or absolute pnpm-store paths are treated as valid provenance and are copied verbatim into the artifact and then `runner.started`. +- Real-esbuild reproduction at the reviewed SHA: a package imported as `bad-meta` had `{ "name": "bad-meta", "version": "/Users/reviewer/.pnpm/store/v3/files/secret" }` and contributed a marker to the actual bundle. Staging returned: + + ```json + {"stage":"SUCCEEDED","manifest":{"schemaVersion":1,"packages":[{"name":"bad-meta","version":"/Users/reviewer/.pnpm/store/v3/files/secret"}]},"leakedAbsoluteStorePath":true} + ``` + +- Impact: the manifest can cease to be an exact-version inventory and can leak a build-machine/store path through generated `package.json`, cloud/sandbox transport, and structured startup logs. This directly misses the requested fail-closed and path-free boundary. +- Required fix: validate package metadata semantically before accepting it. Require a valid npm package name and a valid exact semver (including legitimate prerelease/build metadata, excluding ranges and file/workspace/path locators). On failure, reject with the stable logical dependency name from the resolution boundary only; never echo the invalid metadata value or a filesystem path. Apply the same policy to both metadata-reading paths. +- Required regression: use the real `bundleStager`/esbuild path with contributing packages whose `name` and `version` are nonempty but invalid, including an absolute/pnpm-store-like version. Assert staging rejects with a package-name-only error and that neither the invalid value nor any temp/store path appears. Include a tree-shaken invalid-metadata control if semantic validation remains output-contribution-gated. + +## Commands and deterministic evidence + +- `git status --short --untracked-files=all; git rev-parse HEAD` — clean; exact SHA matched. +- `gh pr view 280 --json ...` and `gh issue view 279 --json ...` — reviewed PR body, files, comments, reviews, checks, and issue contract; PR head matched; CI green. +- `git diff origin/main...HEAD`, `git show af1bca9`, and numbered source inspection — reviewed the full implementation, repair, tests, runtime wiring, and transports. +- `pnpm --filter @agentworkforce/deploy test` — 233 passed, 0 failed. This includes real esbuild coverage for actual-output attribution, workspace/pnpm symlinks, repaired missing/malformed metadata boundaries, unrelated ancestors, tree shaking, duplicate versions, cloud transport, and sandbox transport. +- Real installed-graph probe against `packages/delivery/src/delivery.ts` — manifest was exactly `@relayfile/adapter-core@0.5.6`, `@relayfile/adapter-linear@0.4.6`, `@relayfile/relay-helpers@0.4.7`, and `@relayfile/sdk@0.7.40`; two stages produced byte-identical generated package JSON; no checkout, temp, `/.pnpm/`, or `node_modules/.pnpm` string appeared in it. +- Generated-runner probe with a real bundled `runner-probe@7.8.9` — runner exit 0; `package.json.bundleManifest` and structured `runner.started.bundleManifest` were JSON-identical; neither package JSON nor stdout contained the temp root. +- Semantic-invalid metadata probe — reproduced WF279-CODEX-ITER2-001 exactly as quoted above. +- `pnpm --filter @agentworkforce/runtime exec tsc -p tsconfig.json; node --test packages/runtime/dist/runner.test.js` — compiled; 13 passed, 0 failed. +- `git diff --check` — passed. +- Final `git status --short --untracked-files=all; git rev-parse HEAD; gh pr view 280 --json headRefOid,isDraft,state,statusCheckRollup` — source tree remained unchanged, SHA still exact, PR still draft/open, CI still green. + +## Residual notes + +- The repaired workspace-symlink ownership boundary passed both its focused real-esbuild regressions and a real installed pnpm graph. +- Manifest selection is tied to bundled output bytes rather than declarations; tree-shaken versionless metadata remains omitted as intended. +- Duplicate name/version identities, deterministic ordering, runtime pin preservation, cloud/sandbox package JSON transport, and generated-runner startup parity all passed. +- Embedding the manifest in generated `package.json` avoids an auxiliary-file synchronization problem; repository search found no second bundle-manifest artifact or transport source of truth. +- Fleet-wide `deployments list --json` queryability still depends on cloud persistence/response behavior and is not claimed by this PR. + +## Final verdict + +FINDINGS diff --git a/.workflow-artifacts/release-propagation-279/iteration-2/fix-report.md b/.workflow-artifacts/release-propagation-279/iteration-2/fix-report.md new file mode 100644 index 00000000..b86a7162 --- /dev/null +++ b/.workflow-artifacts/release-propagation-279/iteration-2/fix-report.md @@ -0,0 +1,144 @@ +# Review fix report: workforce #279 iteration 2 + +## Scope + +- Finding repaired: `WF279-CODEX-ITER2-001` only. +- Starting repair head: `af1bca99bf06b488f5c1b6f3098b1c9264426dea`. +- Branch: `codex/issue-279-bundle-versions`; draft PR: #280. +- The iteration-2 review verdict artifact was read but not modified. +- No ready-for-review transition, merge, publish, release, deployment, or + production mutation was performed. + +## Red evidence + +A real `bundleStager`/esbuild regression was added before the source repair. Its +contributing package is resolved through +`node_modules/@relayfile/path-poison`, and its `package.json` contains an +absolute pnpm-store-like path in `version`. The package exports marker bytes +that are retained in `agent.bundle.mjs`. + +Exact command at the reviewed head plus only the new regression: + +```bash +pnpm --filter @agentworkforce/deploy exec tsc -p tsconfig.json && node --test --test-name-pattern='absolute store path' packages/deploy/dist/bundle.test.js +``` + +Observed result before the implementation change: + +```text +tests 1; pass 0; fail 1 +AssertionError [ERR_ASSERTION]: Missing expected rejection. +``` + +This proves the real stager accepted the unsafe non-version metadata before the +repair. + +## Repair + +Both package-metadata ownership paths now use one semantic policy: + +- `validate-npm-package-name` requires a name valid for new npm packages; +- `node-semver` parses the version, after which the parsed identifiers are + reconstructed and compared with the original string to require canonical, + exact SemVer rather than a range or compatibility spelling; +- valid prerelease and build metadata such as + `2.3.4-rc.1+build.20260717` is retained verbatim; +- arbitrary strings, ranges, workspace/file/npm locators, URLs, POSIX and + Windows path forms, surrounding whitespace, control characters, and invalid + prerelease/build identifiers are rejected; +- the rejection uses the stable logical dependency name and metadata field + only. The invalid value and filesystem path are never interpolated. + +The implementation leaves the prior logical-root/symlink ownership trace +unchanged. Output contribution remains gated by esbuild's +`bytesInOutput > 0`, so invalid metadata in a fully tree-shaken dependency is +not reported. Deduplication still keys the full `(name, version)` pair, and the +existing duplicate-version, workspace symlink, and pnpm graph regressions are +unchanged and passing. + +The absolute-path regression additionally proves that rejected metadata does +not produce a generated `package.json` or `runner.mjs`, and that the emitted +bundle itself contains neither the unsafe value nor the temp root. Therefore +the unsafe value cannot enter `bundleManifest` or `runner.started` telemetry. + +## Green regression evidence + +Exact focused command after the repair: + +```bash +pnpm --filter @agentworkforce/deploy exec tsc -p tsconfig.json && node --test --test-name-pattern='absolute store path|non-version package metadata|invalid metadata name|valid metadata for a dependency removed|actual bundled package versions' packages/deploy/dist/bundle.test.js +``` + +Observed result: + +```text +tests 5; pass 5; fail 0 +``` + +The focused tests cover the absolute/store path, the invalid-version matrix, +the logical-name-only error, the second filesystem ownership path, valid +prerelease/build metadata, and a tree-shaken invalid-metadata control. + +## Full verification evidence + +```bash +pnpm --filter @agentworkforce/deploy test +``` + +```text +tests 236; pass 236; fail 0 +``` + +This includes the existing real-esbuild workspace/pnpm symlink tests and the +distinct `@relayfile/adapter-core@0.5.1` plus `@0.5.6` regression. + +```bash +pnpm --filter @agentworkforce/runtime exec tsc -p tsconfig.json && node --test packages/runtime/dist/runner.test.js +``` + +```text +tests 13; pass 13; fail 0 +``` + +The runner suite includes structured `runner.started.bundleManifest` evidence. + +```bash +pnpm run build +pnpm run lint +pnpm run typecheck +git diff --check +``` + +```text +build: passed for 19 of 20 workspace projects +lint: passed for 19 of 20 workspace projects +typecheck: passed for 19 of 20 workspace projects and examples/tsconfig.json +git diff --check: passed +``` + +## Real installed relay graph probe + +The built stager was exercised twice against the repository's actual +`packages/delivery/src/delivery.ts` entry and installed pnpm dependency graph. +The exact command was: + +```bash +node --input-type=module -e 'import { mkdtemp, readFile, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { bundleStager } from "./packages/deploy/dist/bundle.js"; const root = await mkdtemp(path.join(os.tmpdir(), "wf279-installed-graph-")); try { const entry = path.resolve("packages/delivery/src/delivery.ts"); const persona = { id: "installed-relay-graph-probe", intent: "probe", tags: [], description: "probe", skills: [], harness: "claude", model: "anthropic/claude-3-5-sonnet", systemPrompt: "probe", harnessSettings: { reasoning: "medium", timeoutSeconds: 300 }, cloud: true, onEvent: entry }; const first = await bundleStager.stage({ personaPath: path.join(root, "persona.json"), persona, outDir: path.join(root, "first") }); const second = await bundleStager.stage({ personaPath: path.join(root, "persona.json"), persona, outDir: path.join(root, "second") }); const firstSource = await readFile(first.packageJsonPath, "utf8"); const secondSource = await readFile(second.packageJsonPath, "utf8"); const manifest = JSON.parse(firstSource).bundleManifest; const expected = [{ name: "@relayfile/adapter-core", version: "0.5.6" }, { name: "@relayfile/adapter-linear", version: "0.4.6" }, { name: "@relayfile/relay-helpers", version: "0.4.7" }, { name: "@relayfile/sdk", version: "0.7.40" }]; if (JSON.stringify(manifest.packages) !== JSON.stringify(expected)) throw new Error(`unexpected manifest: ${JSON.stringify(manifest)}`); const forbidden = [process.cwd(), root, "/.pnpm/", "node_modules/.pnpm"]; if (firstSource !== secondSource) throw new Error("generated package JSON is nondeterministic"); if (forbidden.some((value) => firstSource.includes(value))) throw new Error("generated package JSON leaked a filesystem path"); console.log(JSON.stringify({ manifest, deterministicPackageJson: true, pathFree: true })); } finally { await rm(root, { recursive: true, force: true }); }' +``` + +Observed manifest: + +```json +{"schemaVersion":1,"packages":[{"name":"@relayfile/adapter-core","version":"0.5.6"},{"name":"@relayfile/adapter-linear","version":"0.4.6"},{"name":"@relayfile/relay-helpers","version":"0.4.7"},{"name":"@relayfile/sdk","version":"0.7.40"}]} +``` + +The two generated package JSON files were byte-identical and contained no +checkout root, temp root, `/.pnpm/`, or `node_modules/.pnpm` string. + +## Changed files + +- `packages/deploy/src/bundle.ts` +- `packages/deploy/src/bundle.test.ts` +- `packages/deploy/package.json` +- `pnpm-lock.yaml` +- `.workflow-artifacts/release-propagation-279/iteration-2/fix-report.md` diff --git a/.workflow-artifacts/release-propagation-279/iteration-4/codex-independent-review.md b/.workflow-artifacts/release-propagation-279/iteration-4/codex-independent-review.md new file mode 100644 index 00000000..c92bafe6 --- /dev/null +++ b/.workflow-artifacts/release-propagation-279/iteration-4/codex-independent-review.md @@ -0,0 +1,221 @@ +# Independent final review: workforce #279 / PR #280 + +## Verdict + +**FINDINGS** + +Reviewed exact head: + +```text +76d4a7f75c1b0b99453ce7e891bea9c3dfd2f84f +``` + +This review is independent of the earlier Codex PASS artifact. I did not use +that verdict as evidence. No source, commit, PR state, deployment, publication, +or production state was changed. The only repository write from this review is +this iteration-4 artifact. The pre-existing untracked iteration-3 review was +left untouched. + +No `AGENTS.md` exists in this repository checkout or its AgentWorkforce parent, +so there was no repository-local instruction file to apply. + +## Severity-ranked findings + +### HIGH — WF279-CODEX-INDEPENDENT-001 + +**Consumer-project metadata is validated before the consumer owner is excluded, +so legitimate private author projects can no longer be deployed.** + +Location: `packages/deploy/src/bundle.ts:296` (ordering relative to the consumer +root exclusion at `packages/deploy/src/bundle.ts:304`). + +`buildBundleManifest` intentionally computes `entryOwner` so the author/consumer +package is not reported as a bundled dependency. However, each contributing +owner's `invalidMetadata` is thrown at lines 296-303 before line 304 skips the +owner whose root equals `entryOwner.root`. The entry file itself is a +contributing esbuild input, so an author `package.json` with a missing version, +a private/workspace-style version, or a noncanonical name rejects the whole +stage even though that package must not enter `bundleManifest`. + +Independent real-esbuild reproduction used a valid contributing +`safe-dep@1.2.3` and changed only the consumer project metadata: + +```json +{ + "missingVersion": { + "status": "error", + "message": "bundle: bundled package \"private-author-app\" has no valid \"version\" metadata" + }, + "noncanonicalVersion": { + "status": "error", + "message": "bundle: bundled package \"private-author-app\" has no valid \"version\" metadata" + }, + "invalidName": { + "status": "error", + "message": "bundle: bundled dependency package has no valid \"name\" metadata" + }, + "noAuthorPackageJsonControl": { + "status": "ok", + "manifest": { + "schemaVersion": 1, + "packages": [{ "name": "safe-dep", "version": "1.2.3" }] + } + } +} +``` + +The missing-version case is valid and common for private, unpublished Node +applications. This change therefore introduces a deploy denial unrelated to +the safety of any bundled dependency. It is release-blocking compatibility +risk in the core staging path. + +Recommended repair: exclude `owner.root === entryOwner?.root` before evaluating +that owner's invalid metadata, while retaining fail-closed validation for all +other contributing owners. Add a real-esbuild regression whose consumer has a +private/versionless or otherwise non-package metadata file and whose valid +dependency still produces exactly `safe-dep@1.2.3`. Re-run the metadata-less +workspace regression to prove the target still cannot climb to consumer +metadata. + +## Independent validation evidence + +### Reviewed contract and current PR state + +- Read issue #279, the full `main...HEAD` diff, PR body, commits, files, checks, + review summaries, issue comments, and inline review comments. +- Read the iteration-1 implementation and repair report and the iteration-2 fix + report as implementation claims to verify, not as signoff evidence. +- PR head and local `HEAD` both equal the exact SHA above. +- GitHub CI `check` is successful; CodeRabbit's status is successful but its + review was skipped because the PR is draft. There are no inline review + comments. The Gemini summary targets the older `3a1fc1e` head and was not used + as evidence. +- GitHub currently reports `mergeable=false`, `mergeable_state=dirty`, even + though its reported base `533b9669bcad1f06e31c8fa6644f7228e12fc510` + equals local `main`, is the merge-base, and is a direct ancestor of the + reviewed head. That external status inconsistency was recorded but is not the + source-code finding above. + +### Previously repaired HIGH class: metadata-less workspace target + +Code inspection confirmed that bare-package resolution records the canonical +real package root and that `findDependencyPackageOwnership` reads metadata only +at that root. It does not walk from a metadata-less symlink target to consumer +or unrelated ancestor metadata. Errors use the logical package name only. + +The current real-esbuild focused run passed the workspace missing/malformed +metadata, unrelated ancestor, valid recovery, tree-shaken control, pnpm/workspace +symlink, duplicate-version, and unsafe metadata cases: + +```text +tests 9; pass 9; fail 0 +``` + +The fixture's dependency bytes are retained in `agent.bundle.mjs`, so this is +not an eager declaration scan or a synthetic metafile test. + +### Previously repaired HIGH class: unsafe/noncanonical metadata + +Both metadata ownership readers call the same `isValidPackageName` and +`isExactPackageVersion` predicates. The former requires a name valid for new +npm packages. The latter parses with node-semver and reconstructs the canonical +major/minor/patch, prerelease, and build spelling before exact string comparison. +There is no semantic divergence between the two readers' validation policy. + +An independent real-esbuild matrix exercised both logical `node_modules` +ownership and relative/direct filesystem ownership. It rejected 52 cases in +total, covering POSIX/Windows absolute and store paths, URLs, `npm:`, `file:`, +and `workspace:` protocols, ranges, wildcard forms, leading `v`/`=`, leading or +trailing whitespace, control characters, invalid names/scopes/casing, leading +zeroes, and invalid prerelease identifiers. Every rejection used the stable +logical name/field or generic dependency label, not the unsafe value or temp +root: + +```json +{"ownershipPaths":2,"rejected":52,"canonicalAccepted":2} +``` + +Both paths accepted the canonical scoped name `@scope/canonical-name` with the +exact version `2.3.4-rc.1+build.20260717` unchanged. + +The readers do have the compatibility problem described in the HIGH finding: +their shared strict policy is incorrectly applied to the consumer entry owner +before that owner is excluded. + +### Attribution, duplicates, symlinks, determinism, and path freedom + +- `buildBundleManifest` selects only metafile inputs with + `bytesInOutput > 0`; the tree-shaken invalid-metadata control passes. +- Deduplication keys the complete `(name, version)` pair, preserving both + `@relayfile/adapter-core@0.5.1` and `@0.5.6`; deterministic sorting is by name + then version. +- Workspace and pnpm symlink fixtures pass through real esbuild resolution. +- An independent two-run probe against the installed + `packages/delivery/src/delivery.ts` graph retained actual delivery input bytes + and produced byte-identical generated package JSON with no checkout root, + temp root, `/.pnpm/`, or `node_modules/.pnpm` string: + +```json +{ + "schemaVersion": 1, + "packages": [ + { "name": "@relayfile/adapter-core", "version": "0.5.6" }, + { "name": "@relayfile/adapter-linear", "version": "0.4.6" }, + { "name": "@relayfile/relay-helpers", "version": "0.4.7" }, + { "name": "@relayfile/sdk", "version": "0.7.40" } + ] +} +``` + +### Artifact transport and runner parity + +- The staged generated `package.json` keeps the exact installed + `@agentworkforce/runtime` dependency and embeds `bundleManifest`. +- Cloud transport reads that same file and sends the parsed object as + `bundle.packageJson` (`packages/deploy/src/modes/cloud/index.ts:185`). +- Both direct and proxy sandbox transport upload that exact file as + `/home/daytona/bundle/package.json` + (`packages/deploy/src/modes/sandbox-client.ts:269-277`). +- The generated runner reads `packageJson.bundleManifest` and passes the same + object to `startRunner`; runtime adds that object unchanged to the structured + `runner.started` attributes (`packages/runtime/src/runner.ts:152-159`). +- Cloud transport, sandbox transport, generated-runner wiring, and exact + `runner.started` parity assertions all pass in the focused/full suites. + +## Gates rerun on the exact head + +Host: Node `v25.8.1`, pnpm `10.17.1`. + +```text +pnpm --filter @agentworkforce/deploy test +tests 236; pass 236; fail 0 + +pnpm --filter @agentworkforce/runtime exec tsc -p tsconfig.json && +node --test packages/runtime/dist/runner.test.js +tests 13; pass 13; fail 0 + +pnpm run build +passed (19 of 20 workspace projects) + +pnpm run lint +passed (19 of 20 workspace projects) + +pnpm run typecheck +passed (19 of 20 workspace projects plus examples) + +git diff --check +passed +``` + +The green existing suites do not cover invalid metadata on the consumer entry +owner, which is why they do not catch WF279-CODEX-INDEPENDENT-001. + +## Final signoff + +**FINDINGS** at `76d4a7f75c1b0b99453ce7e891bea9c3dfd2f84f`. + +The two previously repaired HIGH safety classes validate successfully, as do +the requested graph attribution, version/name validation, duplicate, symlink, +determinism, transport, runtime parity, and workspace gates. The new HIGH +consumer-metadata compatibility regression must be repaired and independently +revalidated before final PASS. diff --git a/.workflow-artifacts/release-propagation-279/iteration-4/fix-report.md b/.workflow-artifacts/release-propagation-279/iteration-4/fix-report.md new file mode 100644 index 00000000..d2dc38b1 --- /dev/null +++ b/.workflow-artifacts/release-propagation-279/iteration-4/fix-report.md @@ -0,0 +1,145 @@ +# Review fix report: workforce #279 iteration 4 + +## Scope + +- Finding repaired: `WF279-CODEX-INDEPENDENT-001` only. +- Starting repair head: `76d4a7f75c1b0b99453ce7e891bea9c3dfd2f84f`. +- Branch: `codex/issue-279-bundle-versions`; draft PR: #280. +- The iteration-4 independent review was read but not modified. No repository + `AGENTS.md` exists in this checkout or its AgentWorkforce parent. +- No ready-for-review transition, merge, publish, release, deployment, or + production mutation was performed. + +## Red evidence + +A real `bundleStager`/esbuild regression was added before the source repair. It +creates nested private consumer projects beneath a valid workspace root. Each +entry imports `safe-dep@1.2.3`, whose marker bytes are retained in +`agent.bundle.mjs`; the expected manifest contains only that dependency. + +Exact red command at the starting head plus only the new regression: + +```bash +pnpm --filter @agentworkforce/deploy exec tsc -p tsconfig.json && node --test --test-name-pattern='private consumer' packages/deploy/dist/bundle.test.js +``` + +Observed before the implementation change: + +```text +tests 1; pass 0; fail 1 +Error: bundle: bundled package "private-author-app" has no valid "version" metadata +``` + +The metadata-free nested-consumer control completed before the failing case. +The failure occurred on the next case, whose private consumer package had a +valid name but no version. This demonstrates that the consumer entry owner was +being validated as if it were a bundled dependency. + +## Repair + +`buildBundleManifest` still resolves the entry owner and every contributing +input through the existing canonical ownership machinery. During manifest +assembly it now compares an input owner's canonical root with the entry owner's +root before applying dependency metadata validation. The entry/consumer owner +is excluded from the dependency manifest without requiring publishable package +metadata; all other contributing owners continue through the same fail-closed +name/version validation. + +The repair does not add package-name or path-string inference. The earlier +logical dependency trace remains unchanged: bare `node_modules` and workspace +symlink resolutions retain a stable logical name plus canonical real package +root, and dependency metadata is read only at that root. Therefore a +contributing metadata-less or malformed symlink target still rejects and +cannot climb to the consumer or an unrelated ancestor. + +The new real-esbuild coverage proves: + +- nested consumers with no package JSON, a missing version, `workspace:*`, or + an invalid name all stage successfully when only `safe-dep@1.2.3` + contributes dependency bytes; +- the consumer never appears in `bundleManifest`; +- the valid dependency's marker is present in the emitted bundle; +- generated package JSON contains no fixture path; +- a supported `onEvent` file outside the private consumer package is owned by + its actual containing package root and still attributes `safe-dep@1.2.3`; +- existing metadata-less/malformed workspace targets, unrelated-ancestor, + unsafe name/version, tree-shaken, duplicate-version, workspace/pnpm symlink, + deterministic-order, and error-sanitization regressions remain green. + +## Focused green evidence + +```bash +pnpm --filter @agentworkforce/deploy exec tsc -p tsconfig.json && node --test --test-name-pattern='private consumer|workspace symlink whose target lacks|unrelated ancestor|version metadata is absent|absolute store path|non-version package metadata|invalid metadata name|removed from the real output|distinct versions|deterministic order|workspace and pnpm' packages/deploy/dist/bundle.test.js +``` + +```text +tests 11; pass 11; fail 0 +``` + +The external-entry regression was also run directly with the repaired consumer +test: + +```text +tests 2; pass 2; fail 0 +``` + +## Full verification evidence + +Before final delivery, current `origin/main` at +`e1f60c4c653b13c75e255f3d9e43a9ec08386bad` was merged into the draft branch +so GitHub could construct a conflict-free pull-request merge ref. The gates +below are the final post-integration results. + +```bash +pnpm --filter @agentworkforce/deploy test +``` + +```text +tests 242; pass 242; fail 0 +``` + +This is the full deploy suite, including cloud and sandbox artifact transport. + +```bash +pnpm --filter @agentworkforce/runtime exec tsc -p tsconfig.json && node --test packages/runtime/dist/runner.test.js +``` + +```text +tests 14; pass 14; fail 0 +``` + +The runner suite includes exact structured +`runner.started.bundleManifest` parity. + +```bash +pnpm run build +pnpm run lint +pnpm run typecheck +git diff --check +``` + +```text +build: passed for 20 of 21 workspace projects +lint: passed for 20 of 21 workspace projects +typecheck: passed for 20 of 21 workspace projects and examples/tsconfig.json +git diff --check: passed +``` + +## Real installed relay graph probe + +The built stager was exercised twice against the repository's actual +`packages/delivery/src/delivery.ts` entry and installed pnpm graph. Both stages +generated byte-identical package JSON with no checkout root, temp root, +`/.pnpm/`, or `node_modules/.pnpm` string. The exact manifest was: + +```json +{"schemaVersion":1,"packages":[{"name":"@relayfile/adapter-core","version":"0.5.6"},{"name":"@relayfile/adapter-linear","version":"0.4.6"},{"name":"@relayfile/relay-helpers","version":"0.4.7"},{"name":"@relayfile/sdk","version":"0.7.40"}]} +``` + +## Changed files + +- `.workflow-artifacts/release-propagation-279/iteration-4/codex-independent-review.md` + — supplied review input, added unchanged. +- `packages/deploy/src/bundle.ts` +- `packages/deploy/src/bundle.test.ts` +- `.workflow-artifacts/release-propagation-279/iteration-4/fix-report.md` diff --git a/README.md b/README.md index d0ec48f4..c99f1fc1 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,16 @@ See [`examples/review-agent`](./examples/review-agent/) for a complete example. You can also use `--bundle-out ` to stage the bundle without launching it, or `--dry-run` to validate schema, triggers, and integration readiness. +Every staged `package.json` includes a `bundleManifest` with the exact resolved +`package.json` versions whose dependency inputs contributed code to +`agent.bundle.mjs`. The list is deterministically ordered, contains no +build-machine paths, and preserves multiple installed versions of the same +package. Operators can inspect it with `jq .bundleManifest +/package.json`; the runtime emits the same JSON object once in the +structured `runner.started` log. A workspace version identifies the resolved +workspace package; it does not claim the local bytes equal a published npm +artifact. + ## Integrations supported Deploy v1 targets the Tier-1 Relayfile providers: diff --git a/packages/deploy/CHANGELOG.md b/packages/deploy/CHANGELOG.md index 53126b7e..edc652b7 100644 --- a/packages/deploy/CHANGELOG.md +++ b/packages/deploy/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Record exact versions for package inputs bundled into `agent.bundle.mjs` in + a deterministic, path-free `package.json` manifest (#279). + ## [4.1.26] - 2026-07-16 ### Fixed diff --git a/packages/deploy/package.json b/packages/deploy/package.json index 5a66e545..0b431fed 100644 --- a/packages/deploy/package.json +++ b/packages/deploy/package.json @@ -36,6 +36,12 @@ "@agentworkforce/persona-kit": "workspace:*", "@agentworkforce/runtime": "workspace:*", "@daytonaio/sdk": "^0.185.0", - "esbuild": "^0.25.0" + "esbuild": "^0.25.0", + "semver": "^7.8.5", + "validate-npm-package-name": "^5.0.1" + }, + "devDependencies": { + "@types/semver": "^7.7.1", + "@types/validate-npm-package-name": "^4.0.2" } } diff --git a/packages/deploy/src/bundle.test.ts b/packages/deploy/src/bundle.test.ts index 5cf0a8a6..acd063d1 100644 --- a/packages/deploy/src/bundle.test.ts +++ b/packages/deploy/src/bundle.test.ts @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import path from 'node:path'; import os from 'node:os'; @@ -78,7 +78,11 @@ test('bundleStager produces an executable, importable bundle from a real onEvent assert.match(runnerSource, /agentSpec = projectAgentSpec\(exported\)/); assert.match(runnerSource, /delete spec\.handler/); assert.match(runnerSource, /Object\.keys\(persona\)/); - assert.match(runnerSource, /await startRunner\({ persona, agent, deployment, handler/); + assert.match(runnerSource, /packageJson\.bundleManifest/); + assert.match( + runnerSource, + /await startRunner\({ persona, agent, deployment, handler, bundleManifest, \.\.\.\(agentSpec/ + ); // bundle output is ES module shape and references the runtime as external const bundleSource = await readFile(result.bundlePath, 'utf8'); @@ -88,7 +92,8 @@ test('bundleStager produces an executable, importable bundle from a real onEvent // package.json pins the exact installed runtime version — never a // wildcard a sandbox's npm install could silently satisfy with a // stale pre-baked/cached copy. - const generatedPackageJson = JSON.parse(await readFile(result.packageJsonPath, 'utf8')); + const generatedPackageJsonSource = await readFile(result.packageJsonPath, 'utf8'); + const generatedPackageJson = JSON.parse(generatedPackageJsonSource); const runtimeDep = generatedPackageJson.dependencies['@agentworkforce/runtime']; const installedRuntimePackageJsonPath = require.resolve('@agentworkforce/runtime/package.json'); const installedRuntimeVersion = JSON.parse( @@ -96,6 +101,756 @@ test('bundleStager produces an executable, importable bundle from a real onEvent ).version; assert.equal(runtimeDep, installedRuntimeVersion); assert.notEqual(runtimeDep, '*'); + assert.deepEqual(generatedPackageJson.bundleManifest, { schemaVersion: 1, packages: [] }); + assert.equal(generatedPackageJsonSource.includes(dir), false, 'artifact metadata must not leak build paths'); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('bundleStager records only actual bundled package versions in deterministic order', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-bundle-manifest-')); + try { + const personaPath = path.join(dir, 'persona.json'); + const personaSpec = persona(); + await writeFile(personaPath, JSON.stringify(personaSpec, null, 2), 'utf8'); + + await writePackage(dir, '@relayfile/relay-helpers', '0.4.9', 'export const relayVersion = "0.4.9";\n'); + await writePackage( + dir, + 'alpha-bundled', + '2.3.4-rc.1+build.20260717', + 'export const alpha = "alpha";\n' + ); + await writePackage(dir, 'unused-declaration', '9.9.9', 'export const unused = true;\n'); + await writeFile( + path.join(dir, 'agent.ts'), + [ + "import { relayVersion } from '@relayfile/relay-helpers';", + "import { alpha } from 'alpha-bundled';", + 'export default async function handler() {', + ' return `${relayVersion}:${alpha}`;', + '}', + '' + ].join('\n'), + 'utf8' + ); + + const result = await bundleStager.stage({ + personaPath, + persona: personaSpec, + outDir: path.join(dir, 'build') + }); + const generatedPackageJsonSource = await readFile(result.packageJsonPath, 'utf8'); + const generatedPackageJson = JSON.parse(generatedPackageJsonSource); + + assert.deepEqual(generatedPackageJson.bundleManifest, { + schemaVersion: 1, + packages: [ + { name: '@relayfile/relay-helpers', version: '0.4.9' }, + { name: 'alpha-bundled', version: '2.3.4-rc.1+build.20260717' } + ] + }); + assert.equal(generatedPackageJsonSource.includes(dir), false, 'manifest must not leak build paths'); + assert.equal(generatedPackageJson.dependencies['@agentworkforce/runtime'], resolveInstalledRuntimeVersion()); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('bundleStager excludes a private consumer with invalid metadata before validating bundled dependencies', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-bundle-private-consumer-')); + try { + await writeFile( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'workspace-root', version: '1.0.0', private: true }, null, 2), + 'utf8' + ); + + const consumerMetadataCases: Array<{ label: string; packageJson?: string }> = [ + { label: 'missing-package-json' }, + { + label: 'missing-version', + packageJson: JSON.stringify({ name: 'private-author-app', private: true }, null, 2) + }, + { + label: 'noncanonical-version', + packageJson: JSON.stringify( + { name: 'private-author-app', version: 'workspace:*', private: true }, + null, + 2 + ) + }, + { + label: 'invalid-name', + packageJson: JSON.stringify( + { name: `../private/${path.basename(dir)}`, version: '1.0.0', private: true }, + null, + 2 + ) + } + ]; + + for (const metadataCase of consumerMetadataCases) { + const consumerRoot = path.join(dir, 'apps', metadataCase.label); + await mkdir(consumerRoot, { recursive: true }); + if (metadataCase.packageJson !== undefined) { + await writeFile(path.join(consumerRoot, 'package.json'), metadataCase.packageJson, 'utf8'); + } + + const personaPath = path.join(consumerRoot, 'persona.json'); + const personaSpec = persona(); + await writeFile(personaPath, JSON.stringify(personaSpec, null, 2), 'utf8'); + await writePackage( + consumerRoot, + 'safe-dep', + '1.2.3', + 'export const safeMarker = "retained-safe-dependency-marker";\n' + ); + await writeFile( + path.join(consumerRoot, 'agent.ts'), + "import { safeMarker } from 'safe-dep'; export default () => safeMarker;\n", + 'utf8' + ); + + const result = await bundleStager.stage({ + personaPath, + persona: personaSpec, + outDir: path.join(consumerRoot, 'build') + }); + const generatedPackageJsonSource = await readFile(result.packageJsonPath, 'utf8'); + const generatedPackageJson = JSON.parse(generatedPackageJsonSource); + + assert.deepEqual( + generatedPackageJson.bundleManifest, + { schemaVersion: 1, packages: [{ name: 'safe-dep', version: '1.2.3' }] }, + metadataCase.label + ); + assert.match( + await readFile(result.bundlePath, 'utf8'), + /retained-safe-dependency-marker/, + metadataCase.label + ); + assert.equal( + generatedPackageJsonSource.includes(dir), + false, + `${metadataCase.label}: manifest must not leak build paths` + ); + } + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('bundleStager stages an entry outside the private consumer package without attributing consumer metadata', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-bundle-external-entry-')); + try { + await writeFile( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'workspace-root', version: '1.0.0', private: true }, null, 2), + 'utf8' + ); + await writePackage( + dir, + 'safe-dep', + '1.2.3', + 'export const safeMarker = "external-entry-safe-dependency-marker";\n' + ); + + const consumerRoot = path.join(dir, 'apps', 'private-author'); + const sharedRoot = path.join(dir, 'shared'); + await mkdir(consumerRoot, { recursive: true }); + await mkdir(sharedRoot, { recursive: true }); + await writeFile( + path.join(consumerRoot, 'package.json'), + JSON.stringify({ name: 'private-author-app', version: 'workspace:*', private: true }, null, 2), + 'utf8' + ); + await writeFile( + path.join(sharedRoot, 'agent.ts'), + "import { safeMarker } from 'safe-dep'; export default () => safeMarker;\n", + 'utf8' + ); + + const personaPath = path.join(consumerRoot, 'persona.json'); + const personaSpec = persona({ onEvent: '../../shared/agent.ts' }); + await writeFile(personaPath, JSON.stringify(personaSpec, null, 2), 'utf8'); + const result = await bundleStager.stage({ + personaPath, + persona: personaSpec, + outDir: path.join(consumerRoot, 'build') + }); + const generatedPackageJsonSource = await readFile(result.packageJsonPath, 'utf8'); + + assert.deepEqual(JSON.parse(generatedPackageJsonSource).bundleManifest, { + schemaVersion: 1, + packages: [{ name: 'safe-dep', version: '1.2.3' }] + }); + assert.match(await readFile(result.bundlePath, 'utf8'), /external-entry-safe-dependency-marker/); + assert.equal(generatedPackageJsonSource.includes(dir), false); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('bundleStager records valid legacy package names from installed artifacts', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-bundle-legacy-name-')); + try { + const personaPath = path.join(dir, 'persona.json'); + const personaSpec = persona(); + await writeFile(personaPath, JSON.stringify(personaSpec, null, 2), 'utf8'); + await writePackage( + dir, + 'JSONStream', + '1.3.5', + 'export const legacyPackageMarker = "JSONStream";\n' + ); + await writeFile( + path.join(dir, 'agent.ts'), + "import { legacyPackageMarker } from 'JSONStream'; export default () => legacyPackageMarker;\n", + 'utf8' + ); + + const result = await bundleStager.stage({ + personaPath, + persona: personaSpec, + outDir: path.join(dir, 'build') + }); + const generatedPackageJson = JSON.parse(await readFile(result.packageJsonPath, 'utf8')); + + assert.deepEqual(generatedPackageJson.bundleManifest, { + schemaVersion: 1, + packages: [{ name: 'JSONStream', version: '1.3.5' }] + }); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('bundleStager resolves workspace and pnpm package symlinks', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-bundle-workspace-')); + try { + const personaPath = path.join(dir, 'persona.json'); + const personaSpec = persona(); + await writeFile(personaPath, JSON.stringify(personaSpec, null, 2), 'utf8'); + + const workspacePackage = path.join(dir, 'packages', 'adapter-core'); + await mkdir(workspacePackage, { recursive: true }); + await writeFile( + path.join(workspacePackage, 'package.json'), + JSON.stringify({ name: '@relayfile/adapter-core', version: '0.5.7', type: 'module', main: 'index.js' }, null, 2), + 'utf8' + ); + await writeFile(path.join(workspacePackage, 'index.js'), 'export const adapterVersion = "0.5.7";\n', 'utf8'); + const symlinkParent = path.join(dir, 'node_modules', '@relayfile'); + await mkdir(symlinkParent, { recursive: true }); + await symlink(workspacePackage, path.join(symlinkParent, 'adapter-core'), 'dir'); + + const pnpmPackage = path.join( + dir, + 'node_modules', + '.pnpm', + '@relayfile+relay-helpers@0.4.9', + 'node_modules', + '@relayfile', + 'relay-helpers' + ); + await mkdir(pnpmPackage, { recursive: true }); + await writeFile( + path.join(pnpmPackage, 'package.json'), + JSON.stringify({ name: '@relayfile/relay-helpers', version: '0.4.9', type: 'module', main: 'index.js' }, null, 2), + 'utf8' + ); + await writeFile(path.join(pnpmPackage, 'index.js'), 'export const relayVersion = "0.4.9";\n', 'utf8'); + await symlink(pnpmPackage, path.join(symlinkParent, 'relay-helpers'), 'dir'); + + await writeFile( + path.join(dir, 'agent.ts'), + [ + "import { adapterVersion } from '@relayfile/adapter-core';", + "import { relayVersion } from '@relayfile/relay-helpers';", + 'export default async function handler() {', + ' return `${adapterVersion}:${relayVersion}`;', + '}', + '' + ].join('\n'), + 'utf8' + ); + + const result = await bundleStager.stage({ + personaPath, + persona: personaSpec, + outDir: path.join(dir, 'build') + }); + const generatedPackageJson = JSON.parse(await readFile(result.packageJsonPath, 'utf8')); + + assert.deepEqual(generatedPackageJson.bundleManifest, { + schemaVersion: 1, + packages: [ + { name: '@relayfile/adapter-core', version: '0.5.7' }, + { name: '@relayfile/relay-helpers', version: '0.4.9' } + ] + }); + const bundledSource = await readFile(result.bundlePath, 'utf8'); + assert.match(bundledSource, /0\.5\.7/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('bundleStager fails closed for a workspace symlink whose target lacks package metadata', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-bundle-missing-workspace-')); + try { + const personaPath = path.join(dir, 'persona.json'); + const personaSpec = persona(); + await writeFile(personaPath, JSON.stringify(personaSpec, null, 2), 'utf8'); + await writeFile( + path.join(dir, 'package.json'), + JSON.stringify({ name: 'author-project', version: '1.0.0', type: 'module' }, null, 2), + 'utf8' + ); + + const workspacePackage = path.join(dir, 'packages', 'missing-workspace'); + await mkdir(workspacePackage, { recursive: true }); + await writeFile( + path.join(workspacePackage, 'index.js'), + 'export const workspaceMarker = "missing-workspace-marker";\n', + 'utf8' + ); + const nodeModules = path.join(dir, 'node_modules'); + await mkdir(nodeModules, { recursive: true }); + await symlink(workspacePackage, path.join(nodeModules, 'missing-workspace'), 'dir'); + await writeFile( + path.join(dir, 'agent.ts'), + "import { workspaceMarker } from 'missing-workspace'; export default () => workspaceMarker;\n", + 'utf8' + ); + + await assert.rejects( + () => bundleStager.stage({ + personaPath, + persona: personaSpec, + outDir: path.join(dir, 'build-missing') + }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match( + error.message, + /bundled package "missing-workspace" has no valid package\.json metadata/ + ); + assert.equal(error.message.includes(dir), false, 'failure must not leak the project path'); + assert.equal(error.message.includes(workspacePackage), false, 'failure must not leak the target path'); + return true; + } + ); + + await writeFile(path.join(workspacePackage, 'package.json'), '{ not valid json', 'utf8'); + await assert.rejects( + () => bundleStager.stage({ + personaPath, + persona: personaSpec, + outDir: path.join(dir, 'build-invalid') + }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal( + error.message, + 'bundle: bundled package "missing-workspace" has no valid package.json metadata' + ); + assert.equal(error.message.includes(dir), false, 'failure must not leak the project path'); + assert.equal(error.message.includes(workspacePackage), false, 'failure must not leak the target path'); + return true; + } + ); + + await writeFile( + path.join(workspacePackage, 'package.json'), + JSON.stringify( + { name: 'missing-workspace', version: '2.4.6', type: 'module', main: 'index.js' }, + null, + 2 + ), + 'utf8' + ); + const result = await bundleStager.stage({ + personaPath, + persona: personaSpec, + outDir: path.join(dir, 'build-valid') + }); + const generatedPackageJson = JSON.parse(await readFile(result.packageJsonPath, 'utf8')); + assert.deepEqual(generatedPackageJson.bundleManifest.packages, [ + { name: 'missing-workspace', version: '2.4.6' } + ]); + assert.match(await readFile(result.bundlePath, 'utf8'), /missing-workspace-marker/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('bundleStager never attributes a metadata-less symlink target to an unrelated ancestor package', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-bundle-unrelated-ancestor-')); + try { + const personaPath = path.join(dir, 'persona.json'); + const personaSpec = persona(); + await writeFile(personaPath, JSON.stringify(personaSpec, null, 2), 'utf8'); + + const unrelatedAncestor = path.join(dir, 'vendor', 'unrelated-ancestor'); + const dependencyTarget = path.join(unrelatedAncestor, 'packages', 'linked-dependency'); + await mkdir(dependencyTarget, { recursive: true }); + await writeFile( + path.join(unrelatedAncestor, 'package.json'), + JSON.stringify({ name: 'unrelated-ancestor', version: '9.9.9', type: 'module' }, null, 2), + 'utf8' + ); + await writeFile( + path.join(dependencyTarget, 'index.js'), + 'export const linkedMarker = "linked-dependency-marker";\n', + 'utf8' + ); + const nodeModules = path.join(dir, 'node_modules'); + await mkdir(nodeModules, { recursive: true }); + await symlink(dependencyTarget, path.join(nodeModules, 'linked-dependency'), 'dir'); + await writeFile( + path.join(dir, 'agent.ts'), + "import { linkedMarker } from 'linked-dependency'; export default () => linkedMarker;\n", + 'utf8' + ); + + await assert.rejects( + () => bundleStager.stage({ + personaPath, + persona: personaSpec, + outDir: path.join(dir, 'build') + }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match( + error.message, + /bundled package "linked-dependency" has no valid package\.json metadata/ + ); + assert.equal(error.message.includes('unrelated-ancestor'), false); + assert.equal(error.message.includes(dir), false); + return true; + } + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('bundleStager fails closed without leaking paths when bundled package version metadata is absent', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-bundle-versionless-')); + try { + const personaPath = path.join(dir, 'persona.json'); + const personaSpec = persona(); + await writeFile(personaPath, JSON.stringify(personaSpec, null, 2), 'utf8'); + const versionlessPackage = path.join(dir, 'node_modules', 'versionless-package'); + await mkdir(versionlessPackage, { recursive: true }); + await writeFile( + path.join(versionlessPackage, 'package.json'), + JSON.stringify({ name: 'versionless-package', type: 'module', main: 'index.js' }, null, 2), + 'utf8' + ); + await writeFile(path.join(versionlessPackage, 'index.js'), 'export const versionless = true;\n', 'utf8'); + await writeFile( + path.join(dir, 'agent.ts'), + "import { versionless } from 'versionless-package'; export default () => versionless;\n", + 'utf8' + ); + + await assert.rejects( + () => bundleStager.stage({ + personaPath, + persona: personaSpec, + outDir: path.join(dir, 'build') + }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.match(error.message, /bundled package "versionless-package" has no valid "version" metadata/); + assert.equal(error.message.includes(dir), false); + return true; + } + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('bundleStager rejects a contributing package whose version is an absolute store path without leaking it', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-bundle-unsafe-version-')); + try { + const personaPath = path.join(dir, 'persona.json'); + const personaSpec = persona(); + const unsafeVersion = path.join(dir, 'node_modules', '.pnpm', 'store', 'v3', 'files', 'secret'); + await writeFile(personaPath, JSON.stringify(personaSpec, null, 2), 'utf8'); + await writePackage( + dir, + '@relayfile/path-poison', + unsafeVersion, + 'export const marker = "contributing-path-poison-marker";\n' + ); + await writeFile( + path.join(dir, 'agent.ts'), + "import { marker } from '@relayfile/path-poison'; export default () => marker;\n", + 'utf8' + ); + + const outDir = path.join(dir, 'build'); + await assert.rejects( + () => bundleStager.stage({ personaPath, persona: personaSpec, outDir }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal( + error.message, + 'bundle: bundled package "@relayfile/path-poison" has no valid "version" metadata' + ); + assert.equal(error.message.includes(unsafeVersion), false); + assert.equal(error.message.includes(dir), false); + return true; + } + ); + + await assert.rejects(readFile(path.join(outDir, 'package.json'), 'utf8'), { code: 'ENOENT' }); + await assert.rejects(readFile(path.join(outDir, 'runner.mjs'), 'utf8'), { code: 'ENOENT' }); + assert.equal( + (await readFile(path.join(outDir, 'agent.bundle.mjs'), 'utf8')).includes(unsafeVersion), + false, + 'the rejected version must not appear in the emitted bundle either' + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('bundleStager rejects non-version package metadata across real esbuild ownership paths', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-bundle-invalid-versions-')); + try { + const personaPath = path.join(dir, 'persona.json'); + const personaSpec = persona(); + await writeFile(personaPath, JSON.stringify(personaSpec, null, 2), 'utf8'); + await writeFile( + path.join(dir, 'agent.ts'), + "import { marker } from '@relayfile/invalid-version'; export default () => marker;\n", + 'utf8' + ); + + const invalidVersions = [ + 'not-a-version', + '^1.2.3', + 'workspace:*', + 'file:../private-package', + 'https://registry.example/private-package.tgz', + 'C:\\pnpm-store\\private-package', + ' 1.2.3', + '1.2.3\nprivate-path', + '1.2.3+invalid_metadata', + '1.2.3-alpha..1' + ]; + + for (const [index, invalidVersion] of invalidVersions.entries()) { + await writePackage( + dir, + '@relayfile/invalid-version', + invalidVersion, + 'export const marker = "contributing-invalid-version-marker";\n' + ); + await assert.rejects( + () => bundleStager.stage({ + personaPath, + persona: personaSpec, + outDir: path.join(dir, `build-${index}`) + }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal( + error.message, + 'bundle: bundled package "@relayfile/invalid-version" has no valid "version" metadata' + ); + assert.equal(error.message.includes(invalidVersion), false); + assert.equal(error.message.includes(dir), false); + return true; + }, + `expected ${JSON.stringify(invalidVersion)} to be rejected` + ); + } + + const relativePackage = path.join(dir, 'vendor', 'relative-invalid-version'); + await mkdir(relativePackage, { recursive: true }); + await writeFile( + path.join(relativePackage, 'package.json'), + JSON.stringify({ + name: '@relayfile/relative-invalid-version', + version: 'npm:@relayfile/private@1.2.3', + type: 'module' + }), + 'utf8' + ); + await writeFile( + path.join(relativePackage, 'index.js'), + 'export const marker = "relative-invalid-version-marker";\n', + 'utf8' + ); + await writeFile( + path.join(dir, 'agent.ts'), + "import { marker } from './vendor/relative-invalid-version/index.js'; export default () => marker;\n", + 'utf8' + ); + await assert.rejects( + () => bundleStager.stage({ + personaPath, + persona: personaSpec, + outDir: path.join(dir, 'build-relative') + }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal( + error.message, + 'bundle: bundled package "@relayfile/relative-invalid-version" has no valid "version" metadata' + ); + assert.equal(error.message.includes('npm:'), false); + assert.equal(error.message.includes(dir), false); + return true; + } + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('bundleStager rejects an invalid metadata name using only the logical dependency name', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-bundle-invalid-name-')); + try { + const personaPath = path.join(dir, 'persona.json'); + const personaSpec = persona(); + const invalidMetadataName = `../private/${path.basename(dir)}`; + await writeFile(personaPath, JSON.stringify(personaSpec, null, 2), 'utf8'); + const packageDir = path.join(dir, 'node_modules', '@relayfile', 'name-poison'); + await mkdir(packageDir, { recursive: true }); + await writeFile( + path.join(packageDir, 'package.json'), + JSON.stringify({ name: invalidMetadataName, version: '1.2.3', type: 'module', main: 'index.js' }), + 'utf8' + ); + await writeFile( + path.join(packageDir, 'index.js'), + 'export const marker = "name-poison-marker";\n', + 'utf8' + ); + await writeFile( + path.join(dir, 'agent.ts'), + "import { marker } from '@relayfile/name-poison'; export default () => marker;\n", + 'utf8' + ); + + await assert.rejects( + () => bundleStager.stage({ + personaPath, + persona: personaSpec, + outDir: path.join(dir, 'build') + }), + (error: unknown) => { + assert.ok(error instanceof Error); + assert.equal( + error.message, + 'bundle: bundled package "@relayfile/name-poison" has no valid "name" metadata' + ); + assert.equal(error.message.includes(invalidMetadataName), false); + assert.equal(error.message.includes(dir), false); + return true; + } + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('bundleStager does not require valid metadata for a dependency removed from the real output', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-bundle-tree-shaken-metadata-')); + try { + const personaPath = path.join(dir, 'persona.json'); + const personaSpec = persona(); + await writeFile(personaPath, JSON.stringify(personaSpec, null, 2), 'utf8'); + const packageDir = path.join(dir, 'node_modules', 'tree-shaken-versionless'); + await mkdir(packageDir, { recursive: true }); + await writeFile( + path.join(packageDir, 'package.json'), + JSON.stringify({ + name: 'tree-shaken-versionless', + version: 'file:/private/tree-shaken-package', + type: 'module', + main: 'index.js', + sideEffects: false + }), + 'utf8' + ); + await writeFile(path.join(packageDir, 'index.js'), 'export const unused = "unused-marker";\n', 'utf8'); + await writeFile( + path.join(dir, 'agent.ts'), + [ + "import { unused } from 'tree-shaken-versionless';", + 'void unused;', + 'export default () => "handler-result";', + '' + ].join('\n'), + 'utf8' + ); + + const result = await bundleStager.stage({ + personaPath, + persona: personaSpec, + outDir: path.join(dir, 'build') + }); + const generatedPackageJson = JSON.parse(await readFile(result.packageJsonPath, 'utf8')); + assert.deepEqual(generatedPackageJson.bundleManifest.packages, []); + assert.doesNotMatch(await readFile(result.bundlePath, 'utf8'), /unused-marker/); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('bundleStager preserves distinct versions of the same package from the actual bundle graph', async () => { + const dir = await mkdtemp(path.join(os.tmpdir(), 'wf-bundle-duplicates-')); + try { + const personaPath = path.join(dir, 'persona.json'); + const personaSpec = persona(); + await writeFile(personaPath, JSON.stringify(personaSpec, null, 2), 'utf8'); + await writePackage(dir, '@relayfile/adapter-core', '0.5.1', 'export const rootAdapter = "0.5.1";\n'); + await writePackage( + dir, + 'duplicate-host', + '1.0.0', + "import { nestedAdapter } from '@relayfile/adapter-core'; export const nested = nestedAdapter;\n" + ); + const hostRoot = path.join(dir, 'node_modules', 'duplicate-host'); + await writePackage( + hostRoot, + '@relayfile/adapter-core', + '0.5.6', + 'export const nestedAdapter = "0.5.6";\n' + ); + await writeFile( + path.join(dir, 'agent.ts'), + [ + "import { rootAdapter } from '@relayfile/adapter-core';", + "import { nested } from 'duplicate-host';", + 'export default async function handler() { return `${rootAdapter}:${nested}`; }', + '' + ].join('\n'), + 'utf8' + ); + + const result = await bundleStager.stage({ + personaPath, + persona: personaSpec, + outDir: path.join(dir, 'build') + }); + const generatedPackageJson = JSON.parse(await readFile(result.packageJsonPath, 'utf8')); + + assert.deepEqual(generatedPackageJson.bundleManifest.packages, [ + { name: '@relayfile/adapter-core', version: '0.5.1' }, + { name: '@relayfile/adapter-core', version: '0.5.6' }, + { name: 'duplicate-host', version: '1.0.0' } + ]); } finally { await rm(dir, { recursive: true, force: true }); } @@ -228,3 +983,24 @@ test('runtimeContextEnv defaults to clock when the agent has no integration trig }); assert.equal(JSON.parse(env.WORKFORCE_DEPLOYMENT_CONTEXT).triggerKind, 'clock'); }); + +async function writePackage( + root: string, + name: string, + version: string, + source: string +): Promise { + const packageDir = path.join(root, 'node_modules', ...name.split('/')); + await mkdir(packageDir, { recursive: true }); + await writeFile( + path.join(packageDir, 'package.json'), + JSON.stringify({ name, version, type: 'module', main: 'index.js' }, null, 2), + 'utf8' + ); + await writeFile(path.join(packageDir, 'index.js'), source, 'utf8'); +} + +function resolveInstalledRuntimeVersion(): string { + const installedRuntimePackageJsonPath = require.resolve('@agentworkforce/runtime/package.json'); + return require(installedRuntimePackageJsonPath).version as string; +} diff --git a/packages/deploy/src/bundle.ts b/packages/deploy/src/bundle.ts index 6fb5d406..ef663752 100644 --- a/packages/deploy/src/bundle.ts +++ b/packages/deploy/src/bundle.ts @@ -1,7 +1,10 @@ -import { mkdir, writeFile, stat } from 'node:fs/promises'; +import { mkdir, readFile, realpath, writeFile, stat } from 'node:fs/promises'; import { builtinModules, createRequire } from 'node:module'; import path from 'node:path'; -import { build } from 'esbuild'; +import { build, type Metafile, type Plugin } from 'esbuild'; +import { parse as parseSemver } from 'semver'; +import validatePackageName from 'validate-npm-package-name'; +import type { BundleManifest, BundlePackageVersion } from '@agentworkforce/runtime/runner'; import type { BundleStageInput, BundleResult, BundleStager } from './types.js'; const require = createRequire(import.meta.url); @@ -11,12 +14,13 @@ const require = createRequire(import.meta.url); * bundle reader can detect format drift. Bumped whenever the runner * shape changes incompatibly. */ -const RUNNER_FORMAT_VERSION = 2; +const RUNNER_FORMAT_VERSION = 3; const NODE_EXTERNALS = [ ...builtinModules, 'node:*' ]; +const NODE_EXTERNAL_SET = new Set(NODE_EXTERNALS); /** * Stage a deploy-ready bundle to `input.outDir`. Output layout: @@ -25,7 +29,7 @@ const NODE_EXTERNALS = [ * agent.bundle.mjs — esbuilt user `onEvent` (default-exported handler) * runner.mjs — entry that imports the runtime + bundle + persona * persona.json — verbatim copy of the input persona spec - * package.json — minimal manifest pinning the runtime dep + * package.json — runtime pin + exact bundled-package manifest * * The bundle is idempotent: re-running with the same `outDir` overwrites * the four files cleanly. Auxiliary files left behind from earlier runs @@ -54,37 +58,66 @@ export const bundleStager: BundleStager = { const personaCopyPath = path.join(input.outDir, 'persona.json'); const packageJsonPath = path.join(input.outDir, 'package.json'); - await build({ - entryPoints: [onEventAbs], - outfile: bundlePath, - bundle: true, - format: 'esm', - platform: 'node', - target: 'node20', - sourcemap: 'inline', - logLevel: 'silent', - minify: input.bundlerOptions?.minify ?? false, - banner: { - js: [ - 'import { createRequire as __agentworkforceCreateRequire } from "node:module";', - 'const require = __agentworkforceCreateRequire(import.meta.url);' - ].join('\n') - }, - // Resolve TypeScript / JS extensions without forcing the user to - // write `.ts`-suffixed imports in their handler file. - resolveExtensions: ['.ts', '.mts', '.cts', '.tsx', '.js', '.mjs', '.cjs', '.jsx', '.json'], - external: [ - // Runtime stays external — see file header comment. - '@agentworkforce/runtime', - '@agentworkforce/runtime/raw', - // Node builtins must never be bundled. - ...NODE_EXTERNALS - ] - }); + const absWorkingDir = process.cwd(); + const dependencyTrace: DependencyTrace = { + roots: new Map(), + metadataFailures: new Map() + }; + let buildResult; + try { + buildResult = await build({ + entryPoints: [onEventAbs], + outfile: bundlePath, + bundle: true, + format: 'esm', + platform: 'node', + target: 'node20', + sourcemap: 'inline', + logLevel: 'silent', + minify: input.bundlerOptions?.minify ?? false, + metafile: true, + absWorkingDir, + plugins: [traceLogicalDependencyRoots(dependencyTrace)], + banner: { + js: [ + 'import { createRequire as __agentworkforceCreateRequire } from "node:module";', + 'const require = __agentworkforceCreateRequire(import.meta.url);' + ].join('\n') + }, + // Resolve TypeScript / JS extensions without forcing the user to + // write `.ts`-suffixed imports in their handler file. + resolveExtensions: ['.ts', '.mts', '.cts', '.tsx', '.js', '.mjs', '.cjs', '.jsx', '.json'], + external: [ + // Runtime stays external — see file header comment. + '@agentworkforce/runtime', + '@agentworkforce/runtime/raw', + // Node builtins must never be bundled. + ...NODE_EXTERNALS + ] + }); + } catch (error) { + const failure = [...dependencyTrace.metadataFailures.entries()].sort(([a], [b]) => + a.localeCompare(b) + )[0]; + if (failure) throw invalidBundledPackageMetadataError(failure[0], failure[1]); + throw error; + } + + const bundleManifest = await buildBundleManifest( + buildResult.metafile, + bundlePath, + onEventAbs, + absWorkingDir, + dependencyTrace.roots + ); await writeFile(personaCopyPath, JSON.stringify(input.persona, null, 2) + '\n', 'utf8'); - await writeFile(packageJsonPath, buildPackageJson(input.persona.id, resolveRuntimeVersion()), 'utf8'); + await writeFile( + packageJsonPath, + buildPackageJson(input.persona.id, resolveRuntimeVersion(), bundleManifest), + 'utf8' + ); await writeFile(runnerPath, renderRunner(), 'utf8'); @@ -130,7 +163,11 @@ function resolveRuntimeVersion(): string { return pkg.version; } -function buildPackageJson(personaId: string, runtimeVersion: string): string { +function buildPackageJson( + personaId: string, + runtimeVersion: string, + bundleManifest: BundleManifest +): string { return ( JSON.stringify( { @@ -142,8 +179,9 @@ function buildPackageJson(personaId: string, runtimeVersion: string): string { dependencies: { '@agentworkforce/runtime': runtimeVersion }, + bundleManifest, comment: - 'Generated by workforce deploy. The runtime dep is pinned to the exact version this deploy CLI was built against so the sandbox installs the same runtime the bundle was compiled for, instead of trusting whatever is pre-baked or cached.' + 'Generated by workforce deploy. The runtime dep is pinned to the exact version this deploy CLI was built against so the sandbox installs the same runtime the bundle was compiled for, instead of trusting whatever is pre-baked or cached. bundleManifest records exact versions whose package inputs contributed bytes to agent.bundle.mjs.' }, null, 2 @@ -167,6 +205,8 @@ import * as userModule from './agent.bundle.mjs'; const require = createRequire(import.meta.url); const persona = require('./persona.json'); +const packageJson = require('./package.json'); +const bundleManifest = packageJson.bundleManifest; // The agent.ts default export is a \`defineAgent({...})\` object carrying the // handler plus the listener declarations. A bare function default export is @@ -205,7 +245,7 @@ function projectAgentSpec(value) { const agent = readRuntimeContext('WORKFORCE_AGENT_CONTEXT'); const deployment = readRuntimeContext('WORKFORCE_DEPLOYMENT_CONTEXT'); -await startRunner({ persona, agent, deployment, handler, ...(agentSpec ? { agentSpec } : {}) }); +await startRunner({ persona, agent, deployment, handler, bundleManifest, ...(agentSpec ? { agentSpec } : {}) }); function readRuntimeContext(name) { const raw = process.env[name]; @@ -223,6 +263,393 @@ function readRuntimeContext(name) { `; } +async function buildBundleManifest( + metafile: Metafile, + bundlePath: string, + entryPointPath: string, + absWorkingDir: string, + dependencyRoots: Map> +): Promise { + const expectedBundlePath = path.resolve(absWorkingDir, bundlePath); + const output = Object.entries(metafile.outputs).find( + ([outputPath]) => path.resolve(absWorkingDir, outputPath) === expectedBundlePath + )?.[1]; + if (!output) { + throw new Error('bundle: esbuild metafile did not describe agent.bundle.mjs'); + } + + const ownerCache: PackageOwnershipCache = { + directories: new Map(), + packageRoots: new Map() + }; + const entryOwner = await findPackageOwnership(entryPointPath, ownerCache, dependencyRoots); + const contributingInputs = Object.entries(output.inputs) + .filter(([, contribution]) => contribution.bytesInOutput > 0) + .map(([inputPath]) => path.resolve(absWorkingDir, inputPath)); + const owners = await Promise.all( + contributingInputs.map((inputPath) => + findPackageOwnership(inputPath, ownerCache, dependencyRoots) + ) + ); + + const uniquePackages = new Map(); + for (const owner of owners) { + if (!owner) continue; + // The entry owner is the author/consumer package, not a bundled + // dependency. Identify it by the canonical ownership root before + // validating publishable dependency metadata: private applications may + // legitimately omit a version or use workspace-only package metadata. + if (owner.root === entryOwner?.root) continue; + if (owner.invalidMetadata) { + const packageLabel = owner.invalidMetadata.name + ? ` package "${owner.invalidMetadata.name}"` + : ' dependency package'; + throw new Error( + `bundle: bundled${packageLabel} has no valid ${owner.invalidMetadata.field} metadata` + ); + } + if (!owner.pkg) continue; + const key = `${owner.pkg.name}\u0000${owner.pkg.version}`; + uniquePackages.set(key, owner.pkg); + } + + return { + schemaVersion: 1, + packages: [...uniquePackages.values()].sort(comparePackageVersions) + }; +} + +interface PackageOwnership { + root: string; + pkg?: BundlePackageVersion; + invalidMetadata?: { + name?: string; + field: 'package.json' | '"name"' | '"version"'; + }; +} + +interface PackageOwnershipCache { + directories: Map>; + packageRoots: Map>; +} + +interface PackageRootMetadata { + pkg?: BundlePackageVersion; + invalidField?: 'package.json' | '"name"' | '"version"'; +} + +interface LogicalDependencyBoundary { + name: string; + root: string; +} + +interface DependencyTrace { + roots: Map>; + metadataFailures: Map; +} + +async function findPackageOwnership( + inputPath: string, + cache: PackageOwnershipCache, + dependencyRoots: Map> +): Promise { + // Capture a logical node_modules package boundary whenever esbuild retains + // one. Symlinked inputs are normally already dereferenced in the metafile; + // those are recovered from the resolution trace below. + const dependencyBoundary = findLogicalDependencyBoundary(inputPath); + if (dependencyBoundary) { + return findDependencyPackageOwnership(dependencyBoundary, cache); + } + + let resolvedInput: string; + try { + resolvedInput = await realpath(inputPath); + } catch { + // Non-file esbuild inputs (for example virtual/plugin namespaces) do not + // have package metadata and must never leak their identifiers as paths. + return undefined; + } + const tracedBoundary = findTracedDependencyBoundary(resolvedInput, dependencyRoots); + if (tracedBoundary) { + return findDependencyPackageOwnership(tracedBoundary, cache); + } + return findPackageOwnershipFromDirectory(path.dirname(resolvedInput), cache); +} + +async function findDependencyPackageOwnership( + boundary: LogicalDependencyBoundary, + cache: PackageOwnershipCache +): Promise { + let realRoot: string; + try { + realRoot = await realpath(boundary.root); + } catch { + return { + root: boundary.root, + invalidMetadata: { name: boundary.name, field: 'package.json' } + }; + } + + let metadata = cache.packageRoots.get(realRoot); + if (!metadata) { + metadata = readPackageRootMetadata(realRoot); + cache.packageRoots.set(realRoot, metadata); + } + const resolved = await metadata; + if (resolved.pkg) return { root: realRoot, pkg: resolved.pkg }; + return { + root: realRoot, + invalidMetadata: { + // Use only the stable logical dependency name in errors. Never expose + // the consumer path, workspace target, or an unrelated ancestor label. + name: boundary.name, + field: resolved.invalidField ?? 'package.json' + } + }; +} + +async function readPackageRootMetadata(directory: string): Promise { + let parsed: { name?: unknown; version?: unknown }; + try { + parsed = JSON.parse(await readFile(path.join(directory, 'package.json'), 'utf8')) as { + name?: unknown; + version?: unknown; + }; + } catch { + return { invalidField: 'package.json' }; + } + if (!isValidPackageName(parsed.name)) { + return { invalidField: '"name"' }; + } + if (!isExactPackageVersion(parsed.version)) { + return { invalidField: '"version"' }; + } + return { pkg: { name: parsed.name, version: parsed.version } }; +} + +function isValidPackageName(value: unknown): value is string { + return typeof value === 'string' && validatePackageName(value).validForOldPackages; +} + +function isExactPackageVersion(value: unknown): value is string { + if (typeof value !== 'string') return false; + const parsed = parseSemver(value); + if (!parsed) return false; + + // node-semver deliberately accepts compatibility spellings such as a + // leading "v" or surrounding whitespace. Reconstruct the canonical SemVer + // spelling so package metadata remains an exact version, while retaining + // valid prerelease and build identifiers. + const prerelease = parsed.prerelease.length > 0 + ? `-${parsed.prerelease.map(String).join('.')}` + : ''; + const build = parsed.build.length > 0 ? `+${parsed.build.join('.')}` : ''; + return value === `${parsed.major}.${parsed.minor}.${parsed.patch}${prerelease}${build}`; +} + +function findPackageOwnershipFromDirectory( + directory: string, + cache: PackageOwnershipCache +): Promise { + const cached = cache.directories.get(directory); + if (cached) return cached; + + const pending = (async (): Promise => { + // Do not attribute a versionless package under node_modules to the + // consumer package above that node_modules boundary. + if (path.basename(directory) === 'node_modules') return undefined; + + const packageJsonPath = path.join(directory, 'package.json'); + try { + const parsed = JSON.parse(await readFile(packageJsonPath, 'utf8')) as { + name?: unknown; + version?: unknown; + }; + if (!isValidPackageName(parsed.name)) { + return { root: directory, invalidMetadata: { field: '"name"' } }; + } + if (!isExactPackageVersion(parsed.version)) { + return { + root: directory, + invalidMetadata: { name: parsed.name, field: '"version"' } + }; + } + return { root: directory, pkg: { name: parsed.name, version: parsed.version } }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { + // Never claim an ancestor's version when the nearest metadata is + // unreadable or malformed. + return { root: directory, invalidMetadata: { field: 'package.json' } }; + } + } + + const inferredPackageName = packageNameAtNodeModulesBoundary(directory); + if (inferredPackageName) { + return { + root: directory, + invalidMetadata: { name: inferredPackageName, field: 'package.json' } + }; + } + + const parent = path.dirname(directory); + return parent === directory + ? undefined + : findPackageOwnershipFromDirectory(parent, cache); + })(); + cache.directories.set(directory, pending); + return pending; +} + +function findLogicalDependencyBoundary(inputPath: string): LogicalDependencyBoundary | undefined { + let directory = path.dirname(path.resolve(inputPath)); + while (true) { + const name = packageNameAtNodeModulesBoundary(directory); + if (name) return { name, root: directory }; + const parent = path.dirname(directory); + if (parent === directory) return undefined; + directory = parent; + } +} + +function findTracedDependencyBoundary( + inputPath: string, + dependencyRoots: Map> +): LogicalDependencyBoundary | undefined { + let match: LogicalDependencyBoundary | undefined; + for (const [root, names] of dependencyRoots) { + if (!isPathInside(inputPath, root) || (match && match.root.length >= root.length)) continue; + const name = [...names].sort()[0]; + if (name) match = { name, root }; + } + return match; +} + +/** + * esbuild intentionally resolves symlinks to their real targets before the + * metafile is produced. Trace bare-package resolutions without changing that + * behavior, so pnpm's realpath-based dependency lookup keeps working while the + * manifest builder retains the logical package boundary and stable name. + */ +function traceLogicalDependencyRoots(trace: DependencyTrace): Plugin { + const recursiveResolve = {}; + const metadataCache = new Map>(); + return { + name: 'agentworkforce-logical-dependency-roots', + setup(buildApi) { + buildApi.onResolve({ filter: /.*/ }, async (args) => { + if (args.pluginData === recursiveResolve) return undefined; + const packageName = packageNameFromBareSpecifier(args.path); + if (!packageName || isExternalPackageSpecifier(args.path)) return undefined; + + const nearestRoot = await findNearestInstalledPackageRoot(args.resolveDir, packageName); + const result = await buildApi.resolve(args.path, { + importer: args.importer, + kind: args.kind, + namespace: args.namespace, + resolveDir: args.resolveDir, + pluginData: recursiveResolve, + with: args.with + }); + const root = nearestRoot && ( + (!result.external && path.isAbsolute(result.path) && isPathInside(result.path, nearestRoot)) + || result.errors.length > 0 + ) ? nearestRoot : undefined; + if (root) { + let metadata = metadataCache.get(root); + if (!metadata) { + metadata = readPackageRootMetadata(root); + metadataCache.set(root, metadata); + } + const resolvedMetadata = await metadata; + if (result.errors.length > 0 && resolvedMetadata.invalidField) { + trace.metadataFailures.set(packageName, resolvedMetadata.invalidField); + return { errors: [{ text: 'bundled dependency metadata validation failed' }] }; + } + if (!result.external && path.isAbsolute(result.path)) { + const names = trace.roots.get(root) ?? new Set(); + names.add(packageName); + trace.roots.set(root, names); + } + } + return result; + }); + } + }; +} + +async function findNearestInstalledPackageRoot( + resolveDirectory: string, + packageName: string +): Promise { + let directory = path.resolve(resolveDirectory); + while (true) { + const logicalRoot = path.join(directory, 'node_modules', ...packageName.split('/')); + try { + return await realpath(logicalRoot); + } catch { + // This resolution level does not contain the requested package. + } + const parent = path.dirname(directory); + if (parent === directory) return undefined; + directory = parent; + } +} + +function invalidBundledPackageMetadataError( + packageName: string, + field: 'package.json' | '"name"' | '"version"' +): Error { + return new Error(`bundle: bundled package "${packageName}" has no valid ${field} metadata`); +} + +function packageNameFromBareSpecifier(specifier: string): string | undefined { + if ( + specifier.length === 0 + || specifier.startsWith('.') + || specifier.startsWith('/') + || specifier.startsWith('#') + || specifier.includes('\\') + ) { + return undefined; + } + const segments = specifier.split('/'); + if (specifier.startsWith('@')) { + return segments.length >= 2 && segments[0].length > 1 && segments[1].length > 0 + ? `${segments[0]}/${segments[1]}` + : undefined; + } + return segments[0] || undefined; +} + +function isExternalPackageSpecifier(specifier: string): boolean { + return specifier.startsWith('node:') + || NODE_EXTERNAL_SET.has(specifier) + || specifier === '@agentworkforce/runtime' + || specifier === '@agentworkforce/runtime/raw'; +} + +function isPathInside(candidate: string, root: string): boolean { + const relative = path.relative(root, candidate); + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative)); +} + +function packageNameAtNodeModulesBoundary(directory: string): string | undefined { + const parent = path.dirname(directory); + if (path.basename(parent) === 'node_modules') return path.basename(directory); + const grandparent = path.dirname(parent); + return path.basename(grandparent) === 'node_modules' && path.basename(parent).startsWith('@') + ? `${path.basename(parent)}/${path.basename(directory)}` + : undefined; +} + +function comparePackageVersions(a: BundlePackageVersion, b: BundlePackageVersion): number { + if (a.name < b.name) return -1; + if (a.name > b.name) return 1; + if (a.version < b.version) return -1; + if (a.version > b.version) return 1; + return 0; +} + async function assertReadableFile(abs: string, label: string): Promise { try { const st = await stat(abs); diff --git a/packages/deploy/src/modes/cloud.test.ts b/packages/deploy/src/modes/cloud.test.ts index 07a240b0..d8b29c20 100644 --- a/packages/deploy/src/modes/cloud.test.ts +++ b/packages/deploy/src/modes/cloud.test.ts @@ -62,7 +62,17 @@ async function withBundle(): Promise<{ bundle: BundleResult; cleanup: () => Prom writeFile(runnerPath, 'export {};', 'utf8'), writeFile(bundlePath, 'export default {};', 'utf8'), writeFile(personaCopyPath, '{}', 'utf8'), - writeFile(packageJsonPath, '{"type":"module"}', 'utf8') + writeFile( + packageJsonPath, + JSON.stringify({ + type: 'module', + bundleManifest: { + schemaVersion: 1, + packages: [{ name: '@relayfile/relay-helpers', version: '0.4.9' }] + } + }), + 'utf8' + ) ]); return { bundle: { @@ -188,7 +198,13 @@ test('cloud launcher POSTs a deploy bundle and returns the cloud handle', async assert.deepEqual(body.agent, dispatcherAgentSpec); assert.equal((body.persona as { schedules?: unknown }).schedules, undefined); assert.deepEqual(body.inputs, { topic: 'AI' }); - assert.deepEqual((body.bundle as { packageJson: unknown }).packageJson, { type: 'module' }); + assert.deepEqual((body.bundle as { packageJson: unknown }).packageJson, { + type: 'module', + bundleManifest: { + schemaVersion: 1, + packages: [{ name: '@relayfile/relay-helpers', version: '0.4.9' }] + } + }); return okJson({ agentId: 'agent-1', deploymentId: 'dep-1', status: 'active' }, 201); } }); diff --git a/packages/deploy/src/modes/sandbox-client.test.ts b/packages/deploy/src/modes/sandbox-client.test.ts index 9aaaf326..a620a63d 100644 --- a/packages/deploy/src/modes/sandbox-client.test.ts +++ b/packages/deploy/src/modes/sandbox-client.test.ts @@ -55,7 +55,16 @@ async function fixtureBundle(dir: string): Promise { writeFile(runnerPath, 'runner', 'utf8'), writeFile(bundlePath, 'bundle', 'utf8'), writeFile(personaCopyPath, '{"id":"demo"}', 'utf8'), - writeFile(packageJsonPath, '{}', 'utf8') + writeFile( + packageJsonPath, + JSON.stringify({ + bundleManifest: { + schemaVersion: 1, + packages: [{ name: '@relayfile/relay-helpers', version: '0.4.9' }] + } + }), + 'utf8' + ) ]); return { runnerPath, bundlePath, personaCopyPath, packageJsonPath, sizeBytes: 13 }; } @@ -132,6 +141,13 @@ test('proxy client mints, uploads, execs, and destroys against cloud sandboxes e assert.equal(uploadBody.entries.length, 4); const runnerEntry = uploadBody.entries.find((e) => e.destination.endsWith('/runner.mjs')); assert.ok(runnerEntry); + const packageEntry = uploadBody.entries.find((e) => e.destination.endsWith('/package.json')); + assert.ok(packageEntry); + const uploadedPackageJson = JSON.parse(Buffer.from(packageEntry.source, 'base64').toString('utf8')); + assert.deepEqual(uploadedPackageJson.bundleManifest, { + schemaVersion: 1, + packages: [{ name: '@relayfile/relay-helpers', version: '0.4.9' }] + }); assert.equal(Buffer.from(runnerEntry!.source, 'base64').toString('utf8'), 'runner'); // Install exec. diff --git a/packages/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md index 859680ae..ece4d5b5 100644 --- a/packages/runtime/CHANGELOG.md +++ b/packages/runtime/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Include the deployed bundle package manifest in structured `runner.started` + evidence (#279). - Add the reserved `[[NO_REPLY]]` harness contract for successful silent runs, including marker leak sanitization and observability. diff --git a/packages/runtime/src/runner.test.ts b/packages/runtime/src/runner.test.ts index f9be1a2a..9dbd3173 100644 --- a/packages/runtime/src/runner.test.ts +++ b/packages/runtime/src/runner.test.ts @@ -101,6 +101,34 @@ test('startRunner dispatches a cron envelope to the handler', async () => { assert.ok(logs.find((l) => l.message === 'runner.handler.ok')); }); +test('startRunner surfaces the supplied bundle manifest in runner.started structured evidence', async () => { + const logs: Array<{ level: string; message: string; attrs?: Record }> = []; + const bundleManifest = { + schemaVersion: 1 as const, + packages: [ + { name: '@relayfile/adapter-core', version: '0.5.7' }, + { name: '@relayfile/relay-helpers', version: '0.4.9' } + ] + }; + + await startRunner({ + persona, + agent: runtimeAgent, + deployment: runtimeDeployment, + workspaceId: 'ws-test', + handler: handler(async () => {}), + bundleManifest, + subsystems: { + sandbox: stubSandbox, + log: (level, message, attrs) => logs.push({ level, message, attrs }) + }, + envelopes: streamOf([]) + }); + + const started = logs.find((entry) => entry.message === 'runner.started'); + assert.deepEqual(started?.attrs?.bundleManifest, bundleManifest); +}); + test('startRunner logs and continues when the handler throws', async () => { const logs: Array<{ level: string; message: string }> = []; let invocations = 0; diff --git a/packages/runtime/src/runner.ts b/packages/runtime/src/runner.ts index 995c315f..20963141 100644 --- a/packages/runtime/src/runner.ts +++ b/packages/runtime/src/runner.ts @@ -39,6 +39,12 @@ export interface StartRunnerOptions { * startup logging; the runtime does not subscribe (the cloud gateway does). */ agentSpec?: AgentSpec; + /** + * Exact package versions whose source contributed bytes to the deployed + * agent bundle. Generated deploy runners read this from package.json and + * pass it through unchanged for startup observability. + */ + bundleManifest?: BundleManifest; /** * Workspace identifier. Resolved from `WORKFORCE_WORKSPACE_ID` env when * not supplied. The runner refuses to start without one. @@ -65,6 +71,16 @@ export interface StartRunnerOptions { harnessRunner?: (args: HarnessRunArgs) => Promise; } +export interface BundlePackageVersion { + readonly name: string; + readonly version: string; +} + +export interface BundleManifest { + readonly schemaVersion: 1; + readonly packages: readonly BundlePackageVersion[]; +} + /** * Cold-start the agent. Returns a promise that resolves once the envelope * stream completes (in production this is essentially "never", since the @@ -138,7 +154,8 @@ export async function startRunner(options: StartRunnerOptions): Promise { workspaceId, schedules: options.agentSpec?.schedules?.map((s) => s.name) ?? [], triggers: options.agentSpec?.triggers ? Object.keys(options.agentSpec.triggers) : [], - integrations: options.persona.integrations ? Object.keys(options.persona.integrations) : [] + integrations: options.persona.integrations ? Object.keys(options.persona.integrations) : [], + ...(options.bundleManifest ? { bundleManifest: options.bundleManifest } : {}) }); const recorder = getTrajectoryRecorder(ctx); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b263064f..a33532df 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,19 @@ importers: esbuild: specifier: ^0.25.0 version: 0.25.12 + semver: + specifier: ^7.8.5 + version: 7.8.5 + validate-npm-package-name: + specifier: ^5.0.1 + version: 5.0.1 + devDependencies: + '@types/semver': + specifier: ^7.7.1 + version: 7.7.1 + '@types/validate-npm-package-name': + specifier: ^4.0.2 + version: 4.0.2 packages/events: dependencies: @@ -1294,6 +1307,12 @@ packages: '@types/node@22.19.15': resolution: {integrity: sha512-F0R/h2+dsy5wJAUe3tAU6oqa2qbWY5TpNfL/RGmo1y38hiyO1w3x2jPtt76wmuaJI4DQnOBu21cNXQ2STIUUWg==} + '@types/semver@7.7.1': + resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==} + + '@types/validate-npm-package-name@4.0.2': + resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -2033,6 +2052,11 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@1.2.1: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} @@ -2166,6 +2190,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + validate-npm-package-name@5.0.1: + resolution: {integrity: sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + vary@1.1.2: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} @@ -3764,6 +3792,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/semver@7.7.1': {} + + '@types/validate-npm-package-name@4.0.2': {} + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -4515,6 +4547,8 @@ snapshots: safer-buffer@2.1.2: {} + semver@7.8.5: {} + send@1.2.1: dependencies: debug: 4.4.3 @@ -4673,6 +4707,8 @@ snapshots: util-deprecate@1.0.2: {} + validate-npm-package-name@5.0.1: {} + vary@1.1.2: {} whatwg-encoding@3.1.1: