Skip to content

feat(#581): publish primitive P1 — version store + Docker runtime + origin-isolating gateway - #785

Merged
Weegy merged 2 commits into
mainfrom
feat/581-publish
Aug 20, 2026
Merged

feat(#581): publish primitive P1 — version store + Docker runtime + origin-isolating gateway#785
Weegy merged 2 commits into
mainfrom
feat/581-publish

Conversation

@Weegy

@Weegy Weegy commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

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's sandbox_execute_enabled-style seam per the plan.

What's in this PR

New package @omadia/publish (middleware/packages/harness-publish):

  • publishStore.ts / postgresPublishStore.tsPublishStore has no update/delete method by design: a republish can never mutate an existing version 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 independent backstop.
  • publish.tspublish() orchestrates collect → hash → createVersion → deploy → setPointer. rollbackTo() calls only store.setPointer — its signature doesn't even accept a PublishRuntime, so it structurally 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 paths list() 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 per appId, mounted at $DATA_DIR in every version's container. That volume reuse is the entire $DATA_DIR durability mechanism: a file outside $DATA_DIR lives in the version's own container and is gone the moment a new version replaces it; a file inside $DATA_DIR is on the shared volume and survives every redeploy.
  • 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=.

Migration 0045_publish_versions.sql: publish_versions (app_id, version) PK; publish_apps holds 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.

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. PublishGateway is a hard boundary: distinct host-suffix required, cookies stripped both directions.

Testing

  • 32 tests in middleware/test/publish/ (stub tier, always on): store immutability incl. a concurrent-publish race test, tree collector, publish/rollbackTo orchestration (incl. a "rollback triggers no new deploy call" assertion), and the gateway origin-isolation suite (two plain http.Servers, no Docker/browser needed — proves a forwarded admin cookie never reaches the app backend and a domain-scoped Set-Cookie from the app never reaches the client).
  • +10 more under 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_DIR round trip (file outside $DATA_DIR gone after redeploy; file inside survives). Ran locally against a real Docker daemon — all pass, containers/volumes cleaned up after.
  • publishStore.pg.test.ts skips cleanly with no test Postgres configured (same convention as postgresSandboxRegistry.pg.test.ts).
  • Combined test/publish/** + test/sandbox/**: 89/89 passing, exit 0.
  • Mutation-checked (rebuilt dist/ between each probe and after restoring, since the orchestrator consumes @omadia/sandbox/@omadia/publish from dist/):
    • Reverted the gateway's Set-Cookie Domain= filter → the isolation suite failed exactly the "strips a Set-Cookie..." test (5 pass / 1 fail).
    • Reverted DockerPublishRuntime's containerExists no-op guard → the "no-op when already deployed" test failed (7 pass / 1 fail).
    • Reverted treeCollector's maxFiles cap → the "throws PublishTreeTooLargeError" test failed (5 pass / 1 fail).
    • All three restored; full suite re-verified 32/32 green after rebuild.
  • node scripts/check-core-decoupling.mjs: held at 3296 (unchanged — no dev-runner-family literals touched, including in comments).
  • npm run typecheck:test (test-typecheck ratchet): held at 406 known errors (unchanged) — the new @ts-expect-error compile-time proof in publishStore.test.ts (asserting PublishStore has no updateVersion/deleteVersion) type-checks clean.
  • Full npm run build (root + all workspace packages incl. the new one) and npm run typecheck -w @omadia/publish: clean.
  • npx eslint packages/harness-publish/src/: clean, no output.

Blast radius

  • New package, new migration, no existing runtime code touched. middleware/package.json build/dev/typecheck/lint script chains got @omadia/publish added (mirroring every other workspace package's registration) — no other repo file changed.
  • Migration 0045 is additive (CREATE TABLE IF NOT EXISTS), applied in filename order by the existing multi-orchestrator migrator — no other migration or registration list needed updating (confirmed against middleware/migrations/README.md).
  • Nothing in 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.
  • No native tool, no route, no operator flag — nothing changes for any existing deployment until P2 wires a tool behind a flag.

Migration number

0045 (middleware/migrations/0045_publish_versions.sql) — confirmed next-free after 0044 (sandbox_registry, #576 P3).

Open questions for Marcel

  1. Runtime image: v1 supports only a Node entrypoint (node:20-alpine, PORT/DATA_DIR env 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?
  2. Stale version cleanup: every version keeps its own container running forever (immutable, but also never torn down). There's no reaper analog for publish versions in this PR — deliberately out of scope, but worth an issue before this sees real traffic.
  3. Apps host suffix / reverse-proxy config: PublishGateway takes appsHostSuffix as 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.


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

…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.
@Weegy
Weegy merged commit 0b73ca1 into main Aug 20, 2026
9 checks passed
@Weegy
Weegy deleted the feat/581-publish branch August 20, 2026 15:44
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.
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant