feat(#581): publish/publish_rollback native tools behind sandbox_publish_enabled (P2) - #786
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.
…ish_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
Conflicts in plugin.ts and publishTool.ts: main carries P2's squash (#786), this branch carries P2 plus P3's deliberate evolution (grant-checked handler registration; the additive resolveScopeKey export). Verified via git log <merge-base>..origin/main that only the #786 squash touched either file since this branch diverged, so the branch side is the correct resolution. Full-tree conflict-marker sweep: clean.
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
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 P2 of issue #581 (publish primitive), stacked on #785 (P1: version store, Docker runtime, origin-isolating gateway). Base branch:
feat/581-publish(notmain).Wires the P1 library (
@omadia/publish) into two agent-callable native tools,publishandpublish_rollback, both behind a new operator flagsandbox_publish_enabled— following the #576 P2execute-tool seam inharness-orchestrator/src/plugin.tsexactly (same file, same additive block shape). No route, no UI, nosrc/index.ts/agentBuilder.tschange — that's #778.What's in this PR
publishtool (tools/publishTool.ts) — input{appId, name, dir, entrypoint}. Provisions the calling turn's own scope sandbox (same posture asexecute: "publish always runs against the turn-scope sandbox" per the plan) and calls@omadia/publish'spublish(). Before touching anything, runsdefaultCommandPolicy()against a syntheticpublish <appId>pseudo-command through the exact samedecideCommand/recordCommandPolicyOutcomemachineryexecuteuses. No default org-floor rule matches a pseudo-command like this, so it's inert today — wired for an operator who wants to gate publishing with a real policy rule, satisfying the plan's conditional "require_approvalsemantics falls Command-Policy Deploy-Kommandos betrifft" without inventing a bespoke policy surface.publish_rollbacktool (tools/publishRollbackTool.ts) — input{appId, version}. Same synthetic-command policy check (rollback <appId>), then callsrollbackTo(). That function doesn't even accept aPublishRuntimeargument — this handler is structurally unable to trigger a rebuild no matter what.plugin.tswiring — both tools registered behindsandbox_publish_enabled(independent ofsandbox_execute_enabled; an operator can enable one without the other), disposed ondeactivatevia the same try/dispose-array-loop every other native-tool block in this file uses.PublishStorepicksPostgresPublishStorewhen the shared graph pool is configured, else falls back toInMemoryPublishStore— the tool works out of the box with zero extra Postgres setup (process-lifetime durability only), same postureInMemorySandboxRegistrydocuments for Durable per-scope sandbox with an execute tool #576.Testing
test/sandbox/publishTool.test.ts,publishRollbackTool.test.ts), same shape asexecuteTool.test.ts: every refused path (deny / require_approval / policy-resolve-failure / malformed input) asserts NEITHER the sandbox backend NOR the runtime NOR the store's pointer was touched, before the permitted-path tests assert the plumbing actually runs end to end (realpublish()/rollbackTo()against anInMemoryPublishStore).test/publish/**+test/sandbox/**+buildOrchestrator/orchestratorDispatcher/orchestratorRegistry): 133/133 passing, exit 0, after a fullnpm run build.dist/between probes and after restoring): 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). Both restored, full suite re-verified green after rebuild.node scripts/check-core-decoupling.mjs: held at 3296 (unchanged).npm run typecheck:test: held at 406 known errors (unchanged).npx eslint packages/harness-orchestrator/src/plugin.ts packages/harness-orchestrator/src/tools/publishTool.ts packages/harness-orchestrator/src/tools/publishRollbackTool.ts: clean, no output.npm run buildandnpm run typecheck -w @omadia/orchestrator: clean.Blast radius
plugin.ts: two new import blocks + one new flag-gated registration block (mirrors theexecuteblock immediately above it) + one new disposer loop indeactivate. Nothing else in the file touched — no existing tool's registration, spec, or behavior changed.harness-orchestrator/package.jsongained apeerDependencyon@omadia/publish.sandbox_publish_enabled=true— same honest-inert convention assandbox_execute_enabled.Open questions for Marcel
Same three as #785 (runtime image scope, stale-version cleanup, apps-host/reverse-proxy config), plus:
publish <appId>/rollback <appId>) the right shape, or shouldpublish/rollbackget their own dedicated policy hook distinct fromdecideCommand's shell-command-oriented matching? I reused the existing machinery deliberately (no new policy surface), but the string isn't a real shell command — flagging in case that reuse reads as a stretch rather than a fit.Stack
Base:
feat/581-publish(#785, P1). This PR does not touchmaindirectly. P3 (GrantStore-based sharing/rollback rights) will stack on top of this per the phase-4c plan.Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.