Skip to content

feat(#581): publish sharing via GrantStore — read=use, write=redeploy/rollback (P3) - #790

Merged
Weegy merged 5 commits into
mainfrom
feat/581-publish-p3-sharing
Aug 20, 2026
Merged

feat(#581): publish sharing via GrantStore — read=use, write=redeploy/rollback (P3)#790
Weegy merged 5 commits into
mainfrom
feat/581-publish-p3-sharing

Conversation

@Weegy

@Weegy Weegy commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Summary

Phase P3 of issue #581 (publish primitive), stacked on #786 (P2: publish/publish_rollback native tools behind sandbox_publish_enabled). Base branch: feat/581-publish-p2-tools (not main).

Wires the existing GrantStore (#575/#576) into P2's tool handlers, matching the issue text exactly: "Sharing per scope grant (read = use, write = redeploy/rollback)".

What's in this PR

  • tools/publishAccess.ts (new) — checkPublishAccess(), the core decision:
    • Ownership is scope-key equality against the 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 the owner out of their own app by construction, not by a special-cased bypass.
    • A brand-new appId with no version yet is allowed unconditionally — the very first publish call is what 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 Scope model with audience-floor permission intersection for shared rooms #575/Identity & Role Projection — omadia as a team-player in the existing IT landscape (Principal = user|role everywhere) #333), then checks resolveCapabilities() for a publish:read:<appId> / publish:write:<appId> capability, with denials winning over grants (same rule audienceFloor.ts/skillSharing.ts apply).
    • Fails CLOSED on every unresolvable case (non-personal: scope, partial role lookup, a throwing grant 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 (that's the whole point of the origin-isolation boundary), so an authenticated caller has to come from wherever Wire the #577/#578 service layers to their surfaces: routes, agent tools, admin UIs #778 builds one. Publishing it here rather than leaving the read-share model half-built.
  • 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 — the only change to publishTool.ts is one additive export { resolveScopeKey }; line at the end, so the wrapper resolves the exact same scope key the inner handler provisions its sandbox under. grants.ts itself is untouched — consumed only via its exported GrantStore/resolveCapabilities contract, same posture skillSharing.ts documents.
  • plugin.ts — when audienceGrants (the GrantStore Scope model with audience-floor permission intersection for shared rooms #575 already publishes as a service) is configured, both tools register the grant-checked handlers; otherwise the raw P2 handlers run exactly as before. 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) — so direct grants work fully today, role grants resolve to nothing until that's wired elsewhere.

Testing

  • 26 new tests.
    • publishAccess.test.ts (17): owner needs no grant for both write and read; a fresh appId is open to anyone; a non-owner with no grant is denied for both 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 of that role; a non-personal: scope is denied even with a matching grant on file; a throwing role source fails closed even though a direct grant alone would otherwise have sufficed.
    • publishGrantedTools.test.ts (9): exercises the wrappers end-to-end through the real native-tool handler shape (string in, string out). The owner republishes/rolls back its own app with zero grants configured anywhere — proving 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 + the new plugin-api API-snapshot test from the merged main), after a full npm run build: 190/190 passing, exit 0.
  • Mutation-checked (dist/ rebuilt between probes and after restoring):
    • Removed the "denials win" check → failed exactly the denial-direction suite (16 pass / 1 fail).
    • Disabled ownership equality (treated every caller as the owner) → failed 6 of 6 affected suites (12 pass / 14 fail) — the strongest possible signal these tests are load-bearing.
    • Bypassed 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.
  • node scripts/check-core-decoupling.mjs: held at 3296 (unchanged).
  • npm run typecheck:test: held at 406 known errors (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 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 it's the same source.
  • Full npm run build and npx eslint on every changed/new file: clean.

Blast radius

  • Two new files in harness-orchestrator/src/tools/, two new test files.
  • plugin.ts: 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: exactly one additive re-export line (export { resolveScopeKey };).
  • grants.ts (Scope model with audience-floor permission intersection for shared rooms #575), publishRollbackTool.ts's existing body, and every P1 file: untouched.
  • No route, no UI, no migration.
  • Nothing changes for a deployment that hasn't set sandbox_publish_enabled=true, and — within that — nothing changes for one that hasn't also wired a GrantStore.

Open questions for Marcel

Carried over from #785/#786 (runtime image scope, stale-version cleanup, apps-host/reverse-proxy config, the synthetic pseudo-command policy shape), plus:

  1. createGrantCheckedResolveTarget has no live caller. It's a tested, correct primitive for the read-share half of the model, but wiring it into an actual authenticated request path is Wire the #577/#578 service layers to their surfaces: routes, agent tools, admin UIs #778's job (route/UI), not something this PR could honestly claim to finish — flagging so it doesn't get lost between phases.
  2. Role-based publish grants are inert today (empty RoleSourceRegistry, since no shared role-source registry exists anywhere in the codebase yet) — direct grants (grantToPrincipal) work fully. Worth a follow-up once a shared registry exists, or intentionally out of scope for publish specifically?
  3. Per your instruction, P4 (admin view) was explicitly NOT started — stays in Wire the #577/#578 service layers to their surfaces: routes, agent tools, admin UIs #778.

Stack

Base: feat/581-publish-p2-tools (#786, P2), which bases on feat/581-publish (#785, P1). This is the third and (per your brief) final PR in the #581 stack before the merge chain runs.


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

Weegy added 4 commits August 20, 2026 16:49
…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.
…/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
Weegy changed the base branch from feat/581-publish-p2-tools to main August 20, 2026 15:52
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
Weegy merged commit 6a2ebbd into main Aug 20, 2026
9 checks passed
@Weegy
Weegy deleted the feat/581-publish-p3-sharing branch August 20, 2026 16:03
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