feat(#581): publish primitive P1 — version store + Docker runtime + origin-isolating gateway - #785
Merged
Conversation
…rigin-isolating gateway Issue #581 (publish primitive: agent-built internal web apps with immutable versions and rollback), phase P1 per the phase-4c plan: version store, Docker-backed runtime, and the origin-isolation gateway. No native tool and no route — that is P2, wired through harness-orchestrator's plugin.ts sandbox_execute_enabled seam per the plan. New package @omadia/publish (middleware/packages/harness-publish): - publishStore.ts / postgresPublishStore.ts: PublishStore has no update/delete method by design — a republish can never mutate an existing version's row, only create a new one. Postgres allocates version numbers via SELECT...FOR UPDATE on a per-app counter row inside the same transaction as the version insert, so concurrent publishes to one app serialize rather than collide; the (app_id, version) primary key is the second, independent backstop. rollbackTo (in publish.ts) only ever calls store.setPointer — its signature does not even take a PublishRuntime, so it cannot trigger a new build/deploy. - treeCollector.ts: reads the published directory only through Sandbox.list()/read() (already traversal-clamped by @omadia/sandbox), recursing on the paths list() itself returns — no raw filesystem path from agent input anywhere in this module. - dockerPublishRuntime.ts: one immutable container per (appId, version) (deploy() is a no-op if that version's container already exists), one Docker volume per appId mounted at in every version's container. That volume reuse is the entire mechanism behind the durability contract: a file outside lives in the version's own container and is gone the moment a new version's container replaces it; a file inside is on the shared volume and survives every redeploy. Verified against a real Docker daemon (SANDBOX_DOCKER_TEST=1 gate, #576's pattern) in dockerPublishRuntime.test.ts, including an end-to-end persistence proof and serving a real Node entrypoint over HTTP. - publishGateway.ts: the origin boundary published apps run behind. Rejects any request whose Host does not end in a dedicated apps suffix (including an exact match on the admin host) before ever resolving an app backend; strips Cookie/Authorization before proxying; strips any Set-Cookie the app tries to scope with an explicit Domain=. Tested with two plain http.Server instances (no Docker, no browser) proving a forwarded admin session cookie never reaches the app backend and a domain-scoped Set-Cookie from the app never reaches the client. Migration 0045_publish_versions.sql: publish_versions (app_id, version) PK, publish_apps holding the next-version counter and current-version pointer with a composite FK back to publish_versions so a pointer can only ever reference a version that genuinely exists. Testing: 32 tests in middleware/test/publish/ (stub-tier, always-on) all green; +10 more under SANDBOX_DOCKER_TEST=1 including the real-Docker round trip, also green. postgresPublishStore.pg.test.ts skips cleanly with no test Postgres configured (same convention as postgresSandboxRegistry.pg.test.ts). Combined with test/sandbox/**: 89/89 passing. Mutation-checked: reverting the gateway's Set-Cookie Domain= filter, DockerPublishRuntime's containerExists no-op guard, and treeCollector's maxFiles cap each independently fails exactly the test meant to catch it, confirmed with a dist/ rebuild between runs. check-core-decoupling.mjs: held at 3296 (unchanged). check-test-typecheck ratchet: held at 406 known errors (unchanged) — the new @ts-expect-error compile-time proof in publishStore.test.ts type-checks clean. Not in this PR (later phases per the plan): the publish/rollback native tools behind an operator flag, GrantStore-based sharing, and the optional admin version-list view.
This was referenced Aug 20, 2026
Weegy
added a commit
that referenced
this pull request
Aug 20, 2026
…ish_enabled (P2) (#786) * feat(#581): publish primitive P1 — version store + Docker runtime + origin-isolating gateway Issue #581 (publish primitive: agent-built internal web apps with immutable versions and rollback), phase P1 per the phase-4c plan: version store, Docker-backed runtime, and the origin-isolation gateway. No native tool and no route — that is P2, wired through harness-orchestrator's plugin.ts sandbox_execute_enabled seam per the plan. New package @omadia/publish (middleware/packages/harness-publish): - publishStore.ts / postgresPublishStore.ts: PublishStore has no update/delete method by design — a republish can never mutate an existing version's row, only create a new one. Postgres allocates version numbers via SELECT...FOR UPDATE on a per-app counter row inside the same transaction as the version insert, so concurrent publishes to one app serialize rather than collide; the (app_id, version) primary key is the second, independent backstop. rollbackTo (in publish.ts) only ever calls store.setPointer — its signature does not even take a PublishRuntime, so it cannot trigger a new build/deploy. - treeCollector.ts: reads the published directory only through Sandbox.list()/read() (already traversal-clamped by @omadia/sandbox), recursing on the paths list() itself returns — no raw filesystem path from agent input anywhere in this module. - dockerPublishRuntime.ts: one immutable container per (appId, version) (deploy() is a no-op if that version's container already exists), one Docker volume per appId mounted at in every version's container. That volume reuse is the entire mechanism behind the durability contract: a file outside lives in the version's own container and is gone the moment a new version's container replaces it; a file inside is on the shared volume and survives every redeploy. Verified against a real Docker daemon (SANDBOX_DOCKER_TEST=1 gate, #576's pattern) in dockerPublishRuntime.test.ts, including an end-to-end persistence proof and serving a real Node entrypoint over HTTP. - publishGateway.ts: the origin boundary published apps run behind. Rejects any request whose Host does not end in a dedicated apps suffix (including an exact match on the admin host) before ever resolving an app backend; strips Cookie/Authorization before proxying; strips any Set-Cookie the app tries to scope with an explicit Domain=. Tested with two plain http.Server instances (no Docker, no browser) proving a forwarded admin session cookie never reaches the app backend and a domain-scoped Set-Cookie from the app never reaches the client. Migration 0045_publish_versions.sql: publish_versions (app_id, version) PK, publish_apps holding the next-version counter and current-version pointer with a composite FK back to publish_versions so a pointer can only ever reference a version that genuinely exists. Testing: 32 tests in middleware/test/publish/ (stub-tier, always-on) all green; +10 more under SANDBOX_DOCKER_TEST=1 including the real-Docker round trip, also green. postgresPublishStore.pg.test.ts skips cleanly with no test Postgres configured (same convention as postgresSandboxRegistry.pg.test.ts). Combined with test/sandbox/**: 89/89 passing. Mutation-checked: reverting the gateway's Set-Cookie Domain= filter, DockerPublishRuntime's containerExists no-op guard, and treeCollector's maxFiles cap each independently fails exactly the test meant to catch it, confirmed with a dist/ rebuild between runs. check-core-decoupling.mjs: held at 3296 (unchanged). check-test-typecheck ratchet: held at 406 known errors (unchanged) — the new @ts-expect-error compile-time proof in publishStore.test.ts type-checks clean. Not in this PR (later phases per the plan): the publish/rollback native tools behind an operator flag, GrantStore-based sharing, and the optional admin version-list view. * feat(#581): publish/publish_rollback native tools behind sandbox_publish_enabled (P2) Stacked on feat/581-publish (P1: version store + Docker runtime + origin-isolating gateway, PR #785). Wires the #581 P1 library into two agent-callable native tools, following the #576 P2 execute-tool seam in harness-orchestrator/src/plugin.ts exactly (same file, same additive block shape, own flag) — no src/index.ts or agentBuilder.ts change. - publish (tools/publishTool.ts): reads {appId, name, dir, entrypoint}, provisions the CALLING TURN's scope sandbox (same resolveScopeKey posture as execute — 'publish läuft IMMER über den Sandbox des Turn-Scopes' per the plan), and calls @omadia/publish's publish(). Runs defaultCommandPolicy() against a synthetic 'publish <appId>' pseudo-command through the same decideCommand/ recordCommandPolicyOutcome machinery execute uses, before ever touching a sandbox or the version store — inert under the shipped org floor (no default rule matches a pseudo-command), wired for an operator who adds one. This is the conditional 'require_approval semantics IF the command policy concerns deploy commands' the phase plan asks for, reusing existing infrastructure rather than inventing a bespoke publish-only policy surface. - publish_rollback (tools/publishRollbackTool.ts): reads {appId, version}, runs the same synthetic-command policy check against 'rollback <appId>', then calls @omadia/publish's rollbackTo(). That function does not even accept a PublishRuntime, so this handler is structurally unable to trigger a new build/deploy. plugin.ts wiring: both tools registered behind sandbox_publish_enabled (independent of sandbox_execute_enabled — an operator can enable one without the other), disposed on deactivate via the same try/dispose-array-loop pattern as every other native-tool block in this file. PublishStore uses the shared graph pool (PostgresPublishStore) when configured, else falls back to InMemoryPublishStore (process-lifetime durability) — the tool is usable with zero extra Postgres setup, same posture InMemorySandboxRegistry documents for #576. Testing: 15 new tests (test/sandbox/publishTool.test.ts, publishRollbackTool.test.ts) mirroring executeTool.test.ts's shape — every refused path (deny/require_approval/policy-resolve-failure/ malformed input) asserted to touch NEITHER the sandbox backend NOR the runtime NOR the store's pointer, before the permitted-path tests assert the plumbing actually runs. Combined regression sweep (test/publish/** + test/sandbox/** + buildOrchestrator/orchestratorDispatcher/ orchestratorRegistry): 133/133 passing after a full rebuild. Mutation-checked (dist/ rebuilt between probes and after restore): disabling the rollback tool's deny-decision branch failed exactly the policy-check suite (5 pass / 1 fail); bypassing the publish tool's zod input validation failed exactly the malformed-input test (8 pass / 1 fail). check-core-decoupling.mjs held at 3296; test-typecheck ratchet held at 406 — both unchanged. Blast radius: additive-only within harness-orchestrator/src/plugin.ts (two new import blocks + one new flag-gated registration block + one new disposer loop, inserted next to the existing execute-tool block, nothing else in the file touched); two new tool files; two new test files; harness-orchestrator/package.json gained a peerDependency on @omadia/publish. No route, no UI, no existing tool's behavior changed. Nothing runs for any deployment that has not set sandbox_publish_enabled=true.
Weegy
added a commit
that referenced
this pull request
Aug 20, 2026
…/rollback (P3) (#790) * feat(#581): publish primitive P1 — version store + Docker runtime + origin-isolating gateway Issue #581 (publish primitive: agent-built internal web apps with immutable versions and rollback), phase P1 per the phase-4c plan: version store, Docker-backed runtime, and the origin-isolation gateway. No native tool and no route — that is P2, wired through harness-orchestrator's plugin.ts sandbox_execute_enabled seam per the plan. New package @omadia/publish (middleware/packages/harness-publish): - publishStore.ts / postgresPublishStore.ts: PublishStore has no update/delete method by design — a republish can never mutate an existing version's row, only create a new one. Postgres allocates version numbers via SELECT...FOR UPDATE on a per-app counter row inside the same transaction as the version insert, so concurrent publishes to one app serialize rather than collide; the (app_id, version) primary key is the second, independent backstop. rollbackTo (in publish.ts) only ever calls store.setPointer — its signature does not even take a PublishRuntime, so it cannot trigger a new build/deploy. - treeCollector.ts: reads the published directory only through Sandbox.list()/read() (already traversal-clamped by @omadia/sandbox), recursing on the paths list() itself returns — no raw filesystem path from agent input anywhere in this module. - dockerPublishRuntime.ts: one immutable container per (appId, version) (deploy() is a no-op if that version's container already exists), one Docker volume per appId mounted at in every version's container. That volume reuse is the entire mechanism behind the durability contract: a file outside lives in the version's own container and is gone the moment a new version's container replaces it; a file inside is on the shared volume and survives every redeploy. Verified against a real Docker daemon (SANDBOX_DOCKER_TEST=1 gate, #576's pattern) in dockerPublishRuntime.test.ts, including an end-to-end persistence proof and serving a real Node entrypoint over HTTP. - publishGateway.ts: the origin boundary published apps run behind. Rejects any request whose Host does not end in a dedicated apps suffix (including an exact match on the admin host) before ever resolving an app backend; strips Cookie/Authorization before proxying; strips any Set-Cookie the app tries to scope with an explicit Domain=. Tested with two plain http.Server instances (no Docker, no browser) proving a forwarded admin session cookie never reaches the app backend and a domain-scoped Set-Cookie from the app never reaches the client. Migration 0045_publish_versions.sql: publish_versions (app_id, version) PK, publish_apps holding the next-version counter and current-version pointer with a composite FK back to publish_versions so a pointer can only ever reference a version that genuinely exists. Testing: 32 tests in middleware/test/publish/ (stub-tier, always-on) all green; +10 more under SANDBOX_DOCKER_TEST=1 including the real-Docker round trip, also green. postgresPublishStore.pg.test.ts skips cleanly with no test Postgres configured (same convention as postgresSandboxRegistry.pg.test.ts). Combined with test/sandbox/**: 89/89 passing. Mutation-checked: reverting the gateway's Set-Cookie Domain= filter, DockerPublishRuntime's containerExists no-op guard, and treeCollector's maxFiles cap each independently fails exactly the test meant to catch it, confirmed with a dist/ rebuild between runs. check-core-decoupling.mjs: held at 3296 (unchanged). check-test-typecheck ratchet: held at 406 known errors (unchanged) — the new @ts-expect-error compile-time proof in publishStore.test.ts type-checks clean. Not in this PR (later phases per the plan): the publish/rollback native tools behind an operator flag, GrantStore-based sharing, and the optional admin version-list view. * feat(#581): publish/publish_rollback native tools behind sandbox_publish_enabled (P2) Stacked on feat/581-publish (P1: version store + Docker runtime + origin-isolating gateway, PR #785). Wires the #581 P1 library into two agent-callable native tools, following the #576 P2 execute-tool seam in harness-orchestrator/src/plugin.ts exactly (same file, same additive block shape, own flag) — no src/index.ts or agentBuilder.ts change. - publish (tools/publishTool.ts): reads {appId, name, dir, entrypoint}, provisions the CALLING TURN's scope sandbox (same resolveScopeKey posture as execute — 'publish läuft IMMER über den Sandbox des Turn-Scopes' per the plan), and calls @omadia/publish's publish(). Runs defaultCommandPolicy() against a synthetic 'publish <appId>' pseudo-command through the same decideCommand/ recordCommandPolicyOutcome machinery execute uses, before ever touching a sandbox or the version store — inert under the shipped org floor (no default rule matches a pseudo-command), wired for an operator who adds one. This is the conditional 'require_approval semantics IF the command policy concerns deploy commands' the phase plan asks for, reusing existing infrastructure rather than inventing a bespoke publish-only policy surface. - publish_rollback (tools/publishRollbackTool.ts): reads {appId, version}, runs the same synthetic-command policy check against 'rollback <appId>', then calls @omadia/publish's rollbackTo(). That function does not even accept a PublishRuntime, so this handler is structurally unable to trigger a new build/deploy. plugin.ts wiring: both tools registered behind sandbox_publish_enabled (independent of sandbox_execute_enabled — an operator can enable one without the other), disposed on deactivate via the same try/dispose-array-loop pattern as every other native-tool block in this file. PublishStore uses the shared graph pool (PostgresPublishStore) when configured, else falls back to InMemoryPublishStore (process-lifetime durability) — the tool is usable with zero extra Postgres setup, same posture InMemorySandboxRegistry documents for #576. Testing: 15 new tests (test/sandbox/publishTool.test.ts, publishRollbackTool.test.ts) mirroring executeTool.test.ts's shape — every refused path (deny/require_approval/policy-resolve-failure/ malformed input) asserted to touch NEITHER the sandbox backend NOR the runtime NOR the store's pointer, before the permitted-path tests assert the plumbing actually runs. Combined regression sweep (test/publish/** + test/sandbox/** + buildOrchestrator/orchestratorDispatcher/ orchestratorRegistry): 133/133 passing after a full rebuild. Mutation-checked (dist/ rebuilt between probes and after restore): disabling the rollback tool's deny-decision branch failed exactly the policy-check suite (5 pass / 1 fail); bypassing the publish tool's zod input validation failed exactly the malformed-input test (8 pass / 1 fail). check-core-decoupling.mjs held at 3296; test-typecheck ratchet held at 406 — both unchanged. Blast radius: additive-only within harness-orchestrator/src/plugin.ts (two new import blocks + one new flag-gated registration block + one new disposer loop, inserted next to the existing execute-tool block, nothing else in the file touched); two new tool files; two new test files; harness-orchestrator/package.json gained a peerDependency on @omadia/publish. No route, no UI, no existing tool's behavior changed. Nothing runs for any deployment that has not set sandbox_publish_enabled=true. * feat(#581): publish sharing via GrantStore — read=use, write=redeploy/rollback (P3) Stacked on feat/581-publish-p2-tools (P2: publish/publish_rollback native tools, PR #786). Wires the #576/#575 GrantStore sharing model into P2's tool handlers, matching the issue text: "Sharing per scope grant (read = use, write = redeploy/rollback)". - tools/publishAccess.ts (new): checkPublishAccess() — the core decision. Ownership is scope-key equality against an app's version-1 sourceScopeKey (already recorded by P1, nothing new to store): the owner needs NO grant lookup at all, so sharing cannot lock out the owner by construction, not by a special-cased bypass. A brand-new appId with no version yet is allowed unconditionally — the first publish call establishes ownership. For everyone else, resolves a Principal from the caller's session scope (only personal:<userId> scopes map to one — the same omadia-user-id space resolveOrCreateChannelIdentity uses per #575/#333) and checks resolveCapabilities() for a publish:read:<appId> / publish:write:<appId> capability, denials winning over grants (same rule audienceFloor.ts/ skillSharing.ts apply). Fails CLOSED on every unresolvable case (non-personal scope, partial role lookup, throwing store) — the opposite failure direction from skillSharing.ts's deliberately fail-open default, because here an unresolved lookup reading as "granted" would let an unrelated scope redeploy or roll back someone else's app. Also ships createGrantCheckedResolveTarget() — the read-gated counterpart for PublishGateway.resolveTarget — as a tested, ready primitive; NOT wired into a live server (the anonymous, origin-isolated P1 gateway strips caller identity by design, so an authenticated caller has to come from wherever #778 builds one). - tools/publishGrantedTools.ts (new): createGrantCheckedPublishHandler / createGrantCheckedPublishRollbackHandler wrap P2's createPublishHandler/createPublishRollbackHandler with a write-capability check before delegating. Neither P2 file's body is modified — one additive `export { resolveScopeKey }` line at the end of publishTool.ts is the only change there, so the wrapper resolves the SAME scope key the inner handler provisions its sandbox under instead of a third copy-pasted implementation. grants.ts itself (#575) is untouched — consumed only via its exported GrantStore/resolveCapabilities contract, same posture skillSharing.ts documents. - plugin.ts: when audienceGrants (the GrantStore #575 already publishes as a service) is configured, both tools register the grant-checked handlers; otherwise the raw P2 handlers run exactly as before — no behavior change for a deployment that has not opted into grants. RoleSourceRegistryImpl here is a fresh, empty registry (documented as a known v1 gap: no role-source registry is published as a shared service ANYWHERE in this codebase yet, Orchestrator builds its own private one the same way) — direct grants work fully, role grants resolve to nothing until that changes. Testing: 26 new tests. publishAccess.test.ts (17) proves both directions explicitly for every rule — owner needs no grant (write AND read), a fresh appId is open, a non-owner with no grant is denied (write and read), a write grant does not imply read and vice versa, a grant for a DIFFERENT appId does not leak, a direct denial beats a direct grant, a role grant covers any holder, a non-personal scope is denied even with a matching grant on file, and a throwing role source fails closed even though a direct grant alone would have sufficed. publishGrantedTools.test.ts (9) exercises the wrappers end-to-end through the real native-tool handler shape: the owner republishes/rolls back its own app with ZERO grants configured (sharing cannot lock out the owner); a denied call returns an explicit refusal string naming the app and touches neither the sandbox backend nor the runtime nor the store's pointer (never a silent no-op); a write grant lets a non-owner both publish and roll back; a read grant is proven NOT sufficient for rollback. Combined regression sweep (test/publish/** + test/sandbox/** + buildOrchestrator/orchestratorDispatcher/orchestratorRegistry + skillSharing + audienceFloor, after a full npm run build): 189/189 passing. Mutation-checked (dist/ rebuilt between probes and after restore): removing the denials-win check failed exactly the denial-direction suite (16 pass / 1 fail); disabling ownership equality (treating every caller as the owner) failed 6 of 6 affected suites (12 pass / 14 fail) — the strongest possible signal these tests are load-bearing; bypassing the publish wrapper's grant check entirely failed exactly its own suite (8 pass / 1 fail). All three restored, full suite re-verified green after rebuild. check-core-decoupling.mjs: held at 3296 (unchanged). test-typecheck ratchet: held at 406 (unchanged) — required switching the two new test files' GrantStore/RoleSourceRegistry/InMemoryGrantStore imports from a relative packages/harness-channel-sdk/src path to the '@omadia/channel-sdk' package specifier, matching skillSharing.test.ts's existing convention: importing a class with private fields (RoleSourceRegistry, InMemoryGrantStore) from its src/ path while the code under test receives it via the package's dist/ declaration makes TypeScript see two nominally distinct types even though they're the same source. Blast radius: two new files in harness-orchestrator/src/tools/, two new test files. plugin.ts gains one new conditional branch inside the existing sandbox_publish_enabled block (picks the grant-checked handler when audienceGrants is configured, the raw P2 handler otherwise) — no other block in the file touched. publishTool.ts gains exactly one additive re-export line. grants.ts (#575), publishTool.ts's/ publishRollbackTool.ts's existing bodies, and every P1 file are untouched. No route, no UI, no migration. Nothing changes for a deployment that has not set sandbox_publish_enabled=true, and — within that — nothing changes for one that has not also wired a GrantStore.
This was referenced Aug 20, 2026
Weegy
added a commit
that referenced
this pull request
Aug 20, 2026
…grations C4 / H1 landed on main while this branch was being re-based on C2b. The two touch the same manifest surface, and — as C7's spec note predicted — the overlap resolves as unions rather than choices. - src/plugins/manifestLoader.ts: seven conflicts, all unions. `parseSqlPermission` and the `publicPathGrants` imports both kept; the permissions record carries both `sql:` and `public_paths:`; KNOWN_PERMISSION_KEYS is the union `public_paths` + `secrets` + `sql`; C4's `extractPublicPaths` kept whole. Main's wording wins on the unknown-key warning; C7's wording wins on the two doc comments, because it names the retired key's NOTE and is not specific to one permission. - specs/470-dev-platform-plugin/README.md: both sections kept, ordered C1 -> C4 -> C7 -> C8. Renumbered the C7 migration 0045 -> 0047. Main took 0045 for `publish_versions` (#785) and 0046 for `plugin_public_path_grants` (C4), so `0045_plugin_sql_grants.sql` would have shipped as a second 0045. The runner sorts on the full filename and would have applied both, but a colliding number is a trap for the next author; all five prose references moved with the file. Verified against the C4-inclusive main: build, typecheck, typecheck:test (406, baseline 406), lint, api:check, core-decoupling ratchet 3300 (unchanged from main's baseline), pg suites 340/340 serial as CI runs them.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Phase P1 of issue #581 (publish primitive: agent-built internal web apps with immutable versions and rollback), per the phase-4c plan (
docs/plans/phase4c-581-publish-prompt-2026-08-20.md). Builds on the #576 sandbox stack (@omadia/sandbox, merged to main).This PR ships the library substrate only — version store, Docker runtime, origin-isolation gateway. No native tool, no route, no flag. That is P2 (stacked on this branch), wired through
harness-orchestrator/src/plugin.ts'ssandbox_execute_enabled-style seam per the plan.What's in this PR
New package
@omadia/publish(middleware/packages/harness-publish):publishStore.ts/postgresPublishStore.ts—PublishStorehas no update/delete method by design: a republish can never mutate an existing version row, only create a new one. Postgres allocates version numbers viaSELECT ... FOR UPDATEon a per-app counter row inside the same transaction as the version insert, so concurrent publishes to one app serialize rather than collide; the(app_id, version)primary key is the independent backstop.publish.ts—publish()orchestrates collect → hash → createVersion → deploy → setPointer.rollbackTo()calls onlystore.setPointer— its signature doesn't even accept aPublishRuntime, so it structurally cannot trigger a new build/deploy.treeCollector.ts— reads the published directory only throughSandbox.list()/read()(already traversal-clamped by@omadia/sandbox), recursing on pathslist()itself returned. No raw filesystem path from agent input anywhere in this module.dockerPublishRuntime.ts— one immutable container per(appId, version)(deploy()is a no-op if that version's container already exists); one Docker volume perappId, mounted at$DATA_DIRin every version's container. That volume reuse is the entire$DATA_DIRdurability mechanism: a file outside$DATA_DIRlives in the version's own container and is gone the moment a new version replaces it; a file inside$DATA_DIRis on the shared volume and survives every redeploy.publishGateway.ts— the origin boundary published apps run behind. Rejects any request whoseHostdoes not end in a dedicated apps suffix (including an exact match on the admin host) before ever resolving an app backend; stripsCookie/Authorizationbefore proxying; strips anySet-Cookiethe app tries to scope with an explicitDomain=.Migration
0045_publish_versions.sql:publish_versions (app_id, version)PK;publish_appsholds the next-version counter and current-version pointer with a composite FK back topublish_versions, so a pointer can only ever reference a version that genuinely exists.Why the origin-isolation design
Cookies are scoped by the browser to a request's host, not its port (RFC 6265). Serving a published app on the admin/portal's own hostname — even on a different port — would let the app read the admin session cookie and set cookies the admin origin would honor.
PublishGatewayis a hard boundary: distinct host-suffix required, cookies stripped both directions.Testing
middleware/test/publish/(stub tier, always on): store immutability incl. a concurrent-publish race test, tree collector,publish/rollbackToorchestration (incl. a "rollback triggers no new deploy call" assertion), and the gateway origin-isolation suite (two plainhttp.Servers, no Docker/browser needed — proves a forwarded admin cookie never reaches the app backend and a domain-scopedSet-Cookiefrom the app never reaches the client).SANDBOX_DOCKER_TEST=1(opt-in, Durable per-scope sandbox with an execute tool #576's pattern): real container deploy + HTTP serve of a Node entrypoint, and an end-to-end$DATA_DIRround trip (file outside$DATA_DIRgone after redeploy; file inside survives). Ran locally against a real Docker daemon — all pass, containers/volumes cleaned up after.publishStore.pg.test.tsskips cleanly with no test Postgres configured (same convention aspostgresSandboxRegistry.pg.test.ts).test/publish/** + test/sandbox/**: 89/89 passing, exit 0.dist/between each probe and after restoring, since the orchestrator consumes@omadia/sandbox/@omadia/publishfromdist/):Set-Cookie Domain=filter → the isolation suite failed exactly the "strips a Set-Cookie..." test (5 pass / 1 fail).DockerPublishRuntime'scontainerExistsno-op guard → the "no-op when already deployed" test failed (7 pass / 1 fail).treeCollector'smaxFilescap → the "throws PublishTreeTooLargeError" test failed (5 pass / 1 fail).node scripts/check-core-decoupling.mjs: held at 3296 (unchanged — nodev-runner-family literals touched, including in comments).npm run typecheck:test(test-typecheck ratchet): held at 406 known errors (unchanged) — the new@ts-expect-errorcompile-time proof inpublishStore.test.ts(assertingPublishStorehas noupdateVersion/deleteVersion) type-checks clean.npm run build(root + all workspace packages incl. the new one) andnpm run typecheck -w @omadia/publish: clean.npx eslint packages/harness-publish/src/: clean, no output.Blast radius
middleware/package.jsonbuild/dev/typecheck/lint script chains got@omadia/publishadded (mirroring every other workspace package's registration) — no other repo file changed.CREATE TABLE IF NOT EXISTS), applied in filename order by the existing multi-orchestrator migrator — no other migration or registration list needed updating (confirmed againstmiddleware/migrations/README.md).src/index.ts,agentBuilder.ts,harness-orchestrator/src/plugin.ts,GrantStore, or any credential/skill/sandbox source was modified — this phase is purely additive library code, consumed nowhere yet.Migration number
0045 (
middleware/migrations/0045_publish_versions.sql) — confirmed next-free after 0044 (sandbox_registry, #576 P3).Open questions for Marcel
node:20-alpine,PORT/DATA_DIRenv convention). A "static site" publish is just a small Node script serving its own files — there's no separate static-file-serving code path. Good enough for v1, or worth a dedicated static mode before P2?PublishGatewaytakesappsHostSuffixas a constructor option; actual DNS/reverse-proxy wiring for a real apps domain is an operator/deployment decision for P2 or later, not decided here.Stack
Base:
origin/main(post-#576 merge, includes the #576 P1–P3 sandbox stack). This is the first PR in the #581 stack — P2 (native tools behind a flag), P3 (GrantStore sharing), and optional P4 (admin version list) will stack on top per the phase-4c plan.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.