Repository navigation
Commit b81da66
Fixes #19987
Clause-②: no
## What this changes
`POST /automation/:name/runs/:runId/resume` now checks WHO is resuming.
Before, the arm validated the body and called
`automationService.resume(runId, signal)` without reading any identity.
So an authenticated caller holding another user's run id (`u2` on a run
whose `getRun().trigger.userId` is `u1`) got `200` and `resume` ran. The
resumed run then continued under the context STORED on the run
(`engine.ts` `const context = run.context`): its data nodes ran as `u1`,
with values `u2` submitted. The screen read on the same pause already
refused `u2`.
The write door now asks the screen read's question (maintainer ruling
Option B on #7968, applied to the write by triage's direction
`5817076635`). Both doors call one predicate,
`isRunStarterOrRunStateReader`
(`packages/runtime/src/domains/automation.ts`):
- the caller is the run's own starter (`getRun(runId).trigger.userId`),
OR
- the caller holds read access to `sys_automation_run` (the operator
override, asked through `mayReadRunState`, the #7900 question), OR
- the caller is a system context.
Everyone else gets `403 PERMISSION_DENIED`, the read twin's code and
status, and `resume()` is never called, so the pause stays parked.
`refuseUnrelatedScreenRead` and the new `refuseUnrelatedResume` are now
two thin guard clauses over that one predicate. Each owns only its
refusal sentence and WHERE it asks. The read twin's behaviour and
message are byte-identical to before.
## `resumeAuthority` today, as the ruling asks (read first, per triage's
binding limit)
`resumeAuthority` is the closed enum `'any' | 'service'`
(`packages/spec/src/automation/node-executor.zod.ts`). It names no
person. Measured on `origin/main` `6bf7f1c0`:
| Node type | Declared `resumeAuthority` | Where | On this REST door |
| :--- | :--- | :--- | :--- |
| `screen` | `'any'` | `service-automation/src/builtin/screen-nodes.ts`
| the route is the intended door; "any" names no specific caller ⇒ the
new gate applies |
| `wait` | `'any'` | `service-automation/src/builtin/wait-node.ts` |
same |
| `map` | `'any'` | `service-automation/src/builtin/map-node.ts` | same
(the engine still walks to a service-gated item) |
| `subflow` | `'any'` | `service-automation/src/builtin/subflow-node.ts`
| same (the engine still judges the child the signal lands on) |
| `approval` | `'service'` | `plugin-approvals/src/approval-node.ts` |
refused by the ENGINE for every caller: the service marker is a symbol
no JSON body can carry |
| `approval_revise` | `'service'` |
`plugin-approvals/src/approval-revise-node.ts` | same |
| any undeclared type | treated as `'service'` (#5561) | `engine.ts`
`resolveResumeAuthority` | same |
An approver decides through `ApprovalService`, which resumes in process
(`approval-service.ts`: `this.automation!.resume!(runId, { ...signal,
[RESUME_AUTHORITY_SERVICE]: true })`) and never enters
`handleAutomationRequest`. So no caller this door admitted before is a
caller the node names, and triage's limit 「不得拒绝节点自身 `resumeAuthority`
已经明确授权的人」 holds by measurement: the set of node-named callers on this
door is empty. The node gate still answers, unchanged and word for word,
behind the new gate (pinned: an admitted starter on an `approval` pause
still gets the engine's own 403 message).
## The two doors' rules, side by side
| | REST `POST /automation/:name/runs/:runId/resume` (this PR) | MCP
`resume_run` (`mcp.ts` `resumeActionRun`, unchanged) |
| :--- | :--- | :--- |
| Run's own starter | admitted | admitted |
| `sys_automation_run` read grant, not the starter | admitted (operator
override, as on the screen read) | refused (starter-only by design, no
operator override) |
| System context | admitted | n/a |
| Stranger | `403 PERMISSION_DENIED` | `404` (one not-found for unknown,
finished and foreign runs) |
| Node gate (`resumeAuthority`) | still applies behind this gate | still
applies |
| Engine answer classification | `classifyResumeResult` |
`classifyResumeResult` (same table) |
## Order of operations, decided by measurement
The gate runs AFTER the body checks and immediately BEFORE `resume()`.
- **After the body checks.** They read nothing about the run, so every
`400 VALIDATION_FAILED` is byte-identical for every caller. The gate's
lookup is spent only on a request the engine would otherwise receive.
- **Before `resume()`.** A refused resume consumes nothing.
- **Fail-closed on a run `getRun` cannot resolve. There is no 404-first
step, and this is where the write departs from the read twin.** The read
twin answers 404 first because `getSuspendedScreen` is an authoritative
"is there anything to disclose" check. The write has no such probe short
of `resume()`, and `resume()` consumes the pause. `getRun`'s `null` is
not authoritative. The engine returns `null` for a run that is parked
and resumable when the durable read degrades (`engine.ts` `getRun`: "a
run that is parked and resumable reports as if it had never run"). It
also returns `null` after the 1000-entry in-memory ring evicts the entry
on a deployment with no suspended-run store
(`DEFAULT_MAX_EXECUTION_LOG_SIZE`, the `splice` in `recordLog`), and a
caller who can start runs can cause that eviction. Letting `null`
through would open the gate in exactly those states.
- **What moves, and for whom.** Only for a caller who is neither the
run's starter, nor a grant holder, nor a system context: an unknown or
finished run now answers them this `403` instead of the engine's `404`.
As a side effect, a stranger can no longer tell a paused run id from an
unknown one here. For every caller the gate admits, every engine answer
is unchanged, including the `404` for unknown and finished runs (both
pinned). One cost falls on honest callers: a caller WITHOUT the grant
who mistypes a run id now gets `403` rather than `404`.
## Tests
All readings are on HEAD `b4cb8a4d`, unless a line says otherwise.
- **Failing probe first** (the card's named probe, committed as a test).
`automation-resume-caller-gate.test.ts` was run against the unfixed arm
(base `6bf7f1c0`) and gave `Tests 17 failed | 33 passed (50)`. The
stranger `u2` on `u1`'s run got `200`, with `resume` called.
- **New suite**
`packages/runtime/src/domains/automation-resume-caller-gate.test.ts`: 50
cases. Every refusal row asserts the envelope `code` AND `status`, and
asserts that `resume` was NOT called:
- stranger refused, with the exact message, and nothing about the
caller's authorization topology in the refusal (#7450);
- a run with no `trigger.userId` refused;
- a run `getRun` cannot resolve refused;
- a `getRun` throw refused, even for the starter;
- a service with no `getRun`: refused unless the grant admits;
- permission subsystem down: stranger refused, starter admitted;
- starter admitted with NO grant: the over-block guard; the signal is
forwarded field by field and `explain` is never called;
- grant holder admitted: `explain` is asked for `read` on
`sys_automation_run` with the caller's own context;
- system context admitted without asking anything;
- the node gate's own 403 still arrives word for word for an admitted
starter;
- unchanged answers: starter on a finished run gets the engine's 404;
grant holder on an unknown id gets the engine's 404; a malformed body is
refused `VALIDATION_FAILED` before the gate for every caller; anonymous
gets `401` first; no security service, or a partial one, admits;
- a parity table (3 callers × 5 security postures × 2 runs = 30 cases)
that drives BOTH doors with the same inputs and requires the same
verdict, and on refusal the same `code`, the same `status` and the same
requirement sentence.
- **Rewritten pin.** `automation-screen-read-gate.test.ts` had a case
pinning the defect itself ("leaves `resume`'s own authority checks
alone": stranger not 403, `resume` called). It is replaced by two cases:
the same stranger is refused at `resume` (same code and status, `resume`
not called), and the triggering user is admitted with no grant. Comments
in three sibling suites that said the resume door had no route-level
gate are corrected. Their assertions are unchanged and still green.
- **Mutation leg** (`node scripts/ablation-replace.mjs`, on committed
`a99d03a4`, the gate's code identical at HEAD). It replaced `if
(unrelated) return unrelated;` with an inert statement: anchor 1 → 0,
blob `467c4019f123` → `e8f9a4915391`. With the gate removed, the two
gate suites went `Tests 15 failed | 57 passed (72)`: every stranger and
unresolved row turned red, and every starter, grant and system row
stayed green. The direction is red, as expected. Restore: blob == HEAD
(`467c4019f123`) and `git diff HEAD` empty. The suite imports the arm by
relative source path, so no `dist/` is involved.
- `pnpm --filter @objectstack/runtime exec vitest run --project local
--maxWorkers=2` (on `a99d03a4`; the later commit changes only a ledger
note string and a docs table row): `Test Files 276 passed (276)`, `Tests
3893 passed | 1 skipped`.
- On HEAD `b4cb8a4d`, the nine suites that read the route ledger or
drive the resume arm (`route-ledger.conformance`, `http-dispatcher`,
`discovery-schema-conformance`, `auth-forward-fault-sanitization` and
the five automation gate suites) were re-run: `Test Files 9 passed (9)`,
`Tests 436 passed (436)`.
- `--project repo`: `Test Files 2 passed`, `Tests 69 passed`.
- `pnpm --filter @objectstack/runtime typecheck`: exit 0. Output:
`check:test-typecheck: OK — @objectstack/runtime's test layer compiles`,
and the new suite is not in the debt ledger.
- `pnpm --filter @objectstack/dogfood exec vitest run
test/flow-durable-suspend.dogfood.test.ts` against the rebuilt runtime
`dist/` (it carries `isRunStarterOrRunStateReader`): `Tests 11 passed
(11)`. That includes the real-engine resume after a real cold boot.
- **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack` on HEAD derives 92 commands. All 92 were run
with exit codes captured before any pipe, and all exited 0. `--ran`
reconciliation: `92 derived, 92 run, 0 NOT-MEASURED, 0 UNRUN` ("a
DERIVED zero — all 92 recorded an exit code and none of them is 3"). Two
runs needed a prerequisite first. `check:dual-build-cjs-loads` and
`check:type-check-debt` refused with exit 3 (no `dist/`), and
`check:skill-examples` refused the same way (no client-react `.d.ts`). A
full `turbo run build` then ran (`73 successful, 73 total`), and all
three were re-run green before the final union.
- **Roster gates** whose rosters sit under a touched directory:
`check:authz-resolver`, `check:error-code-casing`,
`check:filter-alias-parity`, `check:route-ledger-census`, and also
`check:error-status-conformance`. All exit 0.
- `pnpm lint` (the full union, `eslint . --no-inline-config`): exit 0 in
112s.
Two gates were red on the first pass, and both reds were caused by this
diff:
- `check:system-context-census`: the `isSystem` read moved from
`refuseUnrelatedScreenRead` into the shared predicate. The row in
`content/docs/permissions/system-context.mdx` is re-anchored and now
names the resume door.
- `check:doc-authoring`: tracker ids in the route-ledger note string.
They are removed from the string.
## Docs
`content/docs/automation/flows.mdx`:
- "Who may resume" now has the caller question first and the node
question second. It states the three admitted callers, the refusal, the
ordering and the MCP door's stricter rule.
- The pause table's `wait` (signal) row no longer says "any caller".
## Deviations, stated rather than chosen silently
1. **Refusal sentence.** The code and status are the read twin's. The
requirement clause is the read twin's, word for word: both messages are
built from one constant, `PAUSED_RUN_REQUIREMENT`. The verb phrase is
this door's own: "Resuming a paused run requires being the identity that
triggered the run, or read access to 'sys_automation_run'." A resume
refused with "Reading a paused run's screen …" would name an operation
the caller did not attempt. That is the misdirection #11666 took off the
toggle door. If the literal read sentence is wanted instead, it is a
one-line change: `RESUME_DENY_MESSAGE = SCREEN_READ_DENY_MESSAGE`.
2. **Unknown and finished runs** keep today's answers for every caller
the gate admits, not for every caller. The reasons are in "Order of
operations" above.
3. **Two files beyond the claim's listed file surface.**
`packages/runtime/src/route-ledger.ts`: the resume row's `note` now
describes the caller gate.
`content/docs/permissions/system-context.mdx`:
`check:system-context-census` refuses the tree until that row
re-anchors.
## Acceptance notes (observations, not filed)
- **Consequence of Option B, for the maintainer's eye.** A caller that
resumes runs it did NOT start now needs `sys_automation_run` read.
Examples: an integration feeding a `wait` node's signal, or support
tooling. The changeset says so. No in-repo caller does this: the REST
resume callers on `main` are the route mount, the client SDK method and
the tests.
- `WaitExecutorConfigSchema.webhookUrlPattern` defaults to
`/api/v1/automation/resume/{executionId}/{nodeId}`, a path no route
serves, and it has zero runtime readers. It is dormant, not filed.
Carrier: none.
- The client SDK's `automation.resume` docblock lists the resume
refusals but not this 403. The list is incomplete, not wrong. Not filed.
Carrier: none.
## NOT MEASURED locally (CI owns them)
- Test Core shards, Temporal Conformance, the Dogfood Regression Gate
beyond the one suite above, and Dogfood Verify CLI.
- The workspace, examples and downstream type-check lanes: runtime's own
`typecheck` ran. The public surface is unchanged (no export added or
removed), so no importer owes a test.
- The artifact-roster, wide-population and path-scheduled CI steps that
`dispatch-gates` lists outside its derived total.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01TnPAC1UsTGfHPXVUCL6iLn)_
---------
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 61609ed commit b81da66
10 files changed
Lines changed: 737 additions & 77 deletions
File tree
- .changeset
- content/docs
- automation
- permissions
- packages/runtime/src
- domains
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
764 | 764 | | |
765 | 765 | | |
766 | 766 | | |
767 | | - | |
| 767 | + | |
768 | 768 | | |
769 | 769 | | |
770 | 770 | | |
| |||
807 | 807 | | |
808 | 808 | | |
809 | 809 | | |
810 | | - | |
811 | | - | |
812 | | - | |
813 | | - | |
814 | | - | |
| 810 | + | |
| 811 | + | |
| 812 | + | |
| 813 | + | |
| 814 | + | |
| 815 | + | |
| 816 | + | |
| 817 | + | |
| 818 | + | |
| 819 | + | |
| 820 | + | |
| 821 | + | |
| 822 | + | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
| 826 | + | |
| 827 | + | |
| 828 | + | |
| 829 | + | |
| 830 | + | |
| 831 | + | |
| 832 | + | |
| 833 | + | |
| 834 | + | |
| 835 | + | |
| 836 | + | |
| 837 | + | |
| 838 | + | |
| 839 | + | |
| 840 | + | |
815 | 841 | | |
816 | 842 | | |
817 | 843 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
176 | 176 | | |
177 | 177 | | |
178 | 178 | | |
179 | | - | |
| 179 | + | |
180 | 180 | | |
181 | 181 | | |
182 | 182 | | |
| |||
0 commit comments