Skip to content

Remove Team Token PR2: Runtime actions use validated access paths - #594

Open
josue-tech-94 wants to merge 359 commits into
devfrom
feat/user-centric-runtime-access-paths
Open

josue-tech-94 wants to merge 359 commits into
devfrom
feat/user-centric-runtime-access-paths

Conversation

@josue-tech-94

@josue-tech-94 josue-tech-94 commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

PR2: runtime actions use validated access paths

PR2 of the Team Token removal (Task 106). It adds server-validated, user-centric access paths for runtime actions (workflows, GFS uploads,
RPC delegations, approval decisions). V2 stays off: actionContextV2 and rpcDelegationV2 read
CONTROL_API_USER_ACCESS_ACTION_CONTEXT_V2 and CONTROL_API_USER_ACCESS_RPC_DELEGATION_V2, default false
(control-api/src/services/access/userAccessPolicy.ts:213-214), and no manifest under deploy/ or charts/ sets them. Publishing this
head is not a merge or deployment approval.

Status

  • Head: aa8e6a877, a fast-forward of the previously published heads 054a382a1, b22ceb213, 72f49fb99, 603103d92, 5a2a1333c, 3f28a505f and
    14100a24f (and of the earlier c64989e7c, a7d282ace, 378d2cdcf, 0d1c00106, 73fcf94c8, bd86ac5c7).
  • Base: dev at 193f5f4a7 (merged in aa8e6a877). If dev moves again, it is merged once more before T2.
  • Hosted CI on 3f28a505f and 5a2a1333c failed the control-api and Real PostgreSQL jobs: 3f28a505f made gfs-controller and
    workspace-files-controller import @clerum/action-context-contracts by package name, and those jobs do not install either
    service's dependencies. 36071cc1a restores the relative import. Two other failures on 5a2a1333c are in tests no commit since
    3f28a505f touches, where they passed: the T2 stale-lock race test (test(minikube): T2 stale-lock race test intermittently admits two owners in CI #1254) and the egress proxy TLS contract
    (startup_no_live_upstreams=1).
  • Build & Publish failed with no jobs on every push of this branch: the "Build and push by digest" step declared build-args
    twice since the merge 3c501d7ba, which combined dev's profile-ui argument (2d9f0fe0b) with this branch's
    EVENFIRE_* arguments (286537793) in two different hunks. Merged into dev it would have stopped dev's Build & Publish.
    603103d92 keeps one key; actionlint passes.
  • A blind review of 72f49fb99 (five lanes) and the external review of 14100a24f were triaged together; the commits after
    9622b5c7d fix the four findings that change V1 behaviour and the false sentences they found (see "Verification").
  • Not done yet: T2 on minikube, and the measurements listed under "Before merge".

What changed since c64989e7c

PR1 (#334) landed in dev in a reduced form: the aggregate access catalog, the operational resource index and its indexer were removed.
PR2 was rebuilt on that dev by merge (no rebase, no force-push). Read the first-parent commits; the full diff against dev also contains
everything PR2 already had.

Commit Change
d0c53e89b Merge dev d02fcb9f1 (PR1 #334). Deletes the access catalog files PR2 had inherited. Does not compile alone; the next commits restore it.
21cc4ce5b external-rest-api: restore delegation error forwarding in the route catches (10 call sites).
402877017 control-api: restore from c64989e7c the action authorization modules dev removed with the catalog (resolver, candidates, projection, reader, access paths, resource identity, execution budget, the two V2 flags). Of the access routes only GET /external/access/capabilities and the edge GET /me/access/capabilities stay; POST /external/access/resolve and the catalog routes are gone.
05e8492b2 control-api: renumber PR2 migrations after 0138 (see "Migrations").
af8fa56bb, 172d2bd35 control-api: check bound rows once per workflow authority, inside the transaction; V1 GFS session fixture gets its authority context.
fb3ed37f9, 7426f8d37 control-api: load the access graph live from Kubernetes, as dev does, instead of from the removed index; tests against a live Kubernetes gateway.
84da26356, 76303560d, 901ff3b0e, 3054f2272 workflow-recipes calls the authority checkpoint outside the run transaction; action checkpoints are bounded to the request; the workflow read-binding schema is dropped; provider decisions and OAuth exclusion are pinned by tests.
c84210bc8 Lock the rows that approval decisions and consumes depend on.
2c8da993e control-api: bound the action checkpoint (time and pooled client), two-key advisory locks for PR2's keys, and fail loud on readiness instead of blanket catches.
be36aab83 gfs-controller: survive Postgres connection errors on pooled clients (pool.on('error') and a client error listener).
1c98b2718, 5b346af47, 3654241be, 4621af067, 6083919e3 workflow-recipes keeps durable boundary answers when the run store or the recipe patch fails (bounded wait-and-retry when the running-report checkpoint cannot reach the database); test ordering fix for a flake; SDK exports documented.
cbe1f2fe1, d8d0af11a control-api test fixtures and the Real PostgreSQL suite contract aligned with the changes above; each workflow authority case starts with no user sessions (fixes an intermittent failure).
a2900818d, 0f13db2c0 Merge dev 18052d9ce and e611ebfcb. Version conflicts resolved to the higher side.
05c17a517 deploy: stop provisioning gfs-controller-service-token, workspace-files-controller-service-token and RPC_PROXY_MCP_HOST_EDGE_TOKEN. No call uses them with V2 off; they move to PR3. The token script, networkpolicies.yaml and the two HCC templates are byte-identical to dev.
eb3d2401f ci: count PR2's online indexes in the migration bounds check.
76ba77d13, c01a0bdef minikube: the legacy-compatible RPC Proxy edge rollout gate accepts the shipped Proxy label, fixes an empty-array case under bash 3.2 and a flake; CI now runs those shell tests.
a7d282ace Remove two Real PostgreSQL test helpers with no caller (CodeQL alerts 1835 and 1937).
561effa95 Merge dev 4fd7b2878 (#1425: desktop-app only, 33 files, no migration); no conflicts.
378d2cdcf Bump mcp-host to 0.1.633 and host-context-controller to 0.1.427: the e611ebfcb merge had left both at dev's numbers.
0d1c00106 Merge dev 05419da54 (#1434). The only conflict was the mcp-host version line, resolved to 0.1.636.
ce36151d4 workflow-recipes: remove five dead inTransaction = false stores before returns (CodeQL alerts 2014–2018); a new test pins the statement that ends the transaction on each early return of the locked boundary step, which no test caught before; the wire test's mergePatch helper rejects __proto__ and constructor keys (alert 2007).
73fcf94c8 mcp-host tests: four files ran the real checkpoint producer (a tsx child process) inside test bodies and timed out under CPU load. Each file now runs it once per request in a beforeAll, with a 240 s limit per child, and rethrows a recorded failure only in the tests that use it. No test title, body or assertion changes.
bd86ac5c7 workflow-recipes test workflowRunStoreErrors.test.ts: the afterAll hook timed out in CI because a socket the pool opened late to the silent loopback server was never destroyed; the sockets are now destroyed after every listener stops. The closing-server capture now reads the startup packet before closing, so macOS no longer reports ECONNRESET instead of an orderly close. Captured errors and verdicts unchanged.
14100a24f Merge dev 61f32ae04 (#1448: CI check for tracked files that a .gitignore excludes). No conflict; no service code.
edb622cea Merge dev 0ca497d37 (#1445, #1465, #1467). Version lines resolved to the higher side; the release manifest is synced to v0.12.1; duplicate path filters the merge left in build-publish.yml are removed.
3f28a505f gfs-controller and workspace-files-controller images build from the repository root and copy packages/action-context-contracts into both stages. Their relative import of that package did not resolve inside an image built from the service directory (TS2307), so dev's Build & Publish would have failed after the merge. The import change in this commit is reverted by 36071cc1a.
cd6a00267 workflow-recipes: a run with no authority binding (every V1 run) is decided at the running boundary as on dev, by recipe status, whatever its run row phase; a report for a run with no row is still refused. A bound run keeps the phase check.
ab3d45491 rpc-proxy: RPC_PROXY_MCP_SERVER_NAMESPACE defaults to mcp-server, as control-api's own setting does, instead of stopping the boot in production when a manifest older than the image does not set it. Only the V2 path reads it, and its destination check fails closed on a wrong value.
27c05b014 channel-reader: no behaviour change. Telegram already skipped the synchronous re-submit after the Host accepted the async POST (since 4cf9a833c); this commit corrects the log line and the comment and adds a test that pins the skip.
5a2a1333c Docs and comments: dev's notes on migrations 0115, 0116 and 0121 restored in control-api/src/db.ts; MCP_HOST_ACTIVITY_MAX_EVENT_BYTES must be set to the same value on rpc-proxy and the Host; gfs-controller images of this release need migration 0140 before they run.
36071cc1a gfs-controller and workspace-files-controller import packages/action-context-contracts by relative path again, as this branch did before 3f28a505f, and drop the file: dependency 3f28a505f added. The root-context image build stays.
603103d92 ci: one build-args key in Build & Publish (see "Status").
72f49fb99 Merge dev 22dd4213c (#1460). The only conflict was the workflow-recipes version line, resolved to the branch side; the version hook then set 0.1.500.
9622b5c7d Merge dev 34f14dd37 (#1135, status-report bounding). dev now reads each WRC error answer once as text; this branch's 423 pause and 503 run-store checks read the body again and would have stopped detecting both codes, so they now parse the same text. Two tests pin the 423 detection through the reporter, which had none.
6fa26f189 workflow-recipes: the durable running boundary runs only for runs with an authority binding, which the child recipe carries as the label clerum.io/workflow-run-authority: bound. Every V1 running report is decided by recipe status again, without reading Postgres. The upgrade note says workflow-recipes needs migrations 0139 and 0143.
7d45f0012 mcp-host: the direct-service limit no longer charges the timer-driven reads and their acknowledgements (cron results and cron result ack, notification claim and its ack/fail, task result) or the progress stream of an accepted task; eight routes stay charged.
c9df7490b channel-reader: remove the admission-limit handling of the routes the Host no longer limits; the polling loop is dev's again.
b4566e383 workflow-approval-request-reader: recognise the Host's admission limit; synchronous decisions and enrollments answer 429 with Retry-After; a Slack or Teams decision is sent again once, in the background, after the Host's wait.
8da3e468c control-api: comments of the OAuth token refresh and of the Host-RPC admission bucket corrected (comment only).
87b242c5f workflow-approval-request-reader: while a retry is armed, only a repeated click by the same user with the same decision is held; any other click is submitted at once.
1387025e5 workflow-approval-request-reader: test for Retry-After on the synchronous enrollment answer.
b22ceb213 workflow-recipes: comment of the status read corrected (comment only).
054a382a1 workflow-approval-request-reader: while a user's retry is armed, every later click of that user is held, so that user's first decision is the one sent; a click by another user is still submitted, and control-api accepts a decision only from the approval's requester.
aa8e6a877 Merge dev 193f5f4a7 (#1339, HCC network-policy pass). Only version lines conflicted; host-context-controller is set to 0.1.446 (above dev's 0.1.445) because this branch changes HCC code.

No longer in PR2, for parity with dev (already removed at c64989e7c): the 10/minute limiters on the Sandbox OAuth routes
and the legacy Desktop/Sandbox session-creation admission. Earlier descriptions of this PR still listed them.

Migrations

PR2 registers 0139–0144, after dev's last migration 0138:

  • 0139_workflow_authority_bindings
  • 0140_gfs_upload_authority_bindings
  • 0141_pr2_readiness_evidence
  • 0142_pr2_runtime_privileges
  • 0143_workflow_run_failure_reason
  • 0144_workflow_authorization_pause

Open PR #1205 also registers 0139–0141. Whichever lands second renumbers; this is noted on #1205.

Behaviour with V2 off

V1 does not behave exactly as on dev with the V2 flags off. These differences stay in this PR:

  • MCP Host admits at most 600 direct trusted service-plane action requests per minute per Host runtime
    (mcp-host/src/server/directServiceAdmission.ts, not in dev), then answers 429 with Retry-After. Charged: new messages,
    provider message authorization, tool approvals, workflow approval decide, resolve and result request, approval-medium enrollment
    and Telegram verification. Not charged: the cron-result poll and its acknowledgement, the notification claim and its ack/fail, the
    task-result read and the progress stream of an accepted task. Real peaks of the charged routes are measured before the merge.
  • The internal action checkpoint and host-wake routes answer 409 action_context_disabled when actionContextV2 is off, before any
    delegation check (requireActionContextEnabled). The Host message admission (Spec 62) and Host-RPC admission (Spec 65) charges are
    made only in that checkpoint route, so V1 traffic is not charged by them.
  • workflow-recipes: a running report reads the run store only for a run with an authority binding, which its child recipe carries
    as the label clerum.io/workflow-run-authority: bound. No such run exists with the flags off, so every running report is decided
    by recipe status, as on dev.
  • control-api DELETE /internal/sandbox-ui/oauth/grant needs the recipe to exist and to declare the client once (404 / 400 and the
    grant stays); dev deleted the grant and answered 204. Follow-up: control-api: sandbox OAuth disconnect leaves the grant when the recipe or client declaration is gone #1476.
  • control-api OAuth token refresh (a stale token with a refresh token, and the proactive refresh sweep) reads the grant owner first
    and answers recipe_not_found, unknown_oauth_client or no_grant when the owner is gone, the client id is missing or duplicated,
    or the provider differs. A cached token that is not stale is still returned without reading the owner, as on dev.
  • control-api logs host_wake_requested also for a Host that is already active, with different fields.
  • rpc-proxy strips cookie, authorization, x-evenfire-action-delegation and x-clerum-edge-* before proxying the legacy
    Desktop view (HTTP and WebSocket) to the desktop pod; dev forwarded every header. Follow-up: rpc-proxy: verify the legacy Desktop view after edge-credential stripping #1477.
  • rpc-proxy and mcp-host end a V1 activity stream on backpressure and on an oversize event; dev kept writing.
  • rpc-proxy: seven legacy routes parse the JSON body before the scope check (six in rpc-proxy/src/routes/rpc.ts and
    POST /sandbox-ui/:recipeNs/:recipeName/oauth/authorize-url), so a token without the scope can get 400 or 413 instead of 403.
    Follow-up: rpc-proxy: seven legacy routes parse the body before the scope check #1478.
  • rpc-proxy: every Host RPC route requires an RFC 1123 label hostRef (400 Invalid hostRef); dev checked it on the two artifact
    routes only.
  • mcp-host: for direct callers, the draining fence on POST /v1/runtime/messages runs after validation and admission.
  • rpc-proxy: the legacy MCP session cache keeps at most 1,024 sessions (least recently used first out), and a retry always drops
    the cached session and initialises a new one.
  • gfs-controller copy: a malformed percent sequence in an id is reported as an internal error instead of path_invalid.
    Follow-up: gfs-controller: malformed percent-encoding in a copy id is not reported as path_invalid #1480.
  • gfs-controller images of this release write gfs_upload_sessions.action_authority on every upload-session create, so control-api
    migration 0140 must run first (the GKE pipeline orders it that way).
  • channel-reader: when the status message send throws after the Host accepted a Telegram message, the message is not re-submitted
    and the user gets no reply. dev re-submitted it synchronously; the Host suppresses a second delivery with the same message id while
    the first task's record exists, so what the dev user received in that case was not established. On every channel, as on dev, a
    status call that returns no message id ends without a reply. Follow-up: channel-reader: an accepted async task gets no reply when the status message cannot be sent #1479.
  • channel-reader: a Slack, Teams or email message refused with 429 is delivered only if the provider presents it again.
    Follow-up: channel-reader: Slack, Teams and email events refused with 429 are not re-driven #1481.
  • workflow-approval-request-reader: a decision or enrollment refused by the Host limit is answered 429 with Retry-After on the
    synchronous paths. A Slack or Teams decision is sent again once, in the background, after the Host's Retry-After (1 to 60 s); the
    pending retry is kept in memory (at most 64) and is lost if the reader restarts. While it is pending, any later click by the same
    user is not sent, so that user's first decision is the one sent; a click by another user is sent at once and, as on dev, is
    accepted only from the approval's requester.

Known and accepted: if actionContextV2 is turned off while V2 runs exist, workflow-recipes treats that 409 as a retryable
invalid_response and pauses the run durably (AuthorizationPaused, reason workflow_authority_unavailable). The run does not
retry by itself; it continues only after an explicit resume (no connection is held); PR3 follow-up. The trigger and decision paths do not
cancel the action budget on client abort; the action deadline still bounds the work. With V2 on, a bound run latched Failed by a
transient error cannot recover (409 at its next running report); to decide before V2 is enabled.

Verification (local)

On the commits after 14100a24f:

  • Review of the whole PR against dev 0ca497d37 at 3f28a505f, for regressions and blast radius, in two independent lanes
    (control plane and data; runtime edge and deploy): 0 High, 3 Medium, 15 Low. Two of the three Medium are fixed in cd6a00267 and
    ab3d45491. The third (no reply on Telegram when the status message cannot be sent) is not fixed: 27c05b014 changes no
    behaviour, and the delivery moved to channel-reader: an accepted async task gets no reply when the status message cannot be sent #1479. The Low items are fixed in 5a2a1333c, listed under "Behaviour with V2 off", or moved
    to the follow-up issues below.
  • Blind review of 72f49fb99 against dev 22dd4213c, five lanes (control-api authorization, data and workflows, runtime edge,
    security and deploy, contract and tests), with the external review of 14100a24f triaged in the same pass: no Critical or High, and
    no fail-open path with the V2 flags off. Four Medium changed V1 behaviour (V1 running reports reading Postgres; polling starving
    user actions in the Host limit; a lost Telegram reply when the progress stream is refused; the approval reader ignoring the Host's
    429) and are fixed in 6fa26f189..054a382a1, after a written plan validated in two rounds. The V2-only Medium items are kept as
    gates for enabling V2.
  • 6fa26f189..054a382a1: each package's build and full suite (workflow-recipes 3447, mcp-host 9072, channel-reader 423,
    workflow-approval-request-reader 122 tests); every planned mutation fails a test; the tests 72f49fb99 had for the changed files
    pass on the new code except the ones each fix replaces; the old and new code were run on the same inputs, and every changed answer
    belongs to one of the four fixes.
  • cd6a00267, ab3d45491, 27c05b014: the tests 3f28a505f had for the changed files pass on the new code, except the
    assertion on rpc-proxy's source text that ab3d45491 replaces; each new test fails when its fix is removed.
  • Upgrade rehearsal on a local minikube profile, non-certifying: a profile at dev 0ca497d37, upgraded in place to this branch
    (3f28a505f plus one comment). Migrations 0139–0144 applied once on the existing volume; existing rows and workflow runs
    readable; the WorkflowRecipe CRD accepts authorization_paused; the HCC template change reached HCC and the Host; rpc-proxy RBAC
    and Deployment changes live; health ok. Not established: that rpc-proxy was Ready before HCC and the Hosts (only the creation
    order was).
  • T0 (scripts/minikube/t0.sh) on 5a2a1333c and on 72f49fb99: T0_RUN_EXIT=0, T0_AFFECTED_CHECKS=PASS on both.
  • 36071cc1a: the CI failure reproduced locally with the 3f28a505f imports (same error, exit 1) and passes with the relative
    import, with no node_modules in gfs-controller or workspace-files-controller; both services' tests and builds pass (689 and
    130 tests); both images build, and the module that imports the package loads inside each image.
  • 603103d92: actionlint passes on build-publish.yml; the action drops the empty build-argument line of non-profile-ui images.
  • Release version bumps with --all-services against dev and against the previous head: valid.

The battery below ran on a7d282ace.

Gate Result
T0 (scripts/minikube/t0.sh, base e611ebfcb) T0_EXIT=0, T0_AFFECTED_CHECKS=PASS, 13 affected packages
Real PostgreSQL lane, run as CI runs it (PostgreSQL 16) 137/137 files, 1177/1177 tests
Every run: step of the CI jobs shell-syntax and deploy-manifest-check 81/81
Node contract tests; password-recovery-suite-contract.mjs verify 52/52; pass
Runtime access contract (test-control-api-runtime-access-contract.sh) pass, 109 relations
Release version bumps against dev and against c64989e7c valid
T2 public boundary (T2_PUBLIC_BASE_REF=e611ebfcb) PUBLIC_BOUNDARY_PASS
Desktop verify:electron pass
Lines present in dev or in c64989e7c and absent here dev side: 38 lines, each traced to a named commit or a format difference; c64989e7c side: attributed per file to the commits of this sync (87 files) or to the planned catalog deletion (11 files), not line by line

The added and changed shell scripts were run under /bin/bash 3.2 and bash 5.

CodeQL, the 16 open alerts on c64989e7c: 2 fixed in a7d282ace (alerts 1835 and 1937), 5 dismissed as won't fix for parity with dev
(alerts 1926–1930), 8 as false positive (alerts 1803, 1804, 1931–1936) and 1 as used in tests (alert 797). Eight older dismissals
(alerts 1430–1433, 1445, 1447, 1448, 1598) cited the removed limiters; their comments now state the dev parity.

CodeQL, the 12 alerts raised on 0d1c00106 (2007–2018), which replace older alerts on code this sync moved: 6 fixed in ce36151d4
(2007, 2014–2018); 2 dismissed as false positive (2008, rate limiters CodeQL does not credit; 2012, the PostgreSQL host_wake bucket)
and 4 as won't fix for parity with dev (2009, 2010, 2011, 2013). Alert 1646's dismissal comment cited a removed limiter and now states
the dev parity.

Before merge

  1. Blind review: done on 72f49fb99; the fixes since are covered by "Verification".
  2. Human approval.
  3. Merge the latest dev, then one T2 on a recreated branch profile, on the head that merges.
  4. Measure, read-only and per cluster, the real peaks of the charged action requests against the 600/minute Host limit, and the
    build time of the workflow_runs index.
  5. Apply the WorkflowRecipe CRD with authorization_paused out of band, before the merge on clerum-dev and before promotion on clerum.

The upgrade rehearsal listed here before is done (see "Verification").

Follow-up issues (not in this PR)

Items from earlier descriptions

Item State
PR2.1 #797 and PR2.2 #795 Merged on 2026-09-30. This branch was rewritten after that, so their commits are not ancestors of this head. #797's Spec 65 Host-RPC admission is present (control-api/src/services/hostRpcAdmission.ts, rpc-proxy/src/hostRpcPreflight.ts); #795's Host-ref authority changes were not re-checked in this sync. No open PR targets this branch.
R67-M1 (deployed HCC/WFC/GFSC mixed-image lifecycle) Open; the local upgrade rehearsal covered the HCC and gfs-controller roll; T2 under "Before merge".
#801, #674, #641, #717, #190 Open issues, unchanged by this sync.
R40-M1, R11-H1, R11-M1, R20-L1 Unchanged by this sync; still owner dispositions.

Earlier review rounds (R24–R69) and their evidence remain in this PR's comments and in the edit history of this description.

🤖 Generated with Claude Code

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

Comment thread rpc-proxy/src/routes/rpc.ts Fixed
Comment thread rpc-proxy/src/routes/rpc.ts Fixed
Comment thread rpc-proxy/src/routes/rpc.ts Fixed
Comment thread rpc-proxy/src/routes/sandboxUi.ts Fixed
@josue-tech-94

Copy link
Copy Markdown
Contributor Author

@claude Please do one final, independent review of this entire PR at the current head.

Treat this as a final merge/readiness-quality pass, not a request to re-litigate previously resolved review history.

Please review the complete PR diff against its actual base and focus on:

  • correctness and regressions;
  • authentication, authorization, trust boundaries, and privilege escalation;
  • cross-service contracts and producer/consumer compatibility;
  • persistence, retries, ordering, idempotency, and failure handling;
  • test quality, including false-green tests and missing negative cases;
  • whether the final CodeQL/modeling changes preserve detection of genuinely unsafe behavior rather than broadly suppressing findings;
  • whether any change made to address prior findings introduced new collateral issues.

For CodeQL specifically, please review the repository-owned analyzer changes on their technical merits. We have already spent substantial review effort proving several reported paths safe. Do not report an issue merely because stock CodeQL previously produced an alert; report it only if you can identify a concrete remaining vulnerability, false-negative risk, overly broad analyzer model, or inadequately tested security invariant.

Please distinguish clearly between:

  1. issues introduced or worsened by this PR that should block it;
  2. pre-existing/base issues that this PR does not worsen;
  3. non-blocking suggestions.

If you find a blocker, include the exact file/location, concrete failure or exploit scenario, impact, and the smallest correct fix.

If you find no must-fix issue, please say so explicitly and give a final overall verdict on whether this exact PR head is safe to use as the next stacked PR base.

Please review the final state as a whole rather than proposing speculative refactors or stylistic cleanup.

@claude

claude Bot commented Sep 10, 2026 •

Copy link
Copy Markdown

Claude finished @josue-tech-94's task in 4m 9s —— View job


Final independent review — PR #594 @ 2658cc05 (head), base feat/user-centric-access-foundation

I reviewed the full diff as a merge/readiness pass across five surfaces (rpc-proxy routes, control-api verifier/checkpoint/readiness, cross-service checkpoint consumers, the repository-owned CodeQL model, and test quality), tracing the actual code rather than re-litigating resolved history.

Verdict: no must-fix blocker. This exact head is safe to use as the PR3 stack base. The v2 authority model is implemented consistently and fail-closed end-to-end. Below, findings are split into the three requested buckets.


1. Issues introduced/worsened by this PR that should block it

None found. Every new authorization seam re-derives authority from live server-side state and fails closed on denied/stale/unavailable/mismatch.

The CodeQL alerts on this PR were each traced to concrete code and are by-design token→authorization flows, not vulnerabilities:

  • SSRF desktopProxy.ts:230 / sandboxUi.ts bypass :700 — the outbound host and the "is-v2" guard are derived from req.authorizedActionV2, which is server-set only after bindRouteActionV2 proves the request's canonical target hash equals the delegation's signed per-operation target hash (routeActionBindingV2.ts:291-299) and a live Control API checkpoint allows it (actionAuthorityV2.ts:116-157, assertCheckpointMatchesDelegation). canonicalHostRef rejects //\, and every failure path sends a status without calling next(), so there is no path where a failed v2 request degrades to legacy cookie authority. CodeQL cannot see the hash-equality binding, hence the alerts.
  • Missing rate limiting — pre-existing property (rpc-proxy has never had a local limiter); these edges are authenticated and the mandatory per-request checkpoint is the throttle/authority point. Not worsened.
  • File-data-in-network pr2ReadinessReporter.ts — the new EvenfireFileAccessToHttp.ql suppression is extremely narrow (credential-only, single fixed endpoint, canonical factory) and its fixtures prove 5+ genuinely-unsafe variants still fire.

Control-api verifier is sound: service tokens are provenance only (actionCheckpointCaller.ts constrains each caller to its resource type + op set, no checkpoint-wide credential); the R1-B1 SQL fix keeps the logical id and checkpoint boolean in distinct positional params with no injection; OAuth resolves exactly one matching client before any secret/grant/cached-token use; readiness writers cannot be a user session and freshness uses DB clock_timestamp() (replayed operator timestamps can't refresh evidence). All four cross-service consumers (mcp-host, WFC, GFSC, workflow-recipes) checkpoint per-protected-effect with no cached-authority reuse, and request/response shapes match the producer's exact-keys contract.

2. Pre-existing / base issues this PR does not worsen

  • Absence of a local rate-limit middleware in rpc-proxy (design; throttling is downstream/checkpoint-bound).
  • internal/oauth.ts hasExpectedV2OAuthContext returns true when the edge action-context header is absent (legacy compat, bounded behind requireInternalService('rpc-proxy') — not reachable by an external user).

3. Non-blocking suggestions (future hardening, not gating)

  • mcp-host checkpoint client (actionAuthorityCheckpointClient.ts:97-114): unlike WFC/GFSC/workflow-recipes it returns allowed on status+HTTP-code alone without re-comparing returned attribution/authorizationRevision/behaviorBindingHash. Safe today (producer binds those into the allow), but weaker than its siblings — worth aligning.
  • WFC write target shape (workspace-files-controller/src/auth/actionAuthority.ts:155-157 vs routes/files.ts:104-119): write routes add action/destinationPath to the expected target while minted bindings carry 4 keys, so sameTarget's length check would always deny. Dormant and fail-closed (no write-scoped authority is minted yet), but it will silently break v2 writes when they land — fix the target-key set at that time.
  • workflow-recipes (dbRunProcessor.ts:479-485): the checkpoint HTTP fetch runs inside the FOR UPDATE OF run transaction, holding a row lock across a network call — potential lock/pool pressure under load. Robustness only.
  • external-rest delegation bound is a UTF-16 .length ≤ 4096 check, not byte length; use Buffer.byteLength(value,'utf8') if a strict byte bound is intended (harmless for ASCII JWTs).
  • CodeQL: the requirePr2RuntimeReadinessWriter accepted-guard branch in EvenfireMissingRateLimiting.ql has no fixture exercising it — add one to close the coverage gap. The asymmetric consumer strictness (internal edges treated as rate-limited via the 10/min issuer) rests on delegations being bounded/non-replayable — an architecture premise to keep validating, not a query defect.

Notes on scope

Consistent with the PR description, this remains stacked on PR1 and does not target dev; PR1 must not merge to dev yet. Final exact-head canonical static-analysis evidence and the formal $evenfire-pr-review acceptance are process gates outside what I can attest here — I reviewed the code and the analyzer changes on their merits.

Test quality is strong: ~11k added test lines with negative cases (denied/stale/invalid_binding/unavailable) across every new seam, plus producer/consumer interop tests; I did not find false-green patterns in the surfaces reviewed.

Overall: no blocker introduced by this PR; the head is safe as the next stacked base, with the four low-severity hardening items above tracked for follow-up (the WFC write-target one before v2 write authority ships).

@alfredolopez80

Copy link
Copy Markdown
Contributor

@josue-tech-94 here is an external review of PR #594. It covers exact head a4072a20, and it does not replace the internal formal acceptance, which is still pending per §13 of the review context.

CodeQL: no CodeQL analysis exists for a4072a20. The last one is run 34524545210 on d9097379.

Method: 13 parallel review agents, and I verified every Blocker and High myself. Each finding is tagged with its R0 class against merge base 6f160114, and with its evidence: REPRODUCED or READ-ONLY.

Counts: Blocker 2 · Critical 0 · High 6 · Medium 13 · Low 17.

B1, B2, H1 and H2 were already present at 2658cc05. The earlier advisory review ran on that commit and did not report them.

Blockers

B1. Service images don't copy @clerum/action-context-contracts. [INTRODUCED] [tsc simulation: TS2307, EXIT=2; docker build not run]

  • rpc-proxy, control-api and mcp-host (all 4 Dockerfiles) declare the package as a file: dependency, but none of those Dockerfiles copies packages/action-context-contracts. Only workflow-recipes/Dockerfile does.
  • rpc-proxy builds with context rpc-proxy/ (build-images.sh:1158), so the sibling package is outside the context.
  • mcp-host also runs a runtime-stage npm ci (mcp-host/Dockerfile:49).
  • CI stays green because scripts/tests/test-dockerfile-local-packages.sh:185-201 has hard-coded package lists and passes vacuously.
  • Fix:
    • COPY the package before every npm ci.
    • Build rpc-proxy from the repo root.
    • Make the guard read the file: dependencies.

B2. New PR2 tables have no control_api_runtime grants and no runtime-profile rows. [INTRODUCED] [REPRODUCED]

  • Tables: workflow_authority_bindings (010f), pr2_readiness_activations and pr2_readiness_evidence (0111).
  • Repro: on a fresh Postgres 15 I ran the real initDb().
    • has_table_privilege('control_api_runtime', …) is false for SELECT, INSERT, UPDATE and DELETE on all three tables. The same check is true on PR1 tables.
    • None of the three is in deploy/scripts/control-api-runtime-access-profiles.tsv.
  • Why: migration 0062 granted ON ALL TABLES once (db.ts:4868). There are no default privileges, and applyPendingPr1Migrations applies no grants.
  • Impact:
    • verify_runtime_access_contract (run-control-api-db-migration.sh:810) fails the deploy migration job.
    • If that gate were bypassed, v2 binding writes and reads and all readiness writes would fail with 42501. The readiness errors are swallowed.
  • CI stays green because the Real Postgres job runs as a superuser.
  • Fix:
    • Add GRANTs in 010f and 0111.
    • Add TSV rows for the three tables.
    • Add a real-PG test that runs as control_api_runtime.
  • Separately, and pre-existing from PR1: authorization_catalog_environment is also missing from the TSV.

High

  • H1. Every v2 runtime_session operation returns 400 invalid_binding. [INTRODUCED] [REPRODUCED]
    • rpc-proxy/src/actionAuthorityV2.ts:134-156 expects a host destination whenever the target has a hostRef.
    • The producer control-api/src/services/access/actionDestination.ts:34-35 returns null for every type except host and mcp_server.
    • This breaks session.read, task.read, task.manage, model.read and model.select. It fails closed.
    • The fixture at rpc-proxy/src/__tests__/session-search-v2.test.ts:87 hand-writes a shape the producer never emits.
  • H2. A successful v2 workflow.read returns 500. [INTRODUCED] [REPRODUCED]
    • runs.routes.ts:99 and read.routes.ts:136,186 persist entity_type 'workflow_recipe'.
    • The 010f CHECK rejects it with 23514 (db.ts:3056-3058).
    • The route tests mock pool.query, so they don't catch it.
  • H3. The active-view lease wiring is untested. [REPRODUCED by mutation]
    • Removing it at sandboxUi.ts:842-844 and desktopProxy.ts:244-246,295 (M7e/f/g) leaves all 546 rpc-proxy tests green.
    • This wiring enforces the Sandbox/RDP revocation bound.
  • H4. The desktop v2 surface is untested. [REPRODUCED by mutation]
    • M5b and M5c survive. M5c makes a v2 WS upgrade fall back to legacy, and no test fails.
  • H5. workflowActionCheckpointClient.ts:86-92: nothing tests that authority_unavailable fails closed. M14c survives all 2818 workflow-recipes tests.
  • H6. actionAuthorityCheckpoint.ts:89-93: the permittedResourceTypes and permittedOperations checks are untested. M12a, M12b and M12ab survive 651 control-api tests.
    • Clients build domain themselves, so this is the only barrier that confines GFSC and WFC credentials.

Medium

  • M1. workflow-recipes/src/reconciler/dbRunProcessor.ts:482-487: a run whose checkpoint is denied stays Pending forever.
    • FIND_STUCK_RUNS only matches Running, and orphan polling retries the run on every poll.
  • M2. §8.3 is still present: the checkpoint HTTP call runs inside the advisory lock and FOR UPDATE.
    • It is bounded by a 10 s timeout, but the pool maximum is 4.
  • M3. pr2ReadinessEvidence.ts:285 bumps updated_at on every boot, which invalidates all runtime evidence until each service reports again. [REPRODUCED]
  • M4. reauthorize() runs inside the approval FOR UPDATE transaction and takes a second connection (userApprovalRequestService.ts:862, workflowRunService.ts:201-240). This risks pool starvation.
  • M5. pr2ReadinessWriterAuth.ts:63-70 accepts any valid mcp-host runtime JWT as the fleet-wide mcp-host readiness writer.
  • M6. The Dockerfile local-package CI guard is stale and passes vacuously (see B1).
  • M7. packages/action-context-contracts is not in the ci-public.yml matrix, so its 9 tests never run in CI.
  • M8. EvenfireFileAccessToHttp.ql:185-195 uses local flowsTo, so a second tainted header or a template-literal URL is missed. The alias and property unsafe fixtures use concatenation and don't exercise the case they are named for.
  • M9. EvenfireMissingRateLimiting.ql:406-429 (OAuth) and :447-482 (WFC) accept a guard by name and line order. A limiter whose result is ignored, or a same-named local function, suppresses the alert.
  • M10–M12. Untested protections, shown by surviving mutants:
    • GFSC/WFC attribution re-compare: M4, 18 variants.
    • mcp-host failover fresh checkpoint: M8a, M8d.
    • Readiness max-age and epoch clauses: M11a, M11b.
  • M13. §8.2 is still present: the WFC write target mismatch. It is dormant and fails closed.

Low

  • L1. localeCompare is used for key ordering at index.cjs:182. No current schema is affected.
  • L2. Targets are not NFC-normalized before hashing.
  • L3. A WFC checkpoint timeout returns 500 instead of not_mounted (actionAuthority.ts:229-231).
  • L4. The 30 s lease backstop fires only once (activeViewLease.ts:51-54).
  • L5. desktopProxy forwards the v2 Authorization delegation to the desktop pod. Sandbox UI strips it.
  • L6. The distinctness check in apply-inter-service-tokens.sh:192-194 covers only GFSC, WFC and rpc-proxy.
  • L7. The readiness reporters use .catch(() => undefined) with no log.
  • L8. A direct console.log at mcp-host/src/agent/stateMachine.ts:427.
  • L9. workflow-recipes hard-codes CHECKPOINT_PATH.
  • L10. The checkpoint and readiness internal routes have no rate limit. Both are service-authenticated.
  • L11. Schema issues:
    • Bindings and gfs_upload_sessions.action_authority are mutable.
    • Reusing a jti gives a 23505 error.
    • The binding FKs on workflow_approval_requests have no index.
  • L12, L13. Surviving mutants M14b and M9c.
  • L14. The name of producerInterop.test.ts:32 is misleading: it runs no consumer builder.
  • L15. §8.1 is still present: the mcp-host client does not re-compare attribution.
  • L16. CodeQL:
    • the nullary issuer predicate (MissingRateLimiting.ql:153-173);
    • §8.5 is still present: no dedicated readiness-writer fixture (:400-401);
    • the operation literal is matched anywhere (:443);
    • also UserControlledBypass.ql:547 and FileAccessToHttp.ql:27-72.
  • L17. §8.4, UTF-16 length:
    • Node decodes header values as latin1, so .length already equals the wire bytes. Not exploitable.
    • Only the "4,096-byte" wording is imprecise.

Pre-existing (not counted)

  • The legacy raw hostRef SSRF sink at desktopProxy.ts:241,298,344 has an equality gate and is not worsened.
  • The legacy branch of v2OrLegacyHostAllowed.
  • authorization_catalog_environment is missing from the TSV.

CodeQL inline alerts (18)

All 18 were raised on 7cae983d, before the models commit 3058461c3, and carried forward. Code scanning shows 0 open alerts on refs/pull/594/head.

Verdict Alerts
Pre-existing rpc.ts ×3, desktopProxy.ts:168/169, desktopProxy.ts:230
New but covered by the throttling decision /reconnect at desktopProxy.ts:177/178/186
False positive actionAuthorityHostWake.ts:32 (the limiter is present at :28-40), sandboxUi.ts:700, 3 test-only apps
Low hardening actionAuthorityCheckpoint.ts:24/49, pr2ReadinessEvidence.ts:14
Expected cleared by the model, unconfirmed without an exact-head run mcp-host pr2ReadinessReporter.ts:36

Verified clean

  • No v2→legacy fallback: rpc-proxy, sandbox, mcp-host and OAuth. For OAuth, v2 callers never reach the hasExpectedV2OAuthContext legacy branch.
  • Canonical target binding: keys are sorted, fields are compared byte for byte, and the operation registry is closed.
  • Delegation JWT: RS256, exact claims, TTL at most 300 s.
  • Checkpoint interoperability: edge headers are stripped. All 5 checkpoint consumers send exactly the 11 allowed keys, their status mappings match the producer, and they re-compare attribution.
  • Workflows: the single decide -> consume edge is enforced by a DB trigger and by app code, and stored bindings are never bearer authority.
  • Controllers: GFSC checkpoints every route. GFSC and WFC have distinct identities. WFC path safety holds.
  • Readiness: uses the DB receipt time, requires 40-hex revisions, has no default max age, and has a closed inventory of 17 hops.
  • Other: the log redaction covers the delegation header, SQL is parameterized, and the index.cjs and index.d.ts exports match.

Suites

Suite Result
rpc-proxy 546/546
gfs-controller 622 passed, 9 skipped (real-PG)
WFC 112
HCC 2263
external-rest-api 309
mcp-host 3794 passed + 1 todo
workflow-recipes 2818
control-api (PR-owned files) 651 passed, 39 skipped (real-PG; not counted as green)
action-context-contracts 9
Mutation testing 56 mutants; the survivors are listed above
CI run 34528069470 success, 36/36 jobs

docker build was not run.

Decisions for the owner

  1. Recommendation: fix B1 and B2 before merge.
  2. Should H1 and H2 also block acceptance as the PR3 base? They are functional, fail-closed defects. My recommendation is to fix them in the same pass.
  3. The severity of L1 (localeCompare).
  4. Whether internal service routes need rate limits (L10).

@josue-tech-94

Copy link
Copy Markdown
Contributor Author

@alfredolopez80 We went through the full finding set, reproduced the actionable items, and completed the Round-5 correction pass.

What we changed

All validated must-fix findings from your review are now closed:

  • B1 → production Docker/local-package materialization fixed and covered across all affected image variants.
  • B2 → PR2 runtime-table grants/profile coverage fixed and proven with PostgreSQL 16 under the real runtime role.
  • H1 → runtime-session producer/consumer destination contract aligned with producer-backed coverage.
  • H2 → workflow authority schema now admits the real workflow_recipe provenance class while remaining closed to unknown values.
  • H3 → mounted active-view lease wiring is mutation-sensitive for Desktop HTTP/WS and Sandbox.
  • H4 → declared-v2 Sandbox/Desktop traffic is proven unable to downgrade to legacy authority.
  • H5 → real WRC checkpoint-client coverage proves unavailable authority blocks protected work.
  • H6 → checkpoint callers are mutation-tested against their exact resource/operation confinement.
  • L5 → Desktop HTTP/WS now strips edge-only Authorization, cookies, delegation, and trusted-edge headers before upstream contact.
  • M1 → permanent workflow authority denial terminalizes rather than retrying indefinitely.
  • M4 → fixed as R5-M2 using the accepted two-phase approval reauthorization design.
  • M7 → shared action-context contracts now run in ordinary CI.
  • M10 → GFSC/WFC attribution comparisons are independently mutation-sensitive.
  • M11 → provider fallback requires a fresh authority checkpoint.
  • M12 → readiness max-age expiration/refresh now has Real PostgreSQL mutation-sensitive proof.

R5-M2 specifically now performs live authorization before the final decision lock, then uses a repeatable-read final transaction to re-lock/re-read the approval, check DB-time expiry and the authorized snapshot, fence the current authority revision on the same transaction client, and atomically persist provenance + decision. We have CORE_POOL_MAX=1, 12-way one-winner concurrency, approval-race, authority-revision-race, unavailable-authority, and expiry coverage.

Exact-head ordinary CI is green:

34644480421

CodeQL was manually dispatched for this same exact head:

34646387785

https://github.com/evenfire-ai/evenfire/actions/runs/34646387785

Claims we did not turn into fixes

We also independently rechecked the claims we classified differently.

The following remain invalidated by current source/reproduction evidence rather than being silently ignored:

  • M3, M5, M8, M9
  • L1, L2, L3, L4, L6, L10, L12, L13, L16, L17

M6 is the same root cause as B1.

L8 and the previously identified raw-hostRef / legacy-path / base access-profile observations remain pre-existing/R0 rather than PR2-introduced.

These remain acknowledged but non-blocking hardening/future-boundary items:

  • M2 — WRC checkpoint I/O while holding the run lock / load pressure
  • M13 — dormant WFC write-target mismatch before write authority becomes reachable
  • L7 — readiness-report observability
  • L9 — checkpoint-path constant sharing
  • L11 — DB immutability/replay/index hardening
  • L14 — interop-test naming/clarity
  • L15 — MCP response-attribution parity

@josue-tech-94
josue-tech-94 marked this pull request as ready for review September 11, 2026 23:03
@alfredolopez80

Copy link
Copy Markdown
Contributor

@josue-tech-94 — consolidated multi-agent review of this PR at exact head ad1e137ac. Summary: do not merge yet. C1 and INF-1 are small config fixes that block; F1, N1 and T-1 are strongly recommended in PR2. Details below.

PR #594 consolidated review — exact head ad1e137ac

Repo evenfire-ai/evenfire
PR #594 "Runtime actions use validated access paths"
Base ← head feat/user-centric-access-foundation (PR1 #334, OPEN) ← feat/user-centric-runtime-access-paths
Author @josue-tech-94
Exact head reviewed ad1e137ac9ca656a9616346dfe7d129c02414a5c
R0 merge base 9b4755a15c92a0dc7727e72d3febbfc1cc876bb6 (current PR1 branch head)
Scale 352 files, +28498 / −3447
Stack position PR2 of 4 (PR1 → dev, PR2 → PR1, PR3 → PR2, PR4 → PR3)
Findings Blocker 0 · Critical 1 · High 4 · Medium 15 · Low 51

Method: 12 read-only agents: rpc-proxy, control-api, controllers/infra, mcp-host/WRC/external-rest-api, security, cross-service contracts, concurrency/DB/perf, CodeQL/CI, merges/docs, two test runners with mutation testing, and a prior-findings verdict pass. The concurrency/DB/perf agent was stopped before reporting (see §3). The owner's pr2-human-review-context.md was applied: R0 against 9b4755a15, §10 accepted history debt and §11 out-of-scope items are not reported as defects, and §12 is answered below. Every Critical/High finding was re-verified by the lead reviewer against the code at the exact head.

This review covers only ad1e137ac. It does not replace the internal formal acceptance, and it does not authorize merging PR1/PR2 or starting PR3.

Evidence tags: REPRODUCED = a command, test, mutant or script was run and the output is quoted. READ-ONLY = code path traced only.
Class: INTRODUCED (vs 9b4755a15) · PRE-EXISTING · ROUND5-REGRESSION (introduced by the Round-5 fixes or the PR1 sync merges).


0. Key context for reading severities

v2 cannot be enabled at this head. control-api/src/services/access/userAccessPolicy.ts:372-390 freezes reconstructionReadiness.actionContext: 'unavailable', and :222 requires 'ready'. No source path changes it, so CONTROL_API_USER_ACCESS_ACTION_CONTEXT_V2=true always throws action_context_unavailable.

Consequences:

  • Every defect in a v2 runtime path is dormant at this head. It must be closed before v2 activation, not necessarily before this merge; that is the owner's call (section 6).
  • Defects that break build or deploy (C1, INF-1) affect v1 traffic today.

1. Critical

C1 — rpc-proxy crashes at startup on any cluster deployment of this head

  • Critical · INTRODUCED (ef8ada2cd) · REPRODUCED + lead-verified
  • Location: rpc-proxy/src/config.ts:228; deploy/overlays/minikube/configmaps/rpc-proxy-config.yaml:53; rpc-proxy/Dockerfile:20.
  • Defect: mcpServerNamespace: requiredOrDevDefault('RPC_PROXY_MCP_SERVER_NAMESPACE', 'mcp-server') throws when NODE_ENV=production, and the Dockerfile sets ENV NODE_ENV=production. The variable is provisioned nowhere except rpc-proxy/.env.example:
    • git grep RPC_PROXY_MCP_SERVER_NAMESPACE HEAD finds only config.ts and .env.example;
    • the configmap has RPC_PROXY_HOST_NAMESPACE only;
    • the variable does not exist at 9b4755a15.
  • Scenario: deploying ad1e137ac with the repo manifests makes config throw at import, so rpc-proxy goes into CrashLoopBackOff and all RPC traffic stops, including v1. The run output was config import failed: Missing required environment variable: RPC_PROXY_MCP_SERVER_NAMESPACE; with only that variable added, it printed config loaded mcpServerNamespace= mcp-server. CI did not catch this because the lanes are non-cluster.
  • Fix: add RPC_PROXY_MCP_SERVER_NAMESPACE: "mcp-server" to every rpc-proxy configmap overlay. Also add a startup smoke check that loads config with the shipped configmap and NODE_ENV=production.

2. High

INF-1 — rpc-proxy image publish fails; B1 is not closed on the publish path

  • High · ROUND5-REGRESSION (0889b678b) · READ-ONLY, lead-verified
  • Location: .github/workflows/build-publish.yml (rpc-proxy matrix entry path: rpc-proxy, "Resolve build context" step); rpc-proxy/Dockerfile.
  • Defect: the rpc-proxy Dockerfile now copies from the repo root (COPY packages/action-context-contracts ..., COPY rpc-proxy/package*.json ./rpc-proxy/). The matrix entry has no rooted: true, so the build context stays context=$MATRIX_PATH (rpc-proxy/).
  • Scenario: the first dev publish of rpc-proxy fails with a COPY "not found" error. assert_root_build_context does not cover this entry.
  • Fix: add rooted: true to the rpc-proxy matrix entry, and extend assert_root_build_context to fail for any Dockerfile that copies packages/.

F1 — Rehydrated approval-suspended v2 chat task runs without live checkpoints

  • High (dormant, see §0) · INTRODUCED · READ-ONLY, lead-verified

  • Location: mcp-host/src/core/conversation/persistence/sqliteConversationStore.ts:1014; mcp-host/src/agent/stateMachine.ts:722-726; mcp-host/src/agent/taskExecutor.ts:1138-1139, 1229-1233.

  • Defect: persistSuspend always writes source_message: null. Cold-start bootstrap() rebuilds the task with sourceMessage undefined. taskExecutor treats sourceMessage?.authorityV2 === undefined as legacy (if (!authorityBinding) return fallbackPort; reasoning = authorityBinding ? withRuntimeActionAuthority(...) : unguardedReasoning), and the context manager is also unguarded.

  • Scenario:

    1. A v2 message suspends on a tool approval.
    2. The mcp-host pod restarts (deploy, eviction, OOM, scale-down).
    3. Control API revokes the path or grant.
    4. The approval is decided.
    5. resumeAfterApproval sends the tool results to the provider with no checkpoint, and every later iteration, context pass and fallback is also unchecked.

    §3 invariant "every protected action revalidates live" is broken on this path.

  • Fix:

    • Persist the frozen authorityV2 binding with the pending-approval row (source_message or a dedicated column).
    • On rehydrate, fail closed when the task was v2 and the binding is missing.
    • Add a persist → restart → resume test asserting that the checkpoint is called before the provider (liveness witness).

N1 — 7 of the admitted delegatable operations can never be authorized; H1 is only partly closed

  • High (functional; fails closed) · INTRODUCED · READ-ONLY, found independently by 2 agents, lead-verified
  • Affected operations: task.read, task.manage, model.read, model.select, session.read, session.manage (resource runtime_session) and chat.read (resource chat).
  • Location:
    • control-api/src/services/access/accessAuthorityStore.ts:26 (the resource-type union has no runtime_session/chat);
    • accessAuthorityStore.ts:704-836 (loadDatabaseCandidates has no branch for them);
    • control-api/src/services/access/operationalAccessReader.ts:39-48 (not in SOURCE_FAMILIES_BY_TYPE);
    • control-api/src/services/access/liveAuthorizationResolver.ts:197, 248 (not_found);
    • control-api/src/routes/external/rpcDelegations.ts:179-183.
  • Defect: issuing a delegation for these operations calls authorizeActionV2, which always resolves to not_found. The Round-5 H1 fix (427ace910) aligned the checkpoint destination, but the whole path still cannot be used. The fixture control-api/test/fixtures/emitRuntimeSessionDelegationV2Fixture.ts calls issueUserDelegationV2 directly and skips authorization, so the producer-interop tests pass on a token the real producer can never issue (§12 item 15).
  • Scenario: a v2 user with full access calls any session.*, task.* or model.* action and gets 404 (chat.read with a host resource gets 400 invalid_binding).
  • Fix: add an authority source for runtime_session and chat (derived from the owning host/context grant). When doing so, bind target.hostRef to the session (see S1). Replace the bypassing fixture with one that goes through authorizeActionV2.

T-1 — Desktop "v2 cannot fall back to legacy" tests are vacuous; the Round-5 H4 proof does not hold for Desktop

  • High (a claimed High closure is unproven; production code is correct) · ROUND5-REGRESSION (e8d3e4602) · REPRODUCED independently by 2 agents; one used a clean npm ci
  • Location: rpc-proxy/src/routes/desktopProxy.test.ts:62-66 (tests at :359, :478, :501, :523) vs rpc-proxy/src/config.ts:231-234.
  • Defect: the "valid legacy session" cookie is signed with 'test-secret-32-chars-minimum!!', but the router's DesktopSessionService uses the default 'dev-desktop-cookie-secret-32chars!'. The legacy path would reject the cookie anyway, so the tests cannot detect a fallback.
  • Evidence:
    • A witness with the test cookie got 401 {"error":"Invalid desktop session"} proxied 0; a router-signed cookie got 200 proxied 1.
    • Mutants H4a (HTTP declared-v2 → legacy), H4b (WS unverifiable → legacy) and H4c (WS denial → legacy) each SURVIVED the full suite (575/575).
    • With the secret env corrected, 5 tests fail as they should.
    • The HTTP stale/unavailable/mismatched tests (:377, :394) remain unproven.
  • Fix:
    • Mint the cookie with the router's secret (set RPC_PROXY_DESKTOP_COOKIE_SECRET before import).
    • Add a positive control: the same cookie is proxied on a non-v2 request.
    • Assert the v2 status and body, not only "proxy not called".

3. Medium

ID Class · Evidence Location Defect → scenario Fix
T-B1 INTRODUCED · REPRODUCED (mutant M11c survived) mcp-host/src/runtime/actionAuthority.ts:124-127 The continueWithToolResults checkpoint is untested; resumeAfterApproval sets skipContextManager=true. Removing it means a provider call after revocation with the suite green. Adapter test allowed→denied, plus a taskExecutor resume test with a witness.
T-2 ROUND5-REGRESSION · REPRODUCED (H3g, RP-3 survived 575/575) rpc-proxy/src/routes/desktopProxy.ts:265-266, 315-316; sandboxUi.ts:845-846 No test proves the active-view lease closes on disconnect. H3 is PARTIAL. Dropping the registrations leaks lease timers that keep checkpointing closed views. Emit close/finish/error per surface and assert lease.close exactly once, with a started-lease witness.
CQ-F1 INTRODUCED · READ-ONLY .github/codeql/queries/EvenfireFileAccessToHttp.ql:63-72 writesBaseUrlThroughRuntimeAuth casts a module-scoped let read to SourceNode, so it is always empty. The UnsafeProperty/UnsafeAlias fixtures use concatenation, which hasFixedReadinessDestination rejects, so they are vacuous. runtimeAuth.baseUrl = request.body.url plus a template-literal fetch would be suppressed. Production code is safe today. Re-opens M8. Model the write via DataFlow::PropWrite and add a template-literal unsafe fixture that must keep a finding.
CQ-F3 INTRODUCED · READ-ONLY EvenfireMissingRateLimiting.ql:419-427, 462-480 The OAuth suppression accepts by callee name plus line order; an ignored result or a local stub returning true suppresses, and the fixture's "safe" case is such a stub. The WFC branch accepts any receiver .use without dominance or await. Re-opens M9. Require the guard result to dominate the sink; replace the stub fixture with an unsafe variant that must alert.
CQ-F5 INTRODUCED · READ-ONLY EvenfireMissingRateLimiting.ql:298-326 The v2ViewAuthority branch trusts the middleware by name, but that middleware passes non-v2 requests through (desktopProxy.ts:44-50, sandboxUi.ts:106-112), so the suppression rationale does not hold for the legacy path. There is no fixture. Limit the branch to handlers that fail when authorizedActionV2 is absent; add a fixture.
CQ-F6 PRE-EXISTING config, process · READ-ONLY .github/workflows/codeql.yml CodeQL triggers only on [main, dev], so no analysis exists for ad1e137ac. The last dispatch was run 34646387785 on 72baaf513 (10/10 QL tests). The merged PR1 code at this head is unscanned. Dispatch CodeQL on ad1e137ac before acceptance; consider adding feat/**.
TEST-1 PRE-EXISTING harness, relied on by Round-5 · REPRODUCED scripts/tests/test-control-api-runtime-access-contract.sh:12 It asserts 91 but the head has 105 (EXIT=1, no message); it also failed at base (88 vs 102). It is not wired into CI or the Makefile. Round-5 cited it as B2 proof, but it cannot go red where it matters. Fix the count (or derive it) and wire it into CI.
CA-F1 INTRODUCED · READ-ONLY control-api/src/db.ts:3137-3153 (migration 010f) A non-CONCURRENTLY CREATE INDEX on workflow_runs plus 4 ADD COLUMN … REFERENCES inside the bounded migration transaction (lock 10 s / statement 15 s). This contradicts D34's PR1_ONLINE_INDEX_PLAN. On a large table the migration aborts or blocks writes. Move the index to the online-index plan (CONCURRENTLY, outside the transaction) and extend the policy test to PR2 migrations.
CA-F2 ROUND5 partial (M4 residual) · READ-ONLY control-api/src/services/workflowRunService.ts:204-211; workflowTriggerApprovalService.ts:76-80 reauthorize() inside withTransaction takes a second pool connection. With CORE_POOL_MAX (10) concurrent triggers the pool is starved and requests deadlock until timeout. R5-M2 fixed only recordDecision. Reauthorize before opening the transaction, or pass the transaction client through.
INF-2 INTRODUCED · READ-ONLY .github/workflows/build-publish.yml paths-filters packages/action-context-contracts/** is missing from the filters of the 4 consumer images, so a contract-only change publishes no image. Add the path to each consumer filter.
INF-3 INTRODUCED · REPRODUCED scripts/tests/test-dockerfile-local-packages.sh (~197-209) The guard skips workflow-recipes. Process substitution loses the exit status, so 0 packages or a broken manifest passes (harness cases 2 and 4). Fail on an empty package set, propagate the exit status, and include workflow-recipes.
M3 INTRODUCED · REPRODUCED (author: invalidated) control-api/src/services/access/pr2ReadinessEvidence.ts:275-294, 532; control-api/src/main.ts:69-70 ON CONFLICT DO UPDATE … updated_at = NOW() runs even when nothing changed. Against real PG15 a restart dropped 17/17 → 0/17 ready. Once v2 is on, main.ts resolves policy before its own reporter runs, so every restart throws, leading to a crash loop. Add WHERE (…) IS DISTINCT FROM (EXCLUDED.…); add an identical re-import test.
M5 INTRODUCED · READ-ONLY, dormant (author: invalidated) control-api/src/middleware/pr2ReadinessWriterAuth.ts:63-70 Any valid mcp-host access JWT, including a WRC recipe-pod token, is accepted as the fleet-wide mcp-host readiness writer. It can post failed to hold hops down or passed with no real fleet. The test at middleware.pr2ReadinessWriterAuth.test.ts:62 asserts the permissive behaviour. §12 item 12 is YES (dormant). Dedicated writer token or readiness-only scope for first-party Host CRDs; flip the test.
T-7 / C3 INTRODUCED · READ-ONLY rpc-proxy/src/actionAuthorityV2.test.ts:114,180,224; session-search-v2.test.ts:87; mcpProxyService.v2.test.ts; mcp-host/src/runtime/actionAuthorityCheckpointClient.test.ts:77; WRC workflowActionCheckpointClient.test.ts:65 Every checkpoint-response fixture, including destination, is written by hand. Producer drift of the H1 kind stays green; C2 below is exactly such a drift. §12 item 15 is NO for checkpoint responses. Add a control-api checkpoint-response emitter (real resolveActionDestination) and consume it in the rpc-proxy, mcp-host and WRC tests.
RP-5 PRE-EXISTING (5d663a496) · REPRODUCED rpc-proxy/src/routes/sandboxUi.ts:819-820 Shared proxy .once('proxyReq'/'proxyRes') listeners race, so concurrent responses can lose CSP/XFO and the off-origin redirect filter. Not a PR2 blocker under R0. Per-request listeners (selfHandleResponse or request-scoped handler).

Coverage note: the dedicated concurrency/DB/performance agent was stopped before its report, by owner decision, because it could not change the merge recommendation. Its scope is covered by other agents: CA-F1 (migration locking), CA-F2 (pool starvation), §8.3 (WRC checkpoint inside the lock), RP-2/T-2 (lease leaks) and RP-5 (proxy listener race). M2 (performance follow-up) and the decide→consume race analysis were not independently re-verified in this review.


4. Low

ID Class · Evidence Location Summary
S1 INTRODUCED · REPRODUCED (contract), latent packages/action-context-contracts/index.cjs:625-643; rpc-proxy/src/routeActionBindingV2.ts:270-300; control-api/src/services/access/actionDestination.ts:36-48 For runtime_session/chat.read, target.hostRef is not bound to resource.logicalId (task.manage session-a + mcp-host/other is accepted; chat.message.invoke rejects). It becomes cross-host authority once N1 adds a grant source.
S2 INTRODUCED · READ-ONLY gfs-controller/src/api/serve.ts:335-336 requireExactGfsAuthority compares binding.target with itself, so the check is vacuous; it would stay green if the route-level checks were removed. Build the expected target from the request.
S3 INTRODUCED · READ-ONLY, fails closed index.cjs:716-722; serve.ts:916-920 A v2 GFS copy checkpoints gfs.write on the source, not the destination parent, so legitimate copies are denied.
C2 INTRODUCED · REPRODUCED, dormant rpc-proxy/src/actionAuthorityV2.ts:135-157 vs control-api/src/services/access/actionDestination.ts:50-51 chat.read: the producer returns destination null, rpc-proxy expects host (44 rows compared, 2 mismatches; mcp.catalog.read is the reverse, informational). Move the destination rule into the shared package.
C4 INTRODUCED · REPRODUCED, latent control-api/src/services/access/actionAuthorityCheckpoint.ts:99; workflow-recipes/src/reconciler/dbRunProcessor.ts:122-130 The checkpoint parser requires localeCompare key order, while WRC sends a JSONB read-back. It works only for workflow.trigger; workflow.approval.consume would get 400.
C5 INTRODUCED · READ-ONLY gfs-controller/src/auth/actionAuthority.ts:242-316; workspace-files-controller/src/auth/actionAuthority.ts:145-235 GFSC/WFC re-implement the checkpoint client with no shared validator, and every non-5xx collapses to 403. GFSC clears its abort timer (:285) before response.json() (:289), so a stalled body has no timeout (INF-4).
C6 INTRODUCED · READ-ONLY pr2ReadinessEvidence.ts:511-532 If WFC/GFSC is rolled back to a pre-PR2 image, runtime evidence stays ready for up to maxRuntimeEvidenceAgeSeconds (≤86400), and the old verifiers accept v2 tokens without a checkpoint. Use a claim that old verifiers reject (aud/crit).
F2 INTRODUCED · READ-ONLY mcp-host/src/server/routes.ts:304, 354-366 Caller body.authorityV2 is not deleted on the legacy paths (current callers allowlist their bodies).
F3 INTRODUCED · READ-ONLY mcp-host/src/activityHub.ts:81 Activity snapshot/SSE expose the stored binding (sid, delegationJti, accessPathId) to legacy and read_all readers.
F4 INTRODUCED · READ-ONLY mcp-host/src/core/reasoning/port.ts:76-89 A fallback-provider RuntimeActionAuthorityError becomes a non-retryable LlmError(ApiCallFailed), losing authority_unavailable.
F5 INTRODUCED · READ-ONLY workflow-recipes/src/reconciler/workflowActionCheckpointClient.ts:79-81, 117-130; dbRunProcessor.ts:157-164, 499-507 Clock skew on validUntil fails a run permanently; a non-trigger binding is retried forever; no failure reason is persisted.
F6 INTRODUCED · READ-ONLY external-rest-api/src/routes/gfs.ts:305-315; contextSharedFilesystems.ts:99 WorkflowActionDelegationTransportError returns 500 instead of 400 on the GFS token and shared-filesystem routes.
CA-F3..F8 INTRODUCED control-api F3 orphan approval_consume binding (userApprovalRequestService.ts:1043-1064). F4 WorkflowApprovalAuthorityRequiredError unmapped → 500 (decisions.routes.ts:187-210). F5 idempotency hash includes delegationJti → retry 409. F6 REPRODUCED: internal/oauth.ts:70 accepts a non-date expiresAt as unexpired. F7 actionAuthorizer.ts:206-207 swallows without a log. F8 derived sourceExpiresAt ≤ sourceIssuedAt → CHECK 23514 → 500.
RP-2 INTRODUCED · REPRODUCED sandboxUi.ts:842-846; desktopProxy.ts:262-266, 313-316 Lease close listeners are registered after await; a client abort during the checkpoint leaves the lease polling every 10 s until delegation expiry (~29 calls).
RP-4 INTRODUCED · REPRODUCED desktopProxy.ts:313 Lease denial destroys the client socket but not the upstream proxy socket to the desktop pod.
RP-6/7/8 INTRODUCED · READ-ONLY routeActionBindingV2.ts:377-385; wakeAndHold.ts:165 RP-6: runtimeHostEdgeContext returns a legacy context when the destination is missing (unreachable). RP-7: no producer→authorizeActionV2 test. RP-8: endsWith hostRef match; Desktop strips only x-clerum-edge-*; 403 upgrade text says "Unauthorized".
T-3 INTRODUCED · REPRODUCED (L5c survived) desktopProxy.ts:366 Legacy Desktop WS header strip is untested.
T-4 INTRODUCED · REPRODUCED (X-H2 survived) control-api/src/routes/external/workflows/runs.routes.ts:99 The entityType literal is not tied to the 0113 CHECK list.
T-5 INTRODUCED · REPRODUCED (M12c survived both lanes) pr2ReadinessEvidence.ts:530 The future-dated evidence clause (runtimeAge >= 0) is untested.
T-6 INTRODUCED · READ-ONLY .github/workflows/ci-public.yml db.realPostgresMigration.integration (the only B2 runtime-role proof) and gfsUploadSession.realPostgres are not in the pinned critical-suite list.
T-8 INTRODUCED · READ-ONLY external-rest-api/src/pr2ReadinessReporter.test.ts:40 The reporter body is never parsed by control-api's real parser.
T-B2..B4 INTRODUCED; B3 REPRODUCED WRC dbRunProcessor.ts:116-164; GFSC actionAuthority.test.ts:12-64; WFC :30-66 T-B2: WRC FOR UPDATE/FAIL_PENDING_AUTHORITY only mocked. T-B3: GFSC/WFC fixtures use ath1_/abh1_ hashes that the shared validator rejects, and HASH_RE is looser than the contract. T-B4: weak liveness witnesses in activityRoutes.v2.test.ts.
L1 INTRODUCED · REPRODUCED (author: invalidated) packages/action-context-contracts/index.cjs:182 localeCompare makes the hash locale-dependent (da_DK differs; cs/sk/haw reorder real key sets). Fails closed. Use a code-point comparator.
L3 INTRODUCED · REPRODUCED (author: invalidated) workspace-files-controller/src/auth/actionAuthority.ts:229-231 The abort DOMException (code=20) matches 'code' in error, so a checkpoint timeout returns 500 instead of 503.
L12 INTRODUCED · READ-ONLY (author: invalidated) workflow-recipes/src/reconciler/dbRunProcessor.ts:492-495 No test covers a bound run without a checkpointer.
CQ-F2/F4/F7/F8 mixed .github/codeql/**, scripts/tests/test-dockerfile-local-packages.sh F2: header/URL flow uses local SourceNode. F4: readiness-writer branch has no fixture (§8.5); the nullary issuer predicate is global; UserControlledBypass.ql uses line order rather than dominance. F7: the test step does not assert count ≥10. F8 REPRODUCED: vacuous pass on 0 packages.
REL-1 ROUND5-REGRESSION · REPRODUCED host-connectivity-controller version The PR2 HCC bump was lost in the sync merges: head 0.1.322 equals PR1 head 0.1.322, although HCC source differs. Bump to 0.1.323.
LOG-1 / L8 INTRODUCED (1 line, commit 41db179dc) mcp-host/src/agent/stateMachine.ts:427 Added direct console.log. mcp-host's console is overridden by the structured wrapper, so this is a repo-rule nit, not a leak.
MAINT-1..3 INTRODUCED reporters, actionContextV2.ts:19-20, operationTarget.ts:175, gfs-controller MAINT-1: 6 near-copy readiness reporters hardcode 'pr2-readiness-v1'. MAINT-2: unused exports. MAINT-3: 6 gfs-controller files reformatted to no-semicolon.
DOC-1..3, HYG-1..3 process PR body, docs DOC-1: the PR body is stale (names head 6a14a73a4, says "draft" although isDraft=false, omits the ad1e137ac merge). DOC-2: no R5-id ↔ review-id mapping published. DOC-3: zero docs changed (WFC/GFSC READMEs stale; readiness operator env vars undocumented; specs 24/25/42/43, tracker and ledger absent from repo history). HYG-1..3: commit-grouping notes.
T-9, T-10 PRE-EXISTING / flaky desktopProxy.test.ts:301; external-rest-api/test/routes.gfs.test.ts T-9: the cookie-hostRef test fails on the signature, not the hostRef. T-10: socket hang up under full-suite load (31/31 in isolation ×3).

5. Verdicts on findings already raised in this PR

The mapping of Round-5 ids to review ids was recovered from the commits: R5-B1→B1 0889b678b, R5-B2→B2 edec1e7bc, R5-H1→H1 427ace910, R5-H2→H2 4d2c128ab, R5-H3→H3 a32b58986, R5-H4→H4 e8d3e4602, R5-H5→H5 838ba8c5f, R5-H6→H6 135d6a803, R5-H7→L5 1c561e909, R5-M1→M1 43f4e91d1, R5-M2→M4 72baaf513, R5-M3→M7 d7dc246e6, R5-M4→M10 c497d20ce, R5-M5→M11 f41817f64 (+ fixup 4235d1ab1), R5-M6→M12 479b5ae2b.

5.1 Items the author marked CLOSED

ID Verdict at ad1e137ac Evidence
B1 NOT CLOSED (publish path) INF-1; M6 PARTIAL
B2 CLOSED Real-PG grant mutants B2a–d KILLED. The shell contract test it cited is broken and not in CI (TEST-1).
H1 PARTIAL Destination aligned (44-row matrix); authorization is unreachable (N1).
H2 CLOSED Migration 0113 covers every entity_type producer; real-PG test kills the widened CHECK.
H3 PARTIAL Lease mount/deny mutants H3a–f KILLED; close-on-disconnect H3g/RP-3 SURVIVED (T-2).
H4 Production code CLOSED, proof NOT valid for Desktop T-1: H4a/b/c SURVIVED; Sandbox H4d KILLED.
H5 CLOSED Real client with 503 → no child, ROLLBACK witness; mutant KILLED.
H6 CLOSED H6a/b KILLED.
L5 CLOSED (v2 paths) L5a/b/d–g KILLED; legacy WS strip untested (T-3).
M1 CLOSED Permanent denial terminalizes and is not re-checkpointed; mutant KILLED. Edges in F5.
M4 PARTIAL recordDecision fixed; trigger reauth still uses a 2nd pool connection (CA-F2).
M7 CLOSED Wiring survives both sync merges.
M10 CLOSED GFSC ×9 and WFC ×9 mutants KILLED.
M11 CLOSED for fallback/retry (M11a/b KILLED) The continueWithToolResults checkpoint is untested (T-B1); rehydrate bypass (F1).
M12 CLOSED with gaps M12a/b KILLED in real-PG only; runtimeAge >= 0 untested (T-5); inclusive boundary untested.

5.2 Items the author marked INVALIDATED

ID Verdict Note
M3 STILL VALID (REPRODUCED) Section 3
M5 STILL VALID (dormant) Section 3
M8 RE-OPENED with new evidence CQ-F1
M9 RE-OPENED with new evidence CQ-F3
L1 VALID (latent, fails closed; REPRODUCED) Section 4
L2 Invalid Names are NFC-enforced at creation; GFS binds a UUID.
L3 STILL VALID (REPRODUCED) Section 4
L4 Valid observation, not a defect Renew is bounded by the 8 s upstream timeout; residual only if it is set above 30 s.
L6 Valid observation, not a defect Only the confined vs unconfined pairs need the distinctness check.
L10 Low hardening Callers include per-host mcp-host JWT holders; work is bounded by AccessExecutionBudget.
L12 STILL VALID (test gap) Section 4
L13 Invalid at head 4d2c128ab kills the mutant.
L16 Partly re-opened CQ-F4 (no readiness-writer fixture).
L17 Invalid (REPRODUCED) Node decodes header values as latin1, so .length equals bytes; Buffer.byteLength(…,'utf8') would be wrong.

5.3 Items the author accepted as non-blocking follow-ups

Settled; recorded here with their state at head.

ID State
M2 Not re-verified (see the coverage note in §3).
M13 / claude §8.2 The claim is incorrect: 5- and 6-key write bindings pass the WFC target check (REPRODUCED). Nothing to fix.
L7 Present: 5 of 6 reporters swallow errors silently (only WRC logs).
L9 Present and wider than reported: GFSC and WFC also hardcode the checkpoint path.
L11 Present, mitigated: jti reuse still raises an unmapped 23505.
L14 Present.
L15 / claude §8.1 Unresolved: mcp-host does not re-compare attribution; GFSC/WFC do.

5.4 claude[bot] observations (§8 of the owner context)

Item State
§8.1 mcp-host attribution Unresolved (L15).
§8.2 WFC write target Claim incorrect.
§8.3 WRC checkpoint inside lock + FOR UPDATE Unresolved (dbRunProcessor.ts:475, 497).
§8.4 UTF-16 header length Claim incorrect (L17).
§8.5 readiness-writer CodeQL fixture Unresolved (CQ-F4).

5.5 Pre-existing / R0 items

Item Verdict
Legacy raw-hostRef SSRF sink PRE-EXISTING, not worse (desktopProxy.ts:259, 364-367 equal base :200/:249).
Legacy branch of v2OrLegacyHostAllowed PRE-EXISTING, not worse.
authorization_catalog_environment TSV PRE-EXISTING, 0 changes.
L8 console.log INTRODUCED (1 line), not pre-existing (LOG-1).

5.6 The 18 CodeQL inline alerts (all currently dismissed)

Lines were resolved through the code-scanning API; the inline line numbers are stale.

Alert Head location Verdict
1340 / 1341 / 1342 rpc-proxy/src/routes/rpc.ts:784-788 GET /rpc/hosts/:hostRef/sessions/search True positive, new route (absent at base; the author labelled it pre-existing). Authenticated + requireScope + v2 checkpoint; acceptable by design. Note that rpc-proxy has no limiter (pre-existing), and PR2 adds this route and /desktop/:hostRef/reconnect.
1312 / 1313 desktopProxy.ts:179-187 POST /session PRE-EXISTING route.
1314 / 1315 desktopProxy.ts:188-196 POST /reconnect New, v2 only, covered by the throttling decision.
1316 desktopProxy.ts:239 view ALL PRE-EXISTING route, v2 guard added.
1292 (SSRF) desktopProxy.ts:317-320 v2 WS upgrade New v2 sink (not pre-existing); guard holds: canonicalHostRef rejects / and \, NAMESPACED_REF_PATTERN, signed target hash.
1302 / 1303 control-api/src/routes/internal/actionAuthorityCheckpoint.ts:24-25 By design (L10).
1304 actionAuthorityHostWake.ts:32 False positive (rateLimitMiddleware at :33-40).
1305 pr2ReadinessEvidence.ts:14 By design, but the guard is weak (M5).
1307 / 1308 / 1309 control-api/test/* Test-only.
1296 mcp-host/src/runtime/pr2ReadinessReporter.ts:29-36 By design (credential to the configured Control API base + fixed path).
1362 rpc-proxy/src/routes/sandboxUi.ts:686 False positive (isV2ViewRequest needs middleware-set state; the legacy cookie is verified as at base).

6. Decisions for the PR owner

  1. Dormant v2 defects vs merge. F1 (High), N1 (High), M3, M5 and CA-F2 are unreachable while actionContext is frozen 'unavailable'. Decide whether PR2 must close them, or whether they become tracked preconditions for v2 activation. Recommendation: fix F1 and N1 in PR2. They are PR2's own invariants (§3 live revalidation; H1 closure) and PR3 builds on them.
  2. INF-1 severity. It breaks the first dev publish of rpc-proxy, not a live service. Recommendation: fix together with C1 (two small config edits).
  3. CodeQL on the exact head. Decide whether acceptance requires a CodeQL dispatch on ad1e137ac (CQ-F6) and whether the re-opened M8/M9 query weaknesses must be fixed in PR2.
  4. Re-opened author invalidations. M3, M5, L1, L3 and L12 have new reproductions or exact code paths. They need an owner verdict.
  5. Rollback posture (C6). Decide whether "old verifier accepts v2 tokens" during the evidence-age window is acceptable for this rollout.

7. §12 checklist (consolidated)

# Question Answer Where
1 Untrusted input creates authority NO rpcDelegations.ts issues only after live authorizeActionV2; strict edge envelope (edgeRuntimeAuth.ts:74-203). Defense-in-depth gap F2.
2 Session/team/path claim bypasses live authorization NO for request paths loadPrincipalAuthoritySnapshot; checkpoint re-evaluates. Exception: F1 rehydrate.
3 Delegated operation/target widens downstream NO today routeActionBindingV2.ts:274-299, edgeRuntimeAuth.ts:268-283, GFSC/WFC exact compare. Latent: S1.
4 Failed v2 reaches legacy behavior NO in production code middleware/auth.ts:34-57, mcpProxyService.ts:100-111, desktopProxy.ts:289-326. The Desktop tests do not prove it (T-1).
5 Protected effect missing live checkpoint YES (dormant) F1. All other effects are covered.
6 Service credentials substitute user authority NO actionCheckpointCaller.ts:60-79; distinct tokens enforced by apply-inter-service-tokens.sh.
7 Stored provenance becomes bearer authority NO uploadSession.ts:322-331, uploadFinalizer.ts:129-134. Provenance exposure F3.
8 Workflow child transitions broader than the single edge NO WRC accepts only workflow.trigger (workflowActionCheckpointClient.ts:79-81); decide→consume is the only in-process edge.
9 Sandbox/RDP outlives revocation bound NO at default config activeViewLease.ts:3-4, 48-58 (10 s / 30 s); residual only if upstream timeout >30 s. Lease leaks RP-2/T-2 do not extend authority.
10 OAuth stale/duplicate/wrong recipe-client NO for v2 oauth.ts:47-87, recipeOAuthClient.ts:10-16. Header-absent path equals legacy base. CA-F6 date parsing is Low.
11 Filesystem crosses user/path/target NO WFC exact target (actionAuthority.ts:152-161); GFSC route checks (serve.ts:290-331, 547-550, 916-920). S2 vacuous duplicate check; S3 copy fails closed.
12 Readiness true with incomplete/stale evidence YES (dormant) M5 writer spoofing. Also: heartbeat outcome hardcoded 'passed', C6 rollback window, M3 false negative.
13 Custom CodeQL models hide unsafe variants YES CQ-F1, CQ-F3, CQ-F4, CQ-F5.
14 Tests fail when protection removed PARTIAL Killed: H3a–f, H4d, H5, H6, L5, M1, M10, M11a/b, M12a/b (real-PG), B2. Survived: H4a/b/c (T-1), H3g/RP-3 (T-2), M11c (T-B1), L5c, M12c, X-H2; untested: M3, M5, L12.
15 Cross-service fixtures emitted by real producers PARTIAL Delegation JWTs: yes. Checkpoint responses (T-7), readiness bodies (T-8), GFSC/WFC fixtures (T-B3): no. The runtime_session fixture bypasses authorization (N1).

8. Verified clean

  • Sync merges: 6a14a73a4 and ad1e137ac were re-created and produce identical trees, so there is no evil merge. The only conflicts were external-rest-api/src/releaseManifest.ts and the control-api package/lock. validate-release-version-bumps EXIT=0.
  • Delegation JWT:
    • RS256 only, with issuer and audience; exact claim set; TTL ≤300 s; iat skew ≤30 s.
    • Canonical target with matching hash.
    • Separate audiences for GFS, WFC and legacy RPC tokens.
  • Canonicalization (REPRODUCED, 15/15): key order does not change the hash; __proto__, arrays, non-strings, nested objects and control characters are rejected; NFC and NFD forms hash differently, so there is no collision; trimming is applied consistently before both check and effect.
  • Headers: x-clerum-edge-* is stripped on all HTTP routes and before proxy.ws on both WS branches. The Sandbox proxy strips cookie, authorization and x-clerum-*. The delegation header is in both redaction lists.
  • No v2 → legacy fallback in production code in rpc-proxy, mcp-host, external-rest-api, GFSC or WFC.
  • Service tokens: callers are confined by resource type and operation. GFSC/WFC/RPC tokens must be distinct. Comparisons are timing-safe. Every internal route after the gate also requires a named service.
  • OAuth resolves exactly one recipe client before any secret use; no oauthClients.find remains.
  • Operation registry: 43 ids equal 43 wire definitions. Nothing routed by rpc-proxy is outside the registry. Request shape (11 keys) and header names are consistent across 5 consumers.
  • Mixed-version rollout: new consumers with an old control-api fail closed everywhere.
  • v2 enablement is gated on all 17 hops, and runtime evidence requires image_revision == activated source.
  • Hygiene: no placeholders; no masking || true / continue-on-error in gates; CodeQL suite exclusions paired with same-@id replacements.

9. Suites and mutation runs

All runs used Node 24.18.0 with npm ci (scripts enabled). Counts are what executed; lint was not run.

Service Result
packages/action-context-contracts 9 / 9 pass
rpc-proxy 144 files · 575 / 575 pass · tsc EXIT=0
control-api unit 527 files · 5547 pass · 0 fail · 293 skipped (real-PG-gated + 3 network) · tsc EXIT=0
control-api real-PG (PG16) 55 files · 292 / 292 pass · all 30 CI critical suites present
external-rest-api 45 files · 308 / 309 (1 flaky routes.gfs.test.ts socket hang up; 31/31 in 3 isolated reruns)
workflow-recipes 2819 pass · 29 skipped
gfs-controller 631 pass · 9 skipped · real-PG lane 9 / 9
workspace-files-controller 121 pass
host-connectivity-controller 2376 pass
mcp-host 3795 pass · 1 todo (pre-existing)
Mutation runs tests-b: 26 / 27 killed (M11c survived). tests-a: see §7 row 14. Tree restored and verified clean after each mutant.
CI Run 34863665613 on ad1e137ac: 37 checks pass. No CodeQL analysis exists for this head (CQ-F6).

10. Recommendation

Do not merge at ad1e137ac yet.

Fix before merge:

  • C1: rpc-proxy would not start on any cluster deployment.
  • INF-1: rpc-proxy image publish is broken, so B1 is not actually closed.

Both are small configuration edits.

Strongly recommended in PR2, because they are PR2's own invariants and Round-5 closure claims:

  • F1: checkpoint loss after rehydrate.
  • N1: runtime_session/chat authorization is unreachable.
  • T-1: the Desktop H4 proof is vacuous.

Can be follow-ups tracked before v2 activation: the Medium items, especially CA-F1/CA-F2, M3, M5, the CodeQL query weaknesses, and a CodeQL dispatch on the final head.

Low items are optional hardening.

Process: PR1 #334 is still OPEN on GitHub (not merged to dev); its branch has only been merged into PR2, twice. The PR body should be refreshed to name ad1e137ac (DOC-1).

@josue-tech-94

Copy link
Copy Markdown
Contributor Author

@claude Please do a final independent review of this entire PR at the current head and determine whether PR2 is safe and complete as the base for PR3.

Context:

  • PR1 has NOT been merged into dev.

  • PR1 was only synchronized with the latest dev changes and remains open.

  • PR2 remains stacked directly on PR1.

  • The required Task 106 topology remains:

    PR1 -> dev
    PR2 -> PR1
    PR3 -> PR2
    PR4 -> PR3

  • Do not recommend folding/merging the stack yet. PR1 must not merge into dev until PR3 and PR4 are complete and the final stack has been reconciled upward.

  • PR1 and PR2 currently have green exact-head CI and CodeQL/GHAS.

  • PR2 completes the runtime/domain-action layer of the user-centric access migration: the authenticated principal remains the user/session, while protected actions use live validated access paths and current authority.

The latest acceptance coverage also specifically proves the original team-derived Host problem:

  • a user with no direct Host grant can reach the Host through an active team membership/grant;
  • the Team remains authorization provenance rather than becoming the authenticated principal;
  • the real access path crosses delegation, rpc-proxy route binding, live authority checkpointing, trusted-edge context, and the protected mcp-host runtime effect;
  • stale/revoked authority is denied;
  • v2 failures do not fall back to legacy/team-token authority.

Please review the complete effective PR2 diff against its current PR1 base, including:

  • correctness and regression risk;
  • authentication/authorization boundaries;
  • direct vs team-derived access paths;
  • user identity vs team provenance;
  • live membership/grant revocation;
  • stale path/revision handling;
  • no v2 -> legacy authority fallback;
  • Control API -> rpc-proxy -> mcp-host authority propagation;
  • runtime/provider effects requiring current authority;
  • workflow/filesystem/derived-view authority contracts where changed;
  • producer/consumer contract consistency;
  • whether the producer-backed tests genuinely prove the security and runtime contracts rather than merely matching fixtures.

PR3 handoff context

If PR2 is sound, PR3 will be stacked on this PR and will perform the Desktop cutover.

PR3 is expected to:

  • move Desktop to one logical user session;
  • remove authentication-level team-token switching;
  • consume the complete direct + all-team authorized inventory;
  • make active team selection presentation/navigation context only;
  • ensure resources do not disappear merely because a different team is active;
  • deduplicate resources available through both direct and team-derived paths while retaining usable path provenance;
  • send the selected access path into the PR2 downstream authority model;
  • allow team-only Host access to work end-to-end without changing authenticated identity;
  • observe live membership/grant revocation without requiring logout/re-login;
  • preserve chats, tasks, streams, notifications, and relevant state across team navigation.

Please explicitly conclude one of these:

  1. No objection — PR2 is safe as the technical base for PR3, or
  2. PR2 is not yet safe as the PR3 base, with the concrete must-fix issue(s) and why PR3 cannot safely rely on the current contract.

Do not review or design PR3 implementation itself beyond identifying a concrete PR2 contract or behavior that PR3 would be unable to rely on.

Also do not treat PR1 as merged into dev or recommend changing the required four-PR stack/fold order as part of this review.

@claude

claude Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Claude finished @josue-tech-94's task in 6m 7s —— View job


Final independent review — PR #594 @ 4b8147a8 (head), base feat/user-centric-access-foundation (PR1, still open)

I reviewed this exact head as a merge/readiness pass focused on your PR3-handoff questions, tracing production code rather than re-litigating resolved history. I independently re-verified the concrete blockers from the last external review (ad1e137ac) against the current head, and I checked the team-derived Host contract that PR3 will build on.

Verdict

1. No objection — PR2 is safe as the technical base for PR3.

The v2 authority model is implemented consistently and fail-closed end-to-end, the team-derived access-path contract PR3 needs is present and stable, and every must-fix item from the prior external review is now closed at this head. The remaining items are test-completeness gaps, not contract defects — but two of them are worth closing in PR2 or early PR3 because PR3 depends directly on them (see §3).

Exact-head CI (35252580274) and CodeQL (35252716891) are green. Stack topology is unchanged: PR2 → PR1, PR1 not merged to dev. I am not recommending any fold/merge.


1. Prior blockers — all closed at this head

Prior finding Status @ 4b8147a8 Evidence
C1 rpc-proxy crashes on cluster start (RPC_PROXY_MCP_SERVER_NAMESPACE unprovisioned under NODE_ENV=production) Closed Now set in deploy/base/rpc-proxy/rpc-proxy.yaml:35; guarded by rpc-proxy/src/config.hccBaseUrl.test.ts:71-73 asserting both the config default and the deployment env.
INF-1 rpc-proxy image publish COPY-not-found (build context not root) Closed build-publish.yml rpc-proxy matrix entry now has rooted: true (:284-287); the "Resolve build context" step sets context=. for rooted images.
T-1 Desktop "no v2→legacy fallback" tests vacuous (cookie signed with wrong secret) Closed Tests now sign with config.desktopCookieSecret (desktopProxy.test.ts:64) and add a positive control ("proxies a valid legacy-only desktop session", :286), so the "does not downgrade" cases can actually fail.
N1 runtime_session/chat operations unauthorizable (not_found) Closed authorizationResourceForAction (actionAuthorizer.ts:65-77) rewrites runtime_session/chat to the owning host via target.hostRef before authorization; loadSimpleOperationalGrantCandidates (accessAuthorityStore.ts:412-426) grants the host chat.read, task.read/manage, model.read/select, session.read/manage. Original resource identity is retained in the issued token (:196-201).
F1 rehydrated approval-suspended v2 task runs without live checkpoints Closed Binding is persisted with the pending-approval row (sqliteConversationStore.ts:1041), restored on bootstrap (stateMachine.ts:723-727), and the rehydrate path fails closed on a missing/corrupt binding (sqliteColdStartLoader.ts:164 + strict validRpcApprovalSource). Proven by crossPodRestart.test.ts (round-trip + corrupt-provenance drop) and taskExecutor.test.ts:321-373 (checkpoint runs before the provider on tool-result continuation; denial → access_path_stale). This also closes the related T-B1/M11c (continueWithToolResults) coverage gap.

2. Team-derived Host contract (the core PR3 dependency) — sound

The specific "team-only Host access" invariants PR3 relies on are implemented correctly and, for the security decision, genuinely proven:

  • User stays the authenticated principal; team is only provenance. Access paths are signed with principalUserId, never the team (accessPath.ts:60,71-99); teamId is a carried provenance field only. The runtime edge sets trustedEdgeContext.userId = claims.sub with pathKind/effectiveTeamId as separate provenance, and the interop test explicitly asserts trustedEdgeContext.userId === userId and !== teamId (userDelegationV2.producerInterop.test.ts:205-206).
  • Team-derived Host authorization is resolved live. accessCatalogFamilies.realPostgres.integration.test.ts:590-670 drives the real buildAccessCatalog + resolveLiveAuthorization against real Postgres; the team candidate comes from an actual team_agents JOIN team_members … status='active' query (accessAuthorityStore.ts:368-373).
  • Live revocation denies. Same real-PG test sets team_members.status='deleted', then asserts the catalog returns [] and the resolver returns not_found (:649-669). The commit chain (d0f9e5c7/268bef38/1a04e33f) deliberately settled the expectation on the resolver's real fail-closed contract.
  • No v2→legacy fallback. Production paths in rpc-proxy/mcp-host/OAuth were verified clean by two prior independent reviews and re-confirmed here; the interop test asserts verifyRpcToken(v2Token) === null, and authorizeActionV2 maps any invalid/missing authority to authority_unavailable (503) with no alternate deny (actionAuthorityV2.ts:189-206).
  • Control API → rpc-proxy → mcp-host propagation carries the exact access-path provenance (accessPathId, authorizationRevision, behaviorBindingHash, pathKind, effectiveTeamId) through token → route binding → checkpoint request → trusted edge → runtime effect.

3. Non-blocking items PR3 should be aware of (test completeness, not contract defects)

None of these block PR2 as the PR3 base — the production code is correct and fail-closed. But because PR3 builds directly on this contract, I'd close the first two early:

  1. The genuine team-derived authorization + revocation proof runs only on real Postgres, and that lane is skip-gated. accessCatalogFamilies.realPostgres.integration.test.ts is describe.skip unless CONTROL_API_REAL_PG_ADMIN_URL is set. If that suite isn't in the pinned critical-suite CI list (prior review's T-6 said db.realPostgresMigration.integration/gfsUploadSession.realPostgres were not), the strongest security evidence for the exact behavior PR3 depends on wouldn't run per-PR. Recommend: confirm this real-PG suite executes in the PR CI lane. Fix this →
  2. The runtime interop test stubs the authorization decision. emitActionAuthorityCheckpointV2Fixture.ts calls the real checkpointActionAuthority wrapper but stubs authorize → allowed with synthetic ['fixture.authorized'] capabilities, so the runtime edge never exercises a denied team-derived checkpoint. The denied → access_path_stale branch (mcp-host/src/runtime/actionAuthority.ts:104-106) is not exercised with team provenance. Recommend: add a runtime-edge test that drives a revoked/denied team-derived checkpoint through authorizeActionV2 → executeRuntimeEffect.
  3. N1 rewrite is covered in halves. The rewrite-to-host is asserted with a mocked resolver (actionAuthorizer.test.ts:91-172) and the real host-authority store is exercised only asserting host.read; no single real-resolver test asserts host authority yields a runtime_session/chat capability. Fail-closed, but a gap worth closing when runtime v2 activates.
  4. CodeQL model narrowness (prior CQ-F1/F3/F5): the repository-owned analyzer suppressions match by callee name / line order rather than dominance, and a couple of "unsafe" fixtures use concatenation the model rejects. Production code is safe today; these are query-hardening items, not a detection hole I could turn into a concrete exploit at this head.

Scope notes

Consistent with your framing: PR2 remains stacked on PR1, PR1 is not merged to dev, and the four-PR fold order is unchanged — I'm not recommending any merge/fold. The dormant-until-activation caveat still holds (v2 runtime effects only engage once Control API mints an actionContextV2), which is why the items in §3 are pre-activation hardening rather than live risks. I did not review PR3 implementation itself; I only checked that the PR2 contracts PR3 names are present and stable, and they are.

Overall: no must-fix blocker introduced by this PR; 4b8147a8 is safe as the technical base for PR3, with the §3 test-hardening items (especially #1 and #2) recommended to close early since PR3 depends on exactly those paths.

@evenfire-ai evenfire-ai deleted a comment from alfredolopez80 Sep 17, 2026
@josue-tech-94

Copy link
Copy Markdown
Contributor Author

@alfredolopez80 thanks

We’ve completed the full adjudication/correction cycle and synchronized the latest accepted PR1 base into PR2.

Could you please do one final review of the current exact PR2 head:

  • PR2: 0f8cbb6b79560538a6274a200a89fdfc67ba97cc

Current exact-head validation is green:

  • CI 35302832777: all 37 checks passed.
  • CodeQL 35303355943: successful.
  • JS/TS analysis job 105470446445: successful.
  • GHAS check 105474340384: successful with zero annotations / no new changed-code alerts.

Since your previous review, we also completed the verified PR2 corrections and cleaned the relevant pre-existing/base issues in PR1 before synchronizing it back into PR2.

Please treat the previous findings/comments as historical unless they still reproduce on this exact current head.

For this final pass, please focus on:

  • current correctness;
  • security and authorization boundaries;
  • cross-service contract consistency;
  • migration/stack integrity;
  • regressions introduced by the correction/synchronization work.

If a previous item still applies, please reopen it with current-head evidence. Also please distinguish any PR2-owned issue from a base/pre-existing concern.

The main question is whether this exact PR2 SHA is safe to use as the PR3 stack base.

@alfredolopez80

Copy link
Copy Markdown
Contributor

@josue-tech-94 — final re-review of the exact head you asked for, following up on the previous re-review.

Head reviewed: 0f8cbb6b79560538a6274a200a89fdfc67ba97cc (unchanged while I worked). Delta since 4159bcc73: 12 correction commits plus the PR1-base merge 0f8cbb6b7, 37 files, +679/−99. As you asked, previous items are treated as historical unless they reproduce on this head, and every open item is attributed PR2-OWNED or BASE (a PR1 commit, or older than this stack).

Answer to the main question

Yes — 0f8cbb6b7 is safe to use as the PR3 technical base, with one condition and one PR1 fix:

  • Condition (PR2-OWNED): D1 must be resolved before v2 is activated, and PR3 must not treat the current host-level check for the seven runtime-session operations as a per-session owner check. It is dormant today because the v2 readiness gate is still frozen.
  • PR1 fix (BASE): seed.mjs regressed in the diagnostics fix and now throws ReferenceError on every failure path. Fixture-only, no runtime impact, still exits non-zero.

No Blocker or Critical. No regression from the PR1-base merge: I compared the committed merge with git merge-tree of its parents. The three files with conflicts (rpc-proxy/src/routes/sandboxUi.ts, deploy/images.json, .github/workflows/build-publish.yml) were resolved correctly. The sandbox resolution keeps PR2's v2-wrapped route and applies PR1's once → on change. The two manifest resolutions keep both sides (packages/llm-providers/** and scripts/qa-recorder/**). Version bumps agree across package.json, lockfiles and external-rest-api/src/releaseManifest.ts.

Counts, still open at this head: Blocker 0 · Critical 0 · High 1 (dormant, PR2) · Medium 3 · Low 12


1. Verdicts on items from the previous review

ID Verdict @ 0f8cbb6b7 Evidence Owner of fix
N-F1 FIXED · REPRODUCED A v1 desktop approval now survives restart. The probe used real SQLite across three pods (A stores, B rehydrates, C reloads with no cache): for both approve and deny, the decision returns {success:true}, the session is idle on pod C, 0 rows remain, and startTurn works. Dropped approvals are now resolved durably: releaseDropped (sqliteColdStartLoader.ts:228-239) calls persistApprovalResolved(conv, requestId, 'cancel'), which deletes the row and writes state=idle for all three drop paths (:173-177, :195, :206). Mutants killed: (a) removing the v1/v2 check, (b) disabling the corrupt-v2 drop, (c) removing the durable cleanup. Each gives EXIT=1. PR2 90ec8d256
F1 still FIXED · REPRODUCED These mutated v2 rows are still dropped and never rehydrated unguarded: authorityV2 set to null, {}, {version:2} or false; a bad targetHash; sender ≠ userId; channelType changed. See §3 for the one boundary of the v1/v2 check. —
T-2 FIXED · REPRODUCED Combined mutant: 3 failed / 599 passed. Each single-site mutant is killed (desktop HTTP, desktop WS, sandbox). PR2 82c344421
RP-5 FIXED · REPRODUCED Listeners switched from once to on. Each filters with if (innerReq !== req) return (sandboxUi.ts:769,791) and is removed by off on close/finish (:826-827). A throwaway test used the real http-proxy, the real router and a slow upstream, with four overlapping requests (two slow, one fast, one redirecting off-origin). At head it passes 5/5: every response has XFO DENY and CSP frame-ancestors 'none', each request's x-clerum-user reaches the upstream intact, and the off-origin Location is dropped. With shared once restored it fails 0/5. The repo test at sandboxUi.test.ts:763-809 really is concurrent and kills that mutation (1 failed / 60 passed). BASE 07746b867
RL-D1 FIXED · REPRODUCED control-api/src/routes/rpc-access/users.ts:246-248 returns host-artifact-pre-admission:unauthenticated, and :265-267 returns host-artifact-read:unresolved, instead of null. Mutating either key back to null fails the matching test in routes.rpcAccessUsersArtifactRateLimitDefense.test.ts (EXIT=1). The middleware's own null-key fail-open (rateLimitMiddleware.ts:36-41) still exists, but these routes no longer reach it. BASE 4fd4ee43a
RL-D2 FIXED — and my previous reading was wrong routes.rpcAccessUsersMcpHost.test.ts:384-422 already proved the order (subject limit → directory/k8s authorization → subject+Host limit) and the 200 allowed branch. :508-526 proves a mismatched user or Host consumes no budget, with a liveness witness. These tests predate the previous head (d895141a5); my "denied branch only" statement was incorrect. —
D2 PARTIALLY FIXED · REPRODUCED Classification is now locale-independent: SQLSTATE 57014 plus a per-client "our timeout started" marker, set only after our set_config('statement_timeout') succeeds (accessDatabaseQuery.ts:56-80,105-129, accessAuthorityTransaction.ts:9-22, operationalAccessIndex.ts:132-140,215-227), plus client elapsed ≥ timeoutMs. accessDatabaseCancellation.realPostgres passes 10/10 under both lc_messages=es_ES and C. Restoring the old English-substring check fails 3 tests under es_ES. lock_timeout is SQLSTATE 55P03, so it cannot be misclassified. Correction to my previous review: I said the old bug turned a deadline into an infinite retry. That overstated it: both outcomes map to authority_unavailable, retryable:true (liveAuthorizationResolver.ts:430-436, actionAuthorizer.ts:79-82,225-236). Only the label and the catalog status differed (routes/external/access.ts:125-139: 503 vs a rethrown DatabaseError). New Low residual in §3. BASE 3c82b7a9c
D4 PARTIALLY FIXED · READ-ONLY The seven targeted assertions now match SQLSTATE 23505/42501. Two English-text matches remain: gfsReaderRole.realPostgres.integration.test.ts:210 and gfsReadiness.realPostgres.integration.test.ts:155 (/coherence check failed|permission denied/i). BASE (5e6c990f8)
INF-3 PARTIALLY FIXED · REPRODUCED All nine mutations from the previous table now fail the guard, including the four that survived. Residual: two of them fail only because the self-test's mutations name specific images. The same mutation on an image the self-test does not name still passes: workflow-recipes (or host-context-controller) with a path lacking package.json and source_paths trimmed to ["workflow-recipes/**"] gives EXIT=0, PASS. Cause: test-dockerfile-local-packages.sh:283-293 skips a row whose package.json is missing when none of its sources start with packages/, and the row-count check at :306-321 derives its expectation by the same rule, so the two can never disagree. Scenario: a service directory is renamed and its packages/** sources are trimmed in the same edit; the guard stays green and the image loses local-package change detection. PR2 a55060e3e
INF-4 FIXED for this path, not in general · REPRODUCED scripts/qa-recorder/** is in build-publish.yml:134 and deploy/images.json:42. Removing it from both is caught only by the new hard-coded check (test-images-manifest.sh:654-691; 25 PASS at head). Adding a new root-relative COPY scripts/<other> to a Dockerfile passes both guards. A scan of every published Dockerfile finds no other untracked COPY today. BASE 0947823dc
Delta-1 FIXED · REPRODUCED Removing canonicalActionTarget(...) at workflowActionCheckpointClient.ts:24 now fails the test (1 failed / 4 passed). PR2 94999d5a2
Delta-2 REGRESSED · REPRODUCED See §2. BASE 702bd35b5
Delta-3, C1 test name FIXED The dead not.toContain('Bearer sensitive-token') was removed. The C1 test is renamed to "keeps the base deployment on the required MCP server namespace" (config.hccBaseUrl.test.ts:69), so it no longer claims to render overlays. PR2 99093130d, 22a54a90b

2. New defect introduced by the corrections

Delta-2 regression — Medium · REPRODUCED · BASE (702bd35b5, arrived via the merge)

tests/e2e/fixtures/codex-subscription/approved-tools-setup/seed.mjs:28 declares describeFixtureError with const inside the try that opens at :20. The catch at :59 uses it at :64, where it is out of scope. Every failure path throws ReferenceError: describeFixtureError is not defined, and the original error never reaches stderr. The operator now sees less than before the fix. Reproduced by running the real seed.mjs with a loader hook that stubs the /app imports and makes an import throw ECONNREFUSED: EXIT=1, and stderr contains only the ReferenceError. It still exits non-zero, so it does not pass silently.
The rest of the fix works: identity-lifecycle.mjs:11-17 describeFixtureError keeps only the regex-bounded name, code and cause, and never the message, so no DB URL or token can leak. identity-lifecycle.test.mjs passes 18/18.
Fix: hoist the import above the try, or declare the binding with let outside it. Fix it in PR1 and resync.

3. Still open

D1 — High (dormant) · PR2-OWNED (a203fa693, 48c34094f) · unchanged

packages/action-context-contracts/index.cjs:625,627,633,635,639,641 register the six runtime-session operations without hostBinding (compare :541, :616). chat.read at :614 is also unbound. actionAuthorizer.ts:65-77 rewrites runtime_session/chat to the host, so the session or chat owner is never checked. On a shared host, any holder of host.use can mint a delegation for session.manage targeting another user's chat and get allowed. It is dormant because the readiness gate is still frozen (control-api/src/services/access/userAccessPolicy.ts:390 actionContext: 'unavailable' inside the frozen object at :372; :222 requires 'ready'; no source sets 'ready'). This is the condition on using PR2 as the PR3 base.

N1 residual — Medium · PR2-OWNED (198ce7017) · unchanged

The seven operations authorize through the host redirect, but no test drives them through the real resolver or store. control-api/test/fixtures/emitRuntimeSessionDelegationV2Fixture.ts:49 still stubs resolve, actionAuthorizer.test.ts:47,62,157 inject it, and no real-Postgres test covers these operations. It should land together with the D1 fix, because that test would have to assert the owner check.

D3 — Medium (latent) · PR2-OWNED (427ace910, da2426171) · unchanged

actionDestination.ts:48-49 resolves destination null for resource type chat, and rpc-proxy/src/actionAuthorityV2.ts:132,146-147 answers invalid_binding. The first consumer of chat.read gets a hard failure. There is no consumer today.

Low

  • D2 residual (BASE, REPRODUCED under both locales): an operator pg_cancel_backend that the client observes at or after timeoutMs is classified as a budget deadline. The probe cancelled at ~20 ms with a 500 ms timeout, then held Node's event loop for 700 ms, and got AccessBudgetExceededError. Triggers: event-loop lag or network latency. It has no authorization effect, since both outcomes fail closed and retryable; only the label changes. The reverse error is impossible, because the client clock starts before the server's.
  • RP-5 test gap (BASE): the concurrency test emits only proxyReq. Restoring once on proxyRes alone keeps sandboxUi.test.ts green (61/61), while the real-proxy test fails 0/5. That regression would drop the CSP/XFO floor on concurrent sandbox views and still ship green.
  • Stale comment (merge resolution): sandboxUi.ts:760-763 still says "one-shot listener". The comment update from 07746b867 was lost when the conflict was resolved; the code itself is correct.
  • T-2 residual (PR2): no test covers a client that aborts before the response finishes. Deleting only the HTTP close handler (desktop or sandbox), or only the desktop WS error handler, survives 101/101.
  • v1/v2 boundary (PR2, READ-ONLY for impact): "v2 expected" is decided only from the persisted JSON (expectsV2ApprovalSource, sqliteColdStartLoader.ts:115-122). A row whose authorityV2 key is deleted, or whose source_message is NULL, rehydrates as v1 without checkpoints. It is not an escalation: only the mcp-host DB worker writes that file (dbWorker.ts:54), anyone who can edit a row can already rewrite the pending tool parameters, and a live v1 task already runs unguarded (routes.ts:359-361). But if a future serializer drops the key, v2 tasks would silently come back as v1 instead of failing closed. A server-side marker column would close this.
  • Malformed source_message aborts bootstrap (PRE-EXISTING, 5d663a496, 2026-07-14): the JSON.parse at sqliteConversationStore.ts:557-559 throws on one corrupt row and aborts the whole bootstrap(), so mcp-host does not start. It fails loudly.
  • CQ-F6 (BASE): .github/workflows/codeql.yml:21-23 still triggers only on [main, dev], so CodeQL does not analyze PR2 against its real base. The green CodeQL run you cite is not evidence for this PR's delta against PR1.
  • RL-D3 (BASE): EvenfireMissingRateLimiting.ql only requires the keyGenerator to read sub and the Host, not to use them in the returned key.
  • rpc-proxy flakiness (PRE-EXISTING): did not reproduce this round (6/6 full runs, EXIT=0, 602/602). The two affected files are unchanged, so it stays open rather than fixed.
  • approved-tools/server.test.mjs:19-24 vs :37: server-close t.after still registered before client.close().
  • scripts/ci/install-test-producer-dependencies.sh:26: still discovers only *.test.ts/*.test.tsx.
  • INF-3 / INF-4 residuals: listed in §1.

4. Suites executed at 0f8cbb6b7

Suite Result
control-api unit (vitest run --no-file-parallelism) EXIT=0 — 475 files passed / 56 skipped; 5588 passed, 0 failed, 313 skipped. The 313 skipped are exactly the real-PG tests, run next.
control-api real Postgres (postgres:16-alpine, CI image, CONTROL_API_REAL_PG_REQUIRED=1) EXIT=0 — 56 files, 313 passed, 0 skipped
control-api accessDatabaseCancellation.realPostgres under lc_messages=es_ES and C (PG15) EXIT=0, 10/10 under each
rpc-proxy full suite (clean npm ci + producer deps), 6 runs 6/6 EXIT=0, 602/602 each
rpc-proxy targeted (desktopProxy, sandboxUi, sandboxUi.oauthV2) EXIT=0, 101/101
mcp-host (src/agent src/core/conversation src/__tests__/ironclaw src/db, Node 24) EXIT=0 — 64 files, 606 passed, 0 failed, 1 todo
workflow-recipes workflowActionCheckpointClient.test.ts EXIT=0, 5/5
identity-lifecycle.test.mjs via run-node-test-files.mjs EXIT=0, 18/18
test-images-manifest.sh EXIT=0, 25 PASS / 0 FAIL
test-dockerfile-local-packages.sh EXIT=0 baseline; 9/9 prior mutations killed; 2 new-shape mutations survive (§1 INF-3)

5. Stack position

PR1 #334 is still open and not merged to dev; PR2 remains stacked on PR1. This review answers only whether the PR2 SHA is a safe technical base for PR3. It does not authorize merging PR1 or PR2.


Read-only review. Each mutation ran in an isolated worktree and was restored byte-exactly, and every worktree was confirmed clean afterwards.

@josue-tech-94 josue-tech-94 changed the title Runtime actions use validated access paths Remove team tokens PR2: Runtime actions use validated access paths Sep 18, 2026
Comment thread rpc-proxy/src/routes/sandboxUi.ts Fixed
Comment thread rpc-proxy/src/routes/sandboxUi.ts Fixed
@josue-tech-94 josue-tech-94 changed the title Remove team tokens PR2: Runtime actions use validated access paths Remove Team Token PR2: Runtime actions use validated access paths Sep 18, 2026
@josue-tech-94

Copy link
Copy Markdown
Contributor Author
   @alfredolopez80 thanks

We’ve completed the remaining correction and stack-convergence work, including the final PR1 CI follow-up. Could you please do one final review?

Current validation is green:

The previously identified correction, migration, image/build-guard, lifecycle-test, package-resolution, and synchronization issues have been addressed and revalidated.

Please treat earlier review findings as historical unless they still reproduce on this exact current head.

For this final pass, please focus on:

  • current correctness and regression risk;
  • security and authorization boundaries;
  • cross-service contract consistency;
  • migration and persistence behavior;
  • build/CI guard integrity;
  • anything that would prevent this exact PR2 SHA from being used as the PR3 technical base.

If an earlier finding is still applicable, please reopen it with current-head evidence, and distinguish PR2-owned issues from base/pre-existing concerns.

Thanks.

@alfredolopez80

Copy link
Copy Markdown
Contributor

@josue-tech-94 Re-review at the exact head 19d4939e4d7ebd017b0d75e083a2e2715bc1a17d, read-only, four parallel agents (v2 authority core, rpc-proxy/transport, control-api migrations, build & CI guards) plus direct verification of the Blocker, the contracts package and gfs-controller.

Per your request, earlier findings are treated as historical unless they reproduce on this head, and every item is tagged PR2-OWNED or BASE/INHERITED.


Verdict on your last question: is this SHA safe as the PR3 technical base?

Not yet — two items to land first, both small. This is a change from my round-3 answer, and neither cause is a defect you introduced in the v2 logic.

  1. A body-parser ordering Blocker that arrived from dev and makes every body-bound v2 route return 400 (details below). It is dormant today because v2 cannot be enabled, but PR3 is the PR that enables it, so it would land in your lap.
  2. A migration alias regression introduced by 12d1c3e0a in this correction round, reproduced against a real Postgres 16.

Everything else is follow-up or Low. The v2 authority core itself held up well: the gfs-controller checkpoint is fail-closed, the upload-session authority binding cannot be downgraded mid-flight, and the contracts package has no version skew across its four consumers.

Scope note: merge-base(PR1, PR2) equals PR1's head fae8febb1, so this PR is fully synced with its base. Of the 602-file range, 396 files / +32674/−3550 are PR2-owned; the rest is inherited dev content.


Blocker — v2 delegation returns 400 on every body-bound route

Dormant · certain when enabled · root cause BASE, one half PR2-OWNED

rpc-proxy/src/routes/rpc.ts:369-373:

router.post('/rpc/:serverName',
  requireRpcAuth,
  requireScope('mcp:server:invoke'),   // v2 binding resolves here
  jsonBody,                            // body parsed after

middleware/auth.ts:74-78 dispatches to authorizeBoundRequestV2 as soon as req.userDelegationV2 is present. At that point req.body is undefined, so routeActionBindingV2.ts:28-42 does record(undefined) → {} and then requiredString(undefined) throws RouteActionBindingError('invalid_binding') → HTTP 400. authorizeActionV2 is never reached.

Affected: POST /rpc/:serverName, /rpc/hosts/:hostRef/messages, /wake, /approvals/approve, /approvals/deny, /model, and the three sandbox-ui OAuth routes. /wake (rpc.ts:642-645) has no jsonBody at all while its binding reads req.body.wakeReason.

Root cause is inherited: #669 / 05aebc259 removed the global express.json() from app.ts. Before that, the body was already parsed by the time requireScope ran, so the ordering was harmless. The PR2-owned half is sandboxUi.ts:558-561, where 8f46d9818 placed jsonBody after the auth middleware while stating the order preserves both v1 and v2.

Why CI is green: 15 rpc-proxy test files install their own body parser; only 3 exercise the real createApp(), and none of those covers a v2 body-bound route. A/B probe confirms prod=undefined vs testHarness={...} for model/messages/wake/approve/mcp-invoke.

This is fail-closed, not a bypass — no security exposure. But v2 is unusable until it is fixed.

Fix: move jsonBody ahead of requireScope on every route with a body-derived binding, add jsonBody to /wake, and add one integration test through createApp() asserting that a v2-delegated POST reaches authorizeActionV2.


High

H1 — 011b_llm_allowed_models_image_input renumbered without a legacy alias

PR2-OWNED · REPRODUCED on real Postgres 16 · control-api/src/db.ts:6285 · commit 12d1c3e0a

This migration is named 0115_llm_allowed_models_image_input on both origin/main and origin/dev. The rename to 011b_ carries no legacyVersions entry, so an environment already migrated from main does not recognise the existing row, re-applies the DDL and inserts a second one:

ORPHAN_PROBE_RESULT=["0115_llm_allowed_models_image_input","011b_llm_allowed_models_image_input"]

Not a Blocker: the body is idempotent (ADD COLUMN IF NOT EXISTS, EXCEPTION WHEN duplicate_object), so it is an orphan row rather than a failed deploy. The precedent and the rationale are already in this file — see the 0106 comment at db.ts:6170-6180, which describes exactly this scenario. One-line fix.

H2 — INF-3 still open: the workspace-package contract is skipped when package.json is absent

PR2-OWNED residual · REPRODUCED · scripts/tests/test-dockerfile-local-packages.sh:342-356, :380

52f6a5185 genuinely improved this — assert_manifest_matches_build_publish_matrix now catches single-file drift (verified: EXIT=1). But both signals that decide whether to skip still derive from the fields under validation: claimsWorkspacePackages reads image.source_paths, and the Dockerfile it consults is located via image.path. Gutting path + source_paths together silences both halves of the OR.

EXP1e on profile-ui — repoint path, drop packages/** from source_paths and the paths-filter block, consistently across images.json and build-publish.yml:

  • test-dockerfile-local-packages.sh → EXIT=0, PASS
  • test-images-manifest.sh → EXIT=0, 25 PASS / 0 FAIL

Fully green while profile-ui silently loses change detection for @clerum/desktop-app-links and @clerum/frontend-components.

Liveness witness: the derivation is live when package.json exists — dropping packages/** from control-ui gives EXIT=1 with 5 named FAILs. The hole is precisely the missing-package.json skip.

H3 — INF-4 still open: the root-relative COPY guard is hard-coded to two literals

BASE/INHERITED (0947823dc) · REPRODUCED · scripts/tests/test-images-manifest.sh:657-691

The only guard for a root-relative COPY scripts/... matches the literal "COPY scripts/qa-recorder" on the literal image name "control-ui". There is no generic rule deriving "every root-relative COPY <dir>/ in a published rooted Dockerfile must appear in that image's source_paths and paths-filter".

EXP2: inserted COPY scripts/release /app/scripts/release into control-api/Dockerfile (published, rooted) with no manifest or filter entry → both guard scripts EXIT=0, 25 PASS / 0 FAIL. Same failure mode as the qa-recorder bug this guard was written for.

H4 — make test-unit-all reports success on zero tests

BASE/INHERITED (5d663a496, 2026-07-14) · REPRODUCED · Makefile:120-132

The loop accumulates into failed; an empty TEST_SERVICES makes the body run zero times and the target prints success.

make test-unit-all TEST_SERVICES= → EXIT=0, "All unit tests passed.", zero suites executed.

Raising it here because 24096bd93 in this PR fixes exactly that class — a lost \ truncating the list. That particular truncation happened to fail loudly (orphan tab-indented lines → make parse error), but the recipe has no total > 0 assertion, so an edit that merely shortens the list degrades silently. Makefile:1507 chains this into validate-all, and README.md:217 / docs/testing/e2e-guide.md:262 present it as the way to run every service suite.

H5 — D1 unchanged: a host-scoped grant yields the full capability bundle

PR2-OWNED · dormant

accessAuthorityStore.ts:412-425 grants the whole bundle for any host grant, and operationTargetIsCurrentlyValid (:950-985) has no chatId/taskId predicate. Seven operations in packages/action-context-contracts/index.cjs are declared without hostBinding — chat.read, task.read, task.manage, model.read, model.select, session.read, session.manage — and that binding is the only thing tying the target to a resource (:887-896).

A complete fix needs both halves: remove the redirect at actionAuthorizer.ts:69 and add an ownership loader, and add resourceBinding to the seven schemas. Fixing only one leaves the gap open.

Related (Medium): the v2 fixture covers 5 of the 7 operations (session.manage and chat.read are absent), and its stub at :49-52 encodes the D1 redirect as expected behaviour — so the fixture can never catch D1.


Confirmed fixed on this head

  • Delta-2 seed.mjs regression — fixed and verified behaviourally, not just by reading. describeFixtureError is now imported at :19-20, above the try at :21. Ran the real seed.mjs under Node 24 with a stub loader and a catalog module throwing an ECONNREFUSED error whose message deliberately carried postgresql://clerum:s3cr3t-token@pg/clerum: EXIT=1, stderr {"event":"fixture_identity_operation_failed","operation":"create","failure":{"name":"Error","code":"ECONNREFUSED"}} — no message, no DSN, no token. Negative control: reverting the hoist reproduces ReferenceError: describeFixtureError is not defined at seed.mjs:64, so the harness genuinely exercises the catch path.
  • RP-5 test gap closed. once on proxyRes only → EXIT=1; once on proxyReq only → EXIT=1.
  • T-2 lease mutants. All four named mutants killed.
  • After-schema online-index execution restored. hasPostSchemaIndexes dropped from 3 occurrences to 0 in merge 503b4ea97; b7a362f37 restored 2 and 651daad35 all 3. The mutation dies in both suites — unit 4 failed / 30 passed, real PG 1 failed / 9 passed ("expected length 40 but got 39").
  • 184c93517 is not tautological. It deleted one obsolete assertion; the surviving guard asserts a property, and its vacuity mutation → EXIT=1.
  • assert_manifest_matches_build_publish_matrix is a real improvement: single-file drift → EXIT=1.

Medium (follow-up, not merge-blocking)

# Finding Attribution
M1 The six PR2 migrations renumbered 010f→0115 … 0114→011a, none carrying legacyVersions. The only alias guard (db.userAccessFoundationMigration.test.ts:35) pins PR1's 0109-010e only. Lower risk than H1 because those names never reached main PR2-OWNED
M2 migrationExecutionPolicy.realPostgres.integration.test.ts:362 — the test named "converges accepted legacy aliases without replaying historical bodies" is vacuous: removing an alias leaves it green (EXIT=0, 10/10). It never asserts migration.apply was skipped. This is why H1 and M1 went undetected BASE
M3 The real-Postgres suite-list guard is one-directional: it validates listed suites but never requires every *.realPostgres.integration.test.ts to be listed. 37 pinned, 62 on disk. EXP5a: removing accessCatalogFamilies… → EXIT=0. Mitigating: the aggregate --run realPostgres still executes them; what is lost is the rename tripwire PR2-OWNED (e2c76bcf4)
M4 make install-all (Makefile:112-117) uses bare wait, which returns 0 unconditionally. make install-all SERVICES=/nonexistent-service-xyz → EXIT=0, "All installs complete." validate-all runs it immediately before test-unit-all, so M4 and H4 compose: a whole-repo install failure followed by a zero-test "pass" BASE (5d663a496)
M5 The R6-M4 comment at test-dockerfile-local-packages.sh:518-521 claims omission of a published consumer "cannot leave Docker materialization or change detection green together". EXP3 removed grok-llm-proxy from images.json and all 10 of its build-publish.yml lines → that guard EXIT=0. The real population oracle is scripts/minikube/build-images.sh, a third file. The independence you added is real but narrower than the comment states PR2-OWNED (52f6a5185)
M6 sandboxUi.ts:876 — the res.on('close', cleanup) mutant survives 3 runs PR2-OWNED
M7 DELETE …/oauth/grant middleware order is untested; 8f46d9818 claims 2 routes and covers 1 PR2-OWNED

Low

  • db.ts:3213-3216 comments assert migrations apply "strictly in array order". False since the sync: 18 of 21 post-0106 migrations execute at a different index. PR2-OWNED.
  • Prefix 0109 is shared by 0109_grok_subscription_connections and 0109_user_access_foundation. No behavioural consequence — every consumer keys on the full string; no slice(0,4), no parseInt, no sort anywhere. db.migrationsOrder.test.ts:28-32 does require strictly increasing order and passes on full-string comparison. What is missing is any assertion of prefix uniqueness. BASE.
  • migrationRunner.ts:108-118 — before-schema preparation is guarded by !hasPostSchemaIndexes, so a migration declaring both phases would skip its before-schema indexes. Latent; no such migration exists today. PR2-OWNED.
  • migrationExecutionPolicy.test.ts:58-60 was weakened to …startsWith('0115')).toHaveLength(1) by 981062749; the title says "exactly 25" while the registry has 26. PR2-OWNED.
  • seed.mjs:19-21 — two imports run before try {. If either throws, the catch never runs and Node prints the raw error. EXP6c (stubbing /app/control-api/dist/db.js to throw a DSN-bearing error) leaked the DSN. Whether that module can actually throw such an error at import time is unverified, so this is latent; the pool import predates 6f10dfa69.
  • identity-lifecycle.test.mjs:43-52 is a source-text positional assertion (indexOf on three literals plus ordering). It fails loud if the binding moves — not vacuous — but proves nothing about runtime behaviour and would pass if the catch emitted the full message. Consider adding a behavioural case that drives the catch path. PR2-OWNED.
  • sandboxUi.ts:~828 per-request proxyRes filter untested; two restored v2 branches (:405, :322-326) untested; malformed comment at :796-800. PR2-OWNED.
  • D3: actionDestination.ts:50-52 returns null for chat, so actionAuthorityV2.ts:150-155 → 400 invalid_binding. No consumer of chat.read exists yet, so it is latent for PR3.
  • D4: gfsReadiness…:157 still matches on error text, and was widened this round (ea2375678) with two migration-name fragments rather than moving to SQLSTATE 42501.
  • D2 residual: both branches end in 503 authority_unavailable with retryable:true. No authorization consequence.
  • CQ-F6: .github/workflows/codeql.yml:21 limits pull_request to [main, dev], so CodeQL does not run on this PR's base branch.
  • build_publish_matrix_rows() at test-dockerfile-local-packages.sh:275 computes an unused published variable — dead code that reads as if the manifest were being consulted.
  • The PR body is stale. It cites head 8f46d9818 and "Manual exact-head CodeQL workflow 35399907982 succeeded for this exact SHA". No PR2-owned commit landed after 8f46d9818, but 87 files / +7556/−1728 entered via inherited merges, so the CodeQL and "collateral CLEAN" evidence describes a tree that no longer exists.

Verified clean

  • @clerum/action-context-contracts is linked by file: from all four consumers — no version skew.
  • gfs-controller/src/api/serve.ts:272-337 checkpointRouteAuthority is fail-closed with an exhaustive else-throw.
  • gfs-controller/src/upload/uploadSession.ts:321-330 assertUploadAuthorityOrigin prevents a v2-opened session from being downgraded to v1 mid-flight.
  • No reference anywhere in the tree to a migration id absent from the registry.
  • Release coordinates: rpc-proxy 0.1.99 = lockfile (both fields) = releaseManifest.rpcProxyVersion; external-rest-api 0.1.109 consistent; desktop-app 0.8.1 = desktopVersion; releaseId v0.8.1, minimumDesktopVersion 0.1.252.
  • 6f666f047 Grok dependency is coherent across package.json, both lockfile fields, the source_paths of all four consuming images, and the Dockerfile COPY-before-npm ci rule.
  • The ci-public.yml real-Postgres block opens with set -euo pipefail, so its | tee is not masked. No continue-on-error in the file; the only two || true are benign. The block requires both Test Files N passed and Tests N passed and rejects any skipped.
  • The suite-list guard has a genuine self-mutation test at :61-75 — mutates a copy, re-runs itself, requires failure. That is the right shape.
  • 30 published images in deploy/images.json = 30 build-publish.yml matrix rows.

Suites

Suite EXIT Result
control-api unit 0 497 files, 6007 passed, 0 failed, 351 skipped
control-api real Postgres 16 0 63 files, 351 passed
rpc-proxy 0 (5 runs) 617/617; 1 run in 6 EXIT=1 (pre-existing flake)
mcp-host 0 71 files, 691 passed
workflow-recipes 0 147 files, 2889 passed
test-dockerfile-local-packages.sh 0 PASS
test-images-manifest.sh 0 25 PASS / 0 FAIL
test-release-coordinates.sh 0 34 PASS / 0 FAIL
test-ci-public-real-postgres-suite-list.sh 0 PASS
node --test identity-lifecycle.test.mjs 0 19/19
PR #594 CI — 40/40 pass (CodeQL absent on this base — see CQ-F6)
PR #334 CI — 41/41 pass (includes CodeQL run 35616468291)

Suggested order

  1. Blocker — jsonBody ordering, plus jsonBody on /wake, plus one createApp() integration test on a v2 body-bound route.
  2. H1 — add the legacyVersions: ['0115_llm_allowed_models_image_input'] alias.
  3. M2 — make the alias convergence test non-vacuous; it is the guard that would have caught H1 and M1 for free.
  4. Then this SHA is a sound technical base for PR3. H2/H3/H4 and the rest are real but belong in their own issues — they are CI-guard coverage, not runtime correctness, and two of the three predate this PR.

Read-only review: no commits, pushes, branches, Docker changes or image pulls. git status --porcelain was empty at the end of every agent; the one accidental package-lock.json rewrite (from an experimental make install-all) was detected and restored from HEAD.

🤖 Generated with Claude Code

Comment thread rpc-proxy/src/routes/sandboxUi.ts Dismissed
Comment thread rpc-proxy/src/routes/sandboxUi.ts Dismissed
Comment thread rpc-proxy/src/routes/sandboxUi.ts Dismissed
Comment thread rpc-proxy/src/routes/sandboxUi.ts Dismissed
Comment thread control-api/src/routes/internal/oauth.ts Dismissed
The only conflict was the workflow-recipes version line in package.json
and package-lock.json, resolved to the branch side (0.1.499); the
version hook then bumped it to 0.1.500. The removal of the dead lint
script from dev is kept.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alfredolopez80 and others added 11 commits November 14, 2026 11:57
#1135 bounds status reports in workflow-runtime-core and workflow-recipes.
Conflicts:
- packages/workflow-runtime-core/src/status-reporter/client.ts: dev now
  reads every error answer once as text (responseErrorReason, cause code,
  bodyBytes). The branch's 423 pause and 503 run-store checks read the
  body again with resp.json(), which would fail on a consumed body and
  silently stop detecting both codes. They now parse the same text
  (responseErrorCode). The final error keeps dev's cause detail and the
  branch's WorkflowRunStateUnavailableError.
- tests/unit/status-reporter.test.ts: dev's file plus the branch's
  run-store describe, whose mocks now answer text(). Two tests pin the 423
  pause detection through the reporter, which had no unit test; reverting
  the client to the double body read fails the first one.
- workflow-recipes version: resolved to the branch's 0.1.500 (dev 0.1.498);
  the version hook then set 0.1.501.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… with an authority binding

A V1 `running` report no longer reads the run store. Before this change
every workflow-level and step-level `running` report of a DB-run child read
`workflow_runs` (joined with `workflow_authority_bindings`) to learn whether
the run was bound, so a V1 run answered 503 while the store was unreachable
and threw on any other database error. On dev these routes do not read
Postgres.

- crdConstants: add RUN_AUTHORITY_LABEL (`clerum.io/workflow-run-authority`)
  and RUN_AUTHORITY_BOUND (`bound`).
- dbRunChildRecipeCreator: stamp the label on the child when the run row
  carries an authority binding. The binding is fixed when the row is
  inserted, so the label is decided once. On a 409, an existing child whose
  authority label differs from the expected one is rejected like any other
  identity mismatch.
- restEndpoints: checkpointWorkflowRunBoundary runs the durable boundary only
  for a child whose label equals `bound`; any other value or no label is a V1
  child decided by recipe status, as on dev. `durablyAllowedRunning` (the
  pause-projection clear) uses the same predicate.
- A labelled child whose run has no row still gets 409; an unlabelled child
  with no row is accepted, as on dev.
- dbRunProcessor.ts: comment-only edit of the V1 branch comment above
  `if (!observed.authority_binding) return { outcome: 'allowed' }`. That line
  is still reachable through restEndpoints only for a labelled child whose run
  has no binding, which workflow-recipes never creates. Its test is kept.
- The status GET still reads the run for V1 children and can overlay and
  re-publish a pause for an unlabelled child with a bound row; no such child
  exists at this head (gate for enabling V2).
- docs/deploy/releases-and-upgrades.md: the gfs-controller sentence no longer
  names a release; add the workflow-recipes rollout order against migrations
  0139 and 0143.

Tests: new wiring test from the real child creator to the real handlers and
run processor over a real pool on a closed port (V1 child: 200 and no client
requested; bound child: 503, or a throw on 42P01); creator label tests; the
fake-processor fixture carries the label; the run-store wire test is split
into a bound run (three 503s, one wait) and a V1 run (no client requested).
control-api's real-Postgres trigger test gives its hand-built bound child the
label; it runs only in the T1 real-Postgres lane and was not executed here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the direct-service limit

fix(mcp-host): do not charge timer-driven reads or the progress stream against the direct-service limit

channel-reader polls the cron results, the workflow-approval notification
claim and every in-flight task result about every 2 s. All of them were
charged against the single 600-per-minute direct-service bucket, so with
about 18 in-flight tasks the polls alone filled it and new messages,
approvals and decisions got 429 until the window ended. The progress stream
of a task the Host had already accepted was charged a second time, so a
Telegram reply could be lost when the bucket filled in between.

Stop calling admitDirectServiceRequest on six routes:
- GET /v1/runtime/cron/results and DELETE /v1/runtime/cron/results/:taskId
- POST /v1/runtime/workflow-approval-notifications/claim and
  POST .../deliveries/:id/ack|fail
- GET /v1/runtime/tasks/:taskId/result
- GET /v1/runtime/tasks/:taskId/progress/stream

The bucket keeps the eight routes that start work: messages, tool approval
(approve and deny), provider message authorization, workflow approval
decide, resolve and result request, approval-medium enrollment and Telegram
verification. The function and class comments now list what is charged; the
function comment no longer cites the Spec 62/65/48 boundaries and states
that RPC Proxy ingress is not charged here.

Tests: a real Host with a recording handler on all fourteen routes and the
real channel-reader clients: 900 polls leave the action budget untouched;
at a full bucket the six exempt routes (including the real progress stream
client) are served and the bucket stays full; a table over the routes
checks 429, Retry-After and Cache-Control on the charged ones, with the
same request served after the window rolls over.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the Host no longer limits

The MCP Host no longer charges the cron-result poll and its acknowledgement,
the notification claim and its ack/fail, the task-result read or the
progress stream against its direct-service limit, so it never answers 429
on them. The handling channel-reader had for that answer lost its only
producer (admitDirectServiceRequest).

rpcClient.ts:
- remove RUNTIME_ADMISSION_RETRY_AFTER_SECONDS_DEFAULT,
  RuntimeServiceAdmissionLimitedError, runtimeAdmissionRetryAfter and the
  notificationAdmissionRetryUntil / cronResultsAdmissionRetryUntil fields;
- fetchDeliveries, getCronResults and acknowledgeCronResult lose their
  Retry-After pre-checks; fetchDeliveries, getTaskResult, getCronResults and
  acknowledgeCronResult lose their 429 branches, so a 429 there takes the
  generic error path;
- getCronResults' catch returns [] for every error again, as on dev.
main.ts: drop the RuntimeServiceAdmissionLimitedError import and restore
dev's result-polling fallback loop (plain 2 s sleep; no Retry-After delay or
deadline clamp).

Kept: RUNTIME_ADMISSION_RETRY_AFTER_SECONDS_MAX,
runtimeAdmissionRetryAfterFromHeader, directServiceAdmissionRetryAfter and
the 429 handling of the message POST and provider-message authorization,
which stay charged. The other charged calls (approve, deny, decide, resolve,
result request, Telegram challenge confirm, both link-session confirms) had
no 429 handling before this commit either. postNotificationDeliveryTerminal
had no 429 branch, so there was none to remove for ack/fail.

The comment above the Telegram progress-flow catch in main.ts now says why
that catch is reached only by the status message send.

Tests: the four rpcClient tests that pinned the removed 429 handling and
the class import are removed.

orphans checked: RUNTIME_ADMISSION_RETRY_AFTER_SECONDS_DEFAULT,
RuntimeServiceAdmissionLimitedError, runtimeAdmissionRetryAfter,
notificationAdmissionRetryUntil, cronResultsAdmissionRetryUntil, the
pre-checks and 429 branches of fetchDeliveries, getTaskResult,
getCronResults and acknowledgeCronResult, the getCronResults rethrow,
retryDelayMs and the deadline clamp in main.ts,
RUNTIME_ADMISSION_RETRY_AFTER_SECONDS_MAX, runtimeAdmissionRetryAfterFromHeader,
directServiceAdmissionRetryAfter; removed: all but the last three, which
the message POST and provider-message authorization still use. No other
package references the removed symbols.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t, retry Slack/Teams once

fix(workflow-approval-request-reader): honour the Host admission limit and retry Slack and Teams decisions once

The reader treated the MCP Host's direct-service admission refusal like any
other failure: it read neither Retry-After nor retryAfterSeconds, told Slack
and Teams users "Try again in a moment" after the webhook had already been
acknowledged (so the provider never presents the click again), and
forwarded 429 without Retry-After on the synchronous paths.

- mcpHostClient: a Host answer is the admission refusal only when the
  status is 429, the body error is runtime_service_admission_limited and
  Retry-After is an integer from 1 to 60 equal to the body's
  retryAfterSeconds (same check as channel-reader). Decision and enrollment
  then return { ok: false, status: 429, error, retryAfterSeconds } and log
  it at warn. Any other 429 keeps the generic result.
- Synchronous decision (Telegram and generic callers) and enrollment: the
  response carries Retry-After; the body and status are unchanged; no retry.
- New decisionAdmissionRetry module: one process-wide queue, at most one
  armed retry per approval request id (trimmed, lower-cased), at most 64,
  one setTimeout of retryAfterSeconds each; the entry is removed before its
  runner runs; close() refuses later schedules.
- Slack and Teams background decisions: a canonical 429 on the first
  attempt arms one retry and tells the user it will be sent again in N
  seconds. The retry re-submits the same command once (same
  providerEventId; route resolution and the can-approve check run again);
  its result goes through the existing feedback, and a second refusal says
  "The assistant is busy. Try again in N seconds." without arming another.
  When the queue is full or closed, or another click armed it, the user
  gets that same text. A click on an approval whose retry is armed is not
  submitted: the same user's same decision is only logged; another
  decision gets a Slack thread message (Teams: log only).
- main.ts: shutdown first closes the retry queue and logs
  cancelledDecisionRetries. Pending retries are in memory only and are lost
  when the reader restarts.

Tests: queue unit tests with fake timers; server scenarios for retry
success, a second refusal, a stale approval, held clicks, two concurrent
refusals, a full queue, Teams, no feedback context, the synchronous path
and a malformed 429; a shutdown test; client tests for the canonical and
malformed answers; and in mcp-host's admission test, the real Host with a
full bucket gives the real reader client the wait, and the same command
reaches the handler after the window rolls over.

The reader is not covered by validate-release-version-bumps.mjs and the
version hook does not list it, so its version is unchanged; mcp-host is
bumped for its test file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…omments

docs: correct the comments of T-1 and T-9 (control-api, comment-only)

Comment-only; no behaviour change.

- oauth/tokenHelper.ts: the check at the top of getAccessToken does not
  stop every cached token. Its callers are the refresh path of
  getAccessTokenReactive (a stale token with a refresh token) and the
  proactive refresh sweep; the reactive fast path returns a token that is
  not stale without reading the recipe (reactiveTokenHelper.ts), so this
  check does not cover that token.
- services/hostRpcAdmission.ts: admitHostRpc is called only by the V2
  action checkpoint route (routes/internal/actionAuthorityCheckpoint.ts);
  legacy Host routes are not charged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…le a retry is armed

The Host charges its admission limit on the decide route before it
authorizes the decider, and the reader's control-api can-approve consult
runs only for commands with a channel alias. A user without approval rights
could therefore receive the admission 429 and arm the per-approval retry;
every other user's click (or the other decision) was then held for up to
60 s with "Another decision for this workflow approval is waiting to be
sent", and the armed retry was later denied.

While a retry is armed, only a repeat of the armed click (same provider
user, same decision) is held, and only logged. A click from another user,
or the other decision, is submitted at once through the normal background
path and gets its own feedback. The armed retry stays; if the approval is
decided meanwhile, the retry shows the existing "no longer pending" text.
The "Another decision ... is waiting" text and its code path are removed.

Tests: the other-user scenario now expects an immediate submit with its
own result, and the armed retry running once afterwards; a new scenario
covers the same user's other decision.

orphans checked: ANOTHER_DECISION_PENDING_TEXT, holdSlackDecisionClick,
armedDecisionRetry, the send_held feedback action; removed: all of them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…onous enrollment 429

The synchronous enrollment path forwards the Host's admission wait as
Retry-After (b4566e3), but no test covered it: removing the header line
left every test green. The new test drives a Telegram /start enrollment
whose submit is refused with the canonical 429 and asserts 429, the
Retry-After header and the body, and that nothing is retried after 60 s.
Removing the header line now fails it (expected null to be '9').

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e status read

The status read comment said every re-sent `running` report is re-gated by
the durable boundary. Since 6fa26f1 the boundary runs only for children
carrying the run-authority label; workflow-recipes labels every child of a
run with an authority binding, so an unlabelled child (none exists while
V2 cannot be enabled) is not re-gated. Comment only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hile its retry is armed

A decision refused by the Host limit is retried once in the background.
Until now (87b242c) only a repeated click by the same user with the same
decision was held while that retry was armed; the same user's other
decision was submitted at once. If that click was refused as well, the
armed retry later sent the earlier decision after the user had chosen
differently; if it was accepted, the second click won, unlike the
unsaturated path where the first click decides.

While a user's retry is armed, every later click of that user is now held
(log only; on Slack the other decision gets a thread message saying the
earlier decision is still being sent). The approval stays pending for its
requester until that retry ends; if it is refused again nothing stays
armed and the user can choose again. Clicks of other users are still
submitted at once: control-api accepts a decision only from the
approval's requester, so they do not change it.

Tests: the same user's other decision is held and the first decision is
sent after the wait; another user's click is submitted, refused with
approval_requester_mismatch, and the armed retry still sends the
requester's decision. Mutations: holding only the same decision fails the
first; holding nobody fails the duplicate and first tests; holding every
user fails the second.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1339 lets HCC's network-policy pass settle. The only conflicts were the
version lines of host-context-controller (dev's 0.1.445 is higher than
the branch's 0.1.427; set to 0.1.446 because this branch changes HCC code)
and mcp-host (the branch's 0.1.662; the version hook then set 0.1.663).
host-context-controller/src/hostReconciler.ts and both config.ts files
merged without a conflict; HCC build and suite (129 files, 3115 tests)
and mcp-host typecheck and suite (618 files, 9149 tests) pass on the
merge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@alfredolopez80

Copy link
Copy Markdown
Contributor

Thanks for the deep pass on 14100a24f. Here is where each item stands at aa8e6a877 (T2 still pending).

Must resolve

  • R1-B1 (gfsc/WFC images): fixed. Both images build from the repository root and copy packages/action-context-contracts into both stages (3f28a505f), and the import is relative again (36071cc1a), so CI does not need either service's dependencies. Both images were built locally and the module loads inside each.

V1 behaviour changes

  • R1-M1: fixed in 6fa26f189, as you suggested. The durable running boundary runs only for runs with an authority binding, marked on the child recipe with the label clerum.io/workflow-run-authority: bound; V1 running reports no longer read Postgres.
  • R1-M2: fixed in 7d45f0012. The direct-service limit no longer charges the cron-result poll and its ack, the notification claim and its ack/fail, the task-result read, or the progress stream of an accepted task; the eight action routes stay charged. This also closes a lost Telegram reply when the progress stream got a 429.
  • R1-M3: fixed in b4566e383, 87b242c5f and 054a382a1. The reader recognises the Host's 429, answers synchronous decisions and enrollments with Retry-After, and sends a Slack/Teams decision again once after the wait (in memory, at most 64 pending). While that retry is armed, later clicks of the same user are held, so the user's first decision is the one sent. The "continue to the next Host" branch is not reachable: a command without a host ref gets no targets (mcpHostTargets.ts:140-155).
  • R1-M4: fixed in ab3d45491 (defaults to mcp-server, like control-api).
  • R1-M5: declared in the description; control-api: sandbox OAuth disconnect leaves the grant when the recipe or client declaration is gone #1476.
  • R1-M6: the description and the comment in tokenHelper.ts are corrected (8da3e468c): the owner/provider check applies on refresh and in the proactive sweep, and a fresh cached token is still returned without reading the recipe, as on dev. The code is unchanged in this PR.

V2 path (V2 cannot be enabled at this head): R1-M7 to R1-M14 are confirmed and kept as gates for enabling V2 (PR3). For R1-M10 the description now says the 409 pauses the run durably.

Low: R1-L4 → #1478, which now covers seven routes (the sandbox-ui authorize-url route too). R1-L5 and R1-L20 are declared. R1-L6 is confirmed (V2 gate). The R1-L12 comment in hostRpcAdmission.ts is corrected (8da3e468c). The R1-L23 db.ts comments are restored (5a2a1333c). R1-L19 stays as is: a non-integer TTL is an operator misconfiguration, and failing loud is intended. The other Low items are confirmed and tracked with the V2 gates.

@zach88

zach88 commented Nov 14, 2026

Copy link
Copy Markdown
Contributor

Took another deep pass, this time on aa8e6a877, with most of the attention on what changed since 14100a24f. Good progress here. The image build is fixed: I built both gfs-controller and workspace-files-controller from the repo root and the shared package loads inside each image. The four V1 behaviour changes from last round are fixed too, and with the V2 flags off I still don't see any fail-open path. What's left is concentrated in the new approval retry in workflow-approval-request-reader, plus V2-only Mediums that have now been open two rounds and need a call.

🔴 Not ready to approve

  • Must resolve before approval: 1 (a regression from one of this round's fixes)
  • Optional / non-blocking: 35 (7 Medium carried from round 1, all needing a decision; 9 new Low; 19 Low still open from round 1)

This is my read from the review, not a formal GH approval.

One structural note before the items. workflow-approval-request-reader/src/server.ts (and its test file) has taken three fix: commits this round (b4566e383, 87b242c5f, 054a382a1). The "which click wins" rule in decisionAdmissionRetry.ts changed three times in that span: hold everything, then hold only the identical click, then hold any later click from the same user. I'd stop patching it and write a short decision table first. For each event (same user same decision, same user other decision, other user, decided elsewhere, retry gets 429 again, queue full, shutdown), what gets sent and what the shared message shows. Items 1, 2, 3 and 7 below all live there.

Must resolve before approval

  1. [R2-L1] Late background retry overwrites an already decided approval with "no longer pending" (regression from 87b242c5f, on top of the retry added in b4566e383)
    workflow-approval-request-reader/src/server.ts:574-605 (Slack handleSlackDecisionResult, update_stale branch). Teams has the same path in handleTeamsDecisionResult.
    User B clicks, gets the Host 429, and a retry is armed (up to 60 s). The requester A clicks next, is sent at once, and the message becomes "Approved. Workflow approval recorded." Then B's retry fires. control-api checks status !== 'pending' before authorization (control-api/src/services/workflowApprovalProviderDecisionService.ts:163), so it answers approval_not_pending, and the reader rewrites the same messageTs to "This workflow approval is no longer pending. Trigger the workflow again to request approval." The decision is recorded, but the message now tells people to relaunch a workflow that was already approved, which can lead to a duplicate run. The same happens if the approval is decided from Desktop or control-ui, or by a second click that was already in flight.
    Repro: a throwaway test in test/server.test.ts, using the existing background retry scaffolding. slackUpdateTexts() ends as ["Approved. Workflow approval recorded.", "This workflow approval is no longer pending…"]. At 14100a24f a 429 only posted a thread message and never touched the approval message.
    Suggested fix: when attempt === 'retry', only update the shared message on ok. For anything else, log it or post a thread note to that user.

Optional (non-blocking)

These can merge as follow-ups or at your discretion. The exception is the seven Mediums in the first group: they've been open two rounds now, so each one either gets a written V2-gate decision or a fix. They shouldn't float into round 3.

Carried Mediums that need a decision (all still open since round 1)

  1. [R1-M7] Host-RPC admission charged before authorizing, for any caller (V2 only)
    control-api/src/routes/internal/actionAuthorityCheckpoint.ts:110-116, unchanged. Suggestion: either a written V2 gate naming the ID in the PR3 list, or the one-line fix (charge only when caller.service === 'rpc-proxy').
  2. [R1-M8] PR2 readiness evidence writable by any mcp-host JWT (V2 only)
    control-api/src/middleware/pr2ReadinessWriterAuth.ts:63-70, unchanged. The body says the evidence writer is PR3, but it doesn't mention that today's writer path accepts any Host. A written V2 gate naming the ID would close it.
  3. [R1-M9] Orphaned AuthorizationPaused runs are never reclaimed (V2 only)
    workflow-recipes/src/reconciler/dbRunProcessor.ts:354 (FIND_ORPHAN_RUNS only takes Pending/Running). Written V2 gate, or add the paused phase to the orphan reclaim.
  4. [R1-M11] V2 wake: 404 unknown / 409 not-stateless become 502 (V2 only)
    rpc-proxy/src/services/controlApiRestService.ts:373-397, unchanged.
    Repro (again this round): a throwaway test for both statuses throws Control API v2 host wake returned an invalid authority response. Since it reproduces, it needs either the fix (check status before validating the body as an authority answer) or a signed note on why it's acceptable until V2.
  5. [R1-M12] The operational reader lists entire resource families per authorization (V2 only)
    control-api/src/services/access/operationalAccessReader.ts:680-714. Reproduced last round with 1000 hosts. At minimum, budget exhaustion shouldn't come back as retryable: true.
  6. [R1-M13] run_id::text = $1 defeats the uuid PK index
    control-api/src/services/access/liveAuthorizationResolver.ts:361, databaseAuthorityCandidates.ts:116,131, workflows/workflowRunResumeService.ts:73,91. Reproduced with EXPLAIN last round. This one is cheap to fix: validate the uuid shape, compare run_id = $1::uuid, and return not_found without querying otherwise.
  7. [R1-M14] Hand-written x-clerum-edge-action-context header in control-api tests
    control-api/test/routes.internal.oauth.test.ts:65 (v2ContextHeader). Fine to downgrade or to derive from the rpc-proxy producer like the other fixtures in this PR.

New this round

  1. [R2-L2] Teams retry notice removes the buttons, so "choose again" can't happen
    workflow-approval-request-reader/src/server.ts:675-690 and decisionRetryScheduledText. In Teams, update-message replaces the card with attachments: [] (control-api/src/routes/internal/workflowApprovalReader.ts:478-489). If the retry gets another 429, the card stays on "Try again in N seconds" with no buttons, and the still-pending approval can't be decided from Teams anymore. Also, an opposite click from the same user while their retry is armed is only logged in Teams; Slack posts a thread note. A new message instead of an update would fix both.

  2. [R2-L3] Two sentences in the body about the approval reader aren't accurate
    "accepted only from the approval's requester": for a team approval with no requesterUserId, control-api accepts any active team member (workflowApprovalProviderDecisionService.ts:182-194), and that's exactly the path behind item 1. "that user's first decision is the one sent": not true when the second click goes out before the first one gets its 429. The same wording is in the holdClickWhileOwnRetryPending docstring (server.ts:458-466).

  3. [R2-L4] channel-reader still treats the Host 429 as a generic error on 6 of the 8 charged routes
    channel-reader/src/rpcClient.ts:363-490,656-822. These are approvals/approve|deny, workflow-approvals/decide, resolve, workflow-results/latest, link-sessions/confirm and telegram/challenges/confirm-provider-event. What the user sees:

    • a Telegram /approve replies Failed to send approval: runtime_service_admission_limited;
    • resolve throws;
    • workflow-results returns retryable: false;
    • Slack/Teams verification says "Check that the code is active" while the nonce is still valid.

    Nothing is lost, and 7d45f0012 makes this much less likely, but the body only lists the 429 effect for messages. Either map it to "busy, try again in N s" or add it to "Behaviour with V2 off".

  4. [R2-L5] The Host 429 in the reader's client tests is hand-built
    workflow-approval-request-reader/test/mcpHostClient.test.ts:570-620 (hostAnswer(429, {error, retryAfterSeconds}, '7')). If admitDirectServiceRequest (mcp-host/src/server/routes.ts:100) renames the field or changes the code, the reader stops recognizing it and this suite stays green. channel-reader already derives it from the real RPCServer (channel-reader/test/r40M1ExplicitAdmissionRecovery.integration.test.ts:116), and the same pattern would work here.

  5. [R2-L6] The V2 running boundary is now decided by a mutable CR label, not by the run row (V2 only)
    workflow-recipes/src/workflow/restEndpoints.ts:316-321,708. Since 6fa26f189, whether a report goes through the durable boundary depends on clerum.io/workflow-run-authority: bound on the child WorkflowRecipe. "Run has authority" is also computed three ways: the label, COALESCE(continuation, initiating) in dbRunChildRecipeCreator.ts:144, and initiating only in dbRunProcessor.ts:306. Today they can't diverge, and only the control-api and WRC service accounts can edit the CR (deploy/base/sandbox-recipes/rbac.yaml:9,91; the admin PUT keeps existing labels). Still, before V2 goes on I'd make the label create-only and immutable with a ValidatingAdmissionPolicy, or decide from authority_binding. Edge case to keep in mind: with V2 on across an upgrade, isExpectedExistingChild rejects an older child that has no label.

  6. [R2-L7] The 64 pending retries are one process-wide pool shared by every tenant
    workflow-approval-request-reader/src/decisionAdmissionRetry.ts:10,79. One user who saturates their own Host and clicks 64 approvals leaves everyone else's 429s without an automatic retry ("busy, try again"). It doesn't suppress anyone's decision. A per-mcpHostRef cap on top of the global one would close it.

  7. [R2-L8] GET /status still reads Postgres for every V1 child
    workflow-recipes/src/workflow/restEndpoints.ts:1265-1268 calls dbRunProcessor.getRunStatus() for any child with a run id. That's the sibling of the running fix in 6fa26f189. It fails safe (it falls back to the recipe phase), but with the DB down each GET waits up to 5 s and logs an error, and the coordinator's startup readCurrentStatus goes through it. Gating it on recipeCarriesRunAuthority(recipe) would match the boundary, and would make the body's "every V1 run decided by recipe status" true.

  8. [R2-L9] The cross-service revocation test hand-writes the child label
    control-api/test/workflowTriggerSelectedPath.realPostgres.integration.test.ts:518-527,594. createChildRecipe is a stub that returns a child already labelled bound. Removing that one label line turns the revocation test red, so the guarantee rests on a hand-written fixture. Using createDbRunChildRecipe(fakeCustomApi, run) would make the label come from the real producer.

  9. [R2-L10] Nit: the Dockerfile.dockerignore allowlists aren't per-file
    gfs-controller/Dockerfile.dockerignore, workspace-files-controller/Dockerfile.dockerignore. !packages/ and !<svc>/ bring back everything under them (the image context includes index.test.cjs and package-lock.json). No runtime effect, but the comment promises more than the file does.

Low still open since round 1

No new evidence on these; the code is unchanged unless noted. Details are in last round's comment.

ID Where Note
R1-L1 workflowAuthorityBindingService.ts:131 unreachable today
R1-L3 userAccessPolicy.ts:236, main.ts:161 the body still doesn't warn that turning on ACTION_CONTEXT_V2 at this head crash-loops control-api
R1-L6 actionAuthorityHostWake.ts:45-61 V2
R1-L7, R1-L8 accessDatabaseQuery.ts
R1-L9 restEndpoints.ts:958 now only for labelled children
R1-L10 dbRunProcessor.test.ts cd6a00267 adds more hand-built rows for a state production no longer reaches
R1-L11 actionAuthorityHostWake.ts:148
R1-L12 control-ui/lib/workflowRecipeRunState.ts control-api comment fixed in 8da3e468c; the UI still has no AuthorizationPaused
R1-L13 workspace-files-controller/src/auth/actionAuthority.test.ts
R1-L14 desktop-app/src/appService.ts:296
R1-L15 gfs-controller/src/api/serve.ts:977 the cross_boundary → path_invalid precedence isn't declared (#1480 covers only the percent case)
R1-L16 rpc-proxy suite
R1-L17, R1-L18, R1-L19, R1-L21 control-api
R1-L22 edge token design PR3
R1-L23 various db.ts notes and the tokenHelper comment are restored; the other nits remain

Closed since round 1: R1-B1, R1-M1, R1-M2, R1-M3, R1-M4 are fixed. R1-M5, R1-M6, R1-M10, R1-L2, R1-L4, R1-L5 and R1-L20 are now declared in the body, so I'm treating them as accepted.

What checked out clean

  • Image build (R1-B1). docker build -f <svc>/Dockerfile . from the root works for both controllers. Inside each image, require("../../../packages/action-context-contracts") resolves under /app/packages and loads. The build-publish matrix, path filters, deploy/images.json and build-images.sh are aligned. There's a single build-args key and no duplicate YAML keys in any workflow.

  • The 6fa26f189 label gate. Only createDbRunChildRecipe sets the label, and it sets it from run.authority_binding, which never changes after the insert. Child recipes don't inherit labels, and the admin PUT keeps them. Removing the gate turns 5 tests red, and removing the label stamping turns 6 red. The new wire tests fail against 14100a24f.

  • The 9622b5c7d merge resolution. The body is read once as text, and 423, 503 and the terminal 409 are still detected. status-reporter.test.ts passes 23/23.

  • mcp-host 7d45f0012. The charged/exempt split (8/6) matches the body exactly. No authn or ownership check moved, and an exempt route can't be used to get around the limit. The cross-service test runs the real Host with the real clients.

  • The approval reader, apart from the items above.

    • The Slack/Teams ack stays synchronous.
    • Concurrent 429s arm a single retry, and the retry runs once with no loop.
    • Shutdown cancels the timers.
    • No user can suppress or force another user's decision: holds are keyed by providerUserId, the retry resends the already verified command, and canApprove is re-checked.
    • Retry-After is validated 1–60 and cross-checked with the body.
    • The tests in 87b242c5f and 054a382a1 fail against their parents.
  • rpc-proxy ab3d45491. The namespace default is read only on the V2 path, matches control-api's own default, and the destination check fails closed.

  • control-api. Since 14100a24f only comments changed in src, and they're accurate now. The db.ts notes match dev byte for byte.

  • deploy/charts since round 1. Only image pins changed: no NetworkPolicy or RBAC changes. The new logs don't carry tokens or bodies.

  • The CRD change. charts/clerum-crds/crds/workflowrecipe.yaml adds authorization_paused to the phase enum, and that's structural. Everything else in the file is quote reformatting. "Before merge" step 5 already covers applying it ahead of the merge.

  • Suites and typecheck. Every remaining red is environmental: a missing local dist or package, in files this PR doesn't touch.

    Package Typecheck Tests
    control-api clean full suite green once local builds were in place; Real PostgreSQL workflowTriggerSelectedPath 14/14
    workflow-recipes clean 3444 pass, 3 environmental (snippetRuntime)
    workflow-runtime-core clean 234/234
    workflow-approval-request-reader clean 122/122
    channel-reader not run 423/423
    rpc-proxy not run 1381/1381
    mcp-host not run 9135 pass, 14 environmental (all in subscriptionAdmission.integration)

alfredolopez80 and others added 9 commits November 14, 2026 17:04
… program

pre-gate-sync.sh reads the HCC Host edge-protocol selector with a jq
program in single quotes. 574bbb8 ended two of its lines with a
backslash; inside single quotes the shell passes it to jq unchanged, and
jq rejects it ("unexpected INVALID_CHARACTER", exit 3, on jq 1.7.1 and
1.8.1). Every pre-gate-sync run that reached the rollout block stopped
there, so T2 failed in minikube-pre-gate-sync after a full bootstrap.
jq accepts newlines, so the two backslashes are removed.

Checked against the host-context-controller Deployment of a freshly
bootstrapped profile: the old program fails to compile on both jq
builds; the new one prints "legacy-headers" and still fails with
"protocol selector must be explicit" for zero or two selector entries.
A sweep of every shell script and makefile found no other jq program
with a line continuation inside its quotes. shellcheck findings are
unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1453 makes the workflow runtime wait for the snippet runner before it
reports the run as running. In sdkRuntime.ts that wait sat where this
branch replaced the plain 'running' report with reportRunningBoundary,
so both are kept in that order: the runner readiness wait first (only
when a pending step uses the runner), then the running boundary. The
deps interface and the error classes keep both sides.

The branch's wire test for the running boundary built a snippet-runner
fake without healthCheck, which the merged runtime now calls; the fake
gets the same healthCheck that #1453 gave its own fakes.

workflow-recipes is set to 0.1.506, above dev's 0.1.505, because this
branch changes its code. Its build and suite (159 files, 3494 tests)
pass on the merge, and the release version validator accepts it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The workflow approval reader can only replace a Teams approval card
(update-message PUTs the activity with `attachments: []`), so every
per-user notice removed the card's buttons. Add
POST /internal/workflow-approval-reader/teams-targets/:targetId/send-message
so the reader can post a new plain-text message instead.

- Same caller identity, target resolution and refusals as update-message:
  requireInternalToken + requireInternalService('workflow-approval-reader'),
  resolveTeamsTarget, the serviceUrl allowlist checked before the Secret is
  read, the same app password and token exchange.
- The Secret read and token exchange move unchanged into
  teamsBotAccessTokenForTarget, used by both routes; update-message keeps its
  status codes and bodies (its existing tests pass unmodified, and new tests
  pin its refusal rows).
- Threading is decided here from the CommunicationChannel: the group with
  channelId === conversationId and the target's tenant; only
  `replyInThreads: false` posts at the top level of the conversation, as
  channel-reader does. Threaded and a card id given: POST
  .../conversations/{threadConversationId || conversationId}/activities/{replyToId}
  with { type, text, replyToId }; otherwise POST
  .../conversations/{conversationId}/activities with { type, text }. Every
  path segment is percent-encoded. The activity is built from text and
  replyToId only; caller attachments, entities or threading flags are ignored.
- Upstream non-2xx -> 502 teams_send_failed with providerError; the text is
  never logged.

No RBAC, NetworkPolicy, CRD or deployment change.

Version: control-api 0.1.963 -> 0.1.964 (pre-commit hook).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nly when recorded or stale

Change the approval message only when a decision is recorded or it is no
longer pending.

The Slack and Teams result handlers ran the first-attempt feedback of dev for
the background retry too. A retry arrives up to 60 s after the click, after
other actors may have decided, and its stale answer rewrote the shared
approval message (Slack and Teams) with a relaunch text. In Teams every
notice was delivered by replacing the card, which removes its buttons while
the approval is still pending.

- New pure module decisionFeedback.ts: planDecisionFeedback maps (medium,
  decision, attempt, result, arming) to { shared, notice }. A recorded
  decision rewrites the shared message; a first attempt that finds the
  approval no longer pending marks it stale; a retry changes it only when it
  succeeds. Stale is keyed by the error code (approval_not_pending,
  approval_route_not_found) whatever the status: 409 from the Host, 403 from
  the can-approve consult. Every other result is a notice. The feedback
  texts move there; server.ts keeps none.
- The first-attempt stale text no longer asks to trigger the workflow again.
  A retry that finds the approval decided sends "Your earlier decision was
  not recorded: this workflow approval is no longer pending." The held-click
  text says the result appears on the approval message, or in a message if
  it is not recorded.
- Teams notices are new plain-text messages through control-api's
  teams-targets/:targetId/send-message route (sendTeamsTargetMessage); the
  card is replaced only on a recorded decision or a first-attempt stale
  answer, and a held click of the other decision now gets the held notice.
  The reader passes the card's ids (conversationId, the payload's
  conversation.id, replyToId); control-api places the message from the
  group's reply-in-threads setting.
- Slack notices (including the failure notice inherited from dev) follow the
  target's slackSettings.replyInThreads as verified by control-api: in the
  approval message's thread when true, at the top level of the channel
  otherwise.
- What is sent to the Host and control-api is unchanged: arming, holding and
  the retry runner keep their logic.
- Docstrings of holdClickWhileOwnRetryPending and armDecisionAdmissionRetry
  state the hold only once a retry is armed, and that control-api decides who
  may decide (target user; for a team approval the recorded requester, or
  any active team member with the team's workflow trigger permission when
  none was recorded). README updated.

Tests: decisionFeedback.test.ts (table per row and medium, 403 and 409 stale
forms, invariant over every retry input with an ok-sweep witness), Teams and
Slack server cases for each changed row, notice placement for
replyInThreads true / false / unset and a threaded approval message, the
one-user race (first click armed), and the client call.

Version: the reader is not a pre-commit bump root (stays 0.1.0); no
package.json version changes in this commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…al from the real Host

The reader's canonical 429 case built the Host's answer by hand, so a change
to the Host's body or header would leave it green, and the enrollment route
had no test against the producer anywhere.

mcpHostClient.hostAdmission.integration.test.ts boots the real MCP Host
(`../../mcp-host/src/server`, which also makes CI install mcp-host's
dependencies for this package), fills its direct service admission bucket
(600 admits, the 601st refused as a witness) and asserts that
submitMcpHostDecision and submitMcpHostEnrollment each read
{ ok: false, status: 429, error: 'runtime_service_admission_limited',
retryAfterSeconds: 1..60 } with no fetch stub, and that neither handler ran;
once the window rolls over the same calls reach each handler once.

The hand-built canonical case of mcpHostClient.test.ts is removed; the
malformed 429 cases stay hand-built, since no producer emits them.

Mutations: removing the 429 branch of either client call, or renaming the
Host's `retryAfterSeconds` body field, fails the new test while
mcpHostClient.test.ts stays green.

Version: test-only change in the reader, which is not a pre-commit bump
root; no package.json version changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…a child with run authority

getWorkflowStatus read workflow_runs for any recipe with a run id and used
the answer only when the row had authority. The running boundary already
skips the store for a child without the run-authority label
(checkpointWorkflowRunBoundary), but the GET did not: for a V1 child the
read can only answer hasAuthority false or fail, and while the store is
unreachable every status GET (including the runtime's startup
readCurrentStatus) waited on it and logged at error.

The GET now reads the store only when the recipe carries the run-authority
label (recipeCarriesRunAuthority), as at the running boundary. Without it,
`durable` stays null, so the phase overlay and the pause re-publish are
skipped by their existing conditions; labelled children are unchanged.

The claims half of the run id (`labelRunId ?? claims.runId`) is left as
it is: no token that the status GET accepts carries a run id. The GET
needs scope status_read or status_write; the workflow-recipes tokens with
audience clerum-wrc take no run id, and control-api's delegation token,
which does carry one, is scoped to artifact_read, admin:artifact_read or
admin:artifact_delete only (control-api/src/utils/auth/delegationToken.ts:12).

Tests: a child without the run-authority label and a store that refuses
connections answers 200 from the recipe status with no read and no error
log; T-D1f (no labels, run id in the claims) now expects no read; the V1
wire case asserts the runtime's status reads answer 200 without a pool
query. Mutations: removing the condition fails all three; giving the
`authority: false` fixture the label fails the new test.

Version: workflow-recipes 0.1.506 -> 0.1.507 (pre-commit hook).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…om authority answers

control-api's V2 wake route uses 404 and 409 both for an authority refusal
({ version: 2, status: 'not_found' | 'access_path_stale', code }) and for
the wake result after the authority check passed ({ status: 'unknown' } on
404, { status: 'not-stateless' } on 409). rpc-proxy read every V2 404/409
as an authority answer and threw "invalid authority response" for the two
wake results, so the wake route answered 502 and wake-and-hold treated them
as an unreachable endpoint (or kept holding on a retrigger).

After the single body read of the V2 block, the pair (HTTP status, body
status) decides: 404 with `unknown` returns { kind: 'unknown' }, 409 with
`not-stateless` returns { kind: 'not-stateless' }, as the V1 path already
does. A crossed pair (unknown on 409, not-stateless on 404) or a non-JSON
body still throws, and authority answers are unchanged. The HTTP status
alone cannot decide: an authority access_path_stale is also a 409.

The two wake bodies live in control-api/test/fixtures/hostWakeV2Answers.ts,
pinned against the real route in actionAuthorityHostWake.test.ts and
imported by rpc-proxy's new host-wake-v2-answers.test.ts (rows c1-c9:
wake answers, authority 404/409/403, crossed pairs, non-JSON, V1 path).
Mutations: dropping the two lines fails c1/c2; dropping the HTTP half
fails c5/c6; deciding on the HTTP status alone fails c3/c4.

Callers with V2 on: the wake route answers 404/409 instead of 502;
wake-and-hold settles not-found (404) or legacy not-stateless, including
for the waiters of a mid-hold retrigger, as the V1 path does.

Version: rpc-proxy 0.1.142 -> 0.1.143 and control-api 0.1.964 -> 0.1.965
(pre-commit hook; the control-api change is test-only), with rpcProxyVersion
re-synced in external-rest-api/src/releaseManifest.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
workflow_runs.run_id is the uuid primary key, but seven predicates compared
`run_id::text = $n`, so Postgres could not use workflow_runs_pkey: the
resolver's resume pre-read, LOCK_RUN_FOR_RESUME and RESUME_PAUSED_RUN, and
the four run and artifact branches of the authority candidates statement.
The cast existed because the value is an unvalidated string, and a text
comparison cannot fail on a malformed id.

canonicalWorkflowRunId (services/access/workflowRunId.ts) returns the id
only when it is exactly what run_id::text renders (lowercase, hyphenated),
so every verdict stays the same:
- resolver pre-read: a non-canonical id returns "no run" without a query,
  otherwise `run_id = $1::uuid`;
- resumeWorkflowRun: a non-canonical id answers 404 workflow_run_not_found
  before the transaction (inside the try, so the budget is still closed);
  both statements compare `run_id = $1::uuid`;
- candidates statement: two parameters computed in JS, $6 for a
  workflow_run and $7 for a workflow_artifact's first segment (what
  split_part($3, '/', 1) yields), null otherwise, which matches no row.
  The artifact name condition `split_part($3, '/', 2) <> ''` stays in SQL.
  $3::uuid is not used: whether Postgres evaluates the cast in a branch
  excluded by $2 is not relied on.

Tests: the helper's accepted and refused shapes; resume rejects
`not-a-uuid` and an uppercase id with 404 and no statement, and its
statements carry `$1::uuid`; the resolver skips the pre-read for a
non-canonical id; the artifact SQL assertion follows `$7::uuid`. On real
PostgreSQL, the candidates statement gives the same grants as before for
canonical, missing, uppercase, braced, unhyphenated, padded and malformed
run ids, artifact ids (<id>/<name>, <id>/a/b, <id>/, <id>//x, malformed
first segment) and the approval, GFS and notification branches, and a
resume with a malformed id answers 404 instead of a database error.
EXPLAIN on PostgreSQL 16 shows every changed statement using
workflow_runs_pkey after the change and none before.

Version: control-api 0.1.965 -> 0.1.966 (pre-commit hook).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…althCheck

The Real PostgreSQL revocation test in
workflowTriggerSelectedPath.realPostgres.integration.test.ts drives
workflow-recipes' runWorkflowRuntime with a snippet-runner fake that had
only execute. Since dev's #1453, merged into this branch in 19f88d7,
the runtime calls snippetRunner.healthCheck() before a pending snippet
step, so the test failed with "client.healthCheck is not a function".
The merge gave the workflow-recipes wire fixture the same method; this
fixture was missed. The real SnippetRunnerClient has healthCheck
(workflow-recipes/src/workflow/snippetRunnerClient.ts:57).

With this line the file passes 14/14 on a local PostgreSQL 16
(evidence impl-r2/final/probe-healthcheck.log).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Comment on lines +590 to +596
id?: string
error?: string
}
if (!response.ok) {
res.status(502).json({
error: 'teams_send_failed',
providerError: body.error || `http_${response.status}`,
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.

4 participants