Skip to content

Commit dee7692

Browse files
feat(pm/fleet-write): a workflow_dispatch op behind a closed allowlist, actions write minted for that stroke alone (#22460)
Fixes #22369 Maintainer ruling, verbatim: 「22369 允许 actions: write」 (ruling record 6078120063; the triage seat's relay of the same answer and the build direction: 6077935878). This PR builds that option A: ONE relay op that starts a run of an allowlisted workflow at `main`, `actions: write` minted for exactly the stroke that carries it, a self-test pinning that no other `actions/*` path is reachable from the op table, and one line in the skill table saying when a seat may start a run. ## What changed - `scripts/pm/fleet-write/ops.mjs` — ONE new row `workflow_dispatch` at the END of `OPS` (after `transfer`): `workflow` and `ref` required, nothing optional, permission `actions`, `alone: true`. `WORKFLOW_DISPATCH_ALLOWLIST = Object.freeze(['shard-timings-refresh.yml'])`, `WORKFLOW_DISPATCH_REF = 'main'`, both wired as closed enums in `FIELDS`. `PERMISSIONS` gains `actions: 'write'`; `STROKE_SCOPED_PERMISSIONS = ['actions']` names it as the grant minted only when spent. `REFUSED_PATH_FAMILIES` replaces the blanket `/actions/workflows` family with a catch-all that excepts exactly the dispatch call, plus the run / job / cache / artifact / enable-disable / secret-variable-runner families named one by one. - `scripts/pm/fleet-write/validate.mjs` — the row is judged like every other op (closed keys, enums), plus the stroke rule: a stroke carrying a `workflow_dispatch` carries exactly that one action. `tokenPermissionsOf(payload)` computes the stroke's grant set; `--github-output` now also writes `permission_actions=write` (a run stroke) or `permission_actions=` (every other stroke). The self-test gains one battery (18 cases) and the mint pin now holds both spellings. - `scripts/pm/fleet-write/execute.mjs` — no new branch: the row is an ordinary REST POST. A bodiless 204 now reads `accepted (HTTP 204, no body)` in the row instead of `ok`. One new battery (7 cases): the POST and its body, the bearer, 204 landing, 422/403 failing, no other `actions/*` request leaving, no annotation. - `scripts/pm/fleet-write/dispatch.mjs` — the started run is read back on the SEAT side after a success run: `RUN_OPS` (derived from the table: every op naming a `workflow`), `readBackRuns` lists `GET /repos/{repo}/actions/workflows/{file}/runs?event=workflow_dispatch&branch=main&per_page=5` and takes the newest run created since the dispatch, re-reading on the poll interval for `RUN_READ_BACK_WINDOW_MS` (60 s) before `unfound`; found is `landed` and travels in `result.runs` and `--json` `runs`; `unread` or `unfound` is `unverified`, exit 6 (UNCONFIRMED, the existing vocabulary — a second dispatch is a second run). One new battery (11 cases). - `.github/workflows/fleet-write.yml` — the mint step gains `permission-actions: ${{ steps.validate.outputs.permission_actions }}` and nothing else; header and step comments say why. - `.claude/skills/pm-dispatch/references/rest-channel.md` — ONE line added in the write-side table (when a seat may start a run; a workflow joins the allowlist only by its own PR), paid by deleting ONE line that restated the quota readings whose home is `platform-readings.md`. 82 lines before, 82 after (ceiling 82/82 untouched). ## Least exposure — the mint is conditioned per stroke (assumption 1, measured feasible) `actions/create-github-app-token@v3` collects its `permission-*` inputs in `lib/get-permissions-from-inputs.js`, whose line 11 reads `if (!value) return permissions;` — an EMPTY input is skipped. Measured on a read-only clone of `actions/create-github-app-token`: tag `v3` resolves to commit `bcd2ba49218906704ab6c1aa796996da409d3eb1` (package version 3.2.0), the file is byte-identical at `v3` and at the default branch. So the validator's `$GITHUB_OUTPUT` line `permission_actions=write` (a `workflow_dispatch` stroke) or `permission_actions=` (every other stroke) is enough: one mint step, one expression input, every other run keeps exactly today's four grants. No second mint step, no `if:` fork. Four-axis reading of the two shapes considered (A: one step with an expression input, chosen; B: two mint steps selected by `if:`): - Business need: both serve the same measured pull (a seat whose unblock is a run); A costs one line, B duplicates the whole mint block. - Long-run soundness: A keeps the permission table in ONE place (`ops.mjs`), read by the validator and pinned against the workflow file by the self-test; B would put the grant set in two YAML blocks the pin has to reconcile. - Preventing AI error: A's failure mode (the action's empty-input handling changing) is pinned at the one line that depends on it and would surface as a loud 403 on the dispatch, never as a wider token on a plain stroke; B's failure mode (the two steps drifting) is silent widening. - Startup-stage focus: A adds no new mechanism; B adds a second mint path to keep green. ## The four fences on `actions: write` 1. `workflow` is a closed enum of bare file names (one entry today), matched whole — a path, a dot-dot path, a glob and an off-list name are each refused by name (pinned). 2. `ref` is a closed enum of exactly `main`; it is required on every request, never defaulted. 3. A stroke carrying `workflow_dispatch` carries exactly that one action — the wider token serves one request and nothing beside it (the same shape `transfer` already has for its second repository). 4. The grant is minted only for such a stroke (above), and the op table reaches no other `actions/*` path: the catch-all family plus the named families, with a positive control of 19 cancel / re-run / delete / log / approve / cache / artifact / enable / disable / secret / variable / runner / sub-path spellings each caught. `inputs` is deliberately NOT a key (assumption 6, falsified on its optional half): the one allowlisted workflow declares a bare `workflow_dispatch:` (`.github/workflows/shard-timings-refresh.yml`, no `inputs:`), and GitHub answers 422 to inputs on a workflow that declares none — so an `inputs` key today has zero consumers and one failure mode. A workflow with inputs joins the allowlist by the PR that also spells its inputs' closed shape here. An `inputs` key on the action is refused by name (pinned). ## Assumptions 2–5, as measured - 2 (table position): appended after `transfer`, the table's end. Shared files with the in-flight sibling: #22052 is not addressed here; its branch is at `origin/main` with no PR open at the time of writing, so there is nothing yet to merge behind — serial landing stays as dispatched (this PR merges `origin/main` after that PR lands and before its ready flip). - 3 (read-back): `ANNOTATED_OPS` is NOT used for the run id. Its contract is a number and url read from the platform's ANSWER to the request, and `POST …/dispatches` answers 204 with no body — there is no answer to annotate from; its parser also admits issue urls only. The seat lists the workflow's runs itself (a read the seat container can make: `GET …/actions/runs` is on the readable list), bounded, UNCONFIRMED when not listed. Exit semantics reuse `exitForResult` unchanged (`unverified` → 6). - 4 (permission field): `actions` joins `PERMISSIONS`; `execute.mjs` has no per-permission branch to extend (it imports `PERMISSIONS` only for its header's sake, pre-existing), and the mint step reads the table through the validator's output. The permission vocabulary stays the validator's pin: `every op names a permission the token is narrowed to` still holds with the fifth entry. - 5 (line budget): rest-channel.md 82 → 82. Added: `- ✓ 启动 run 走 op workflow_dispatch:解锁物是白名单 run 才启;入表只经 workflow 自己的 PR。` (114 bytes, under the 120-byte line rule). Deleted: the former line 10, `两只桶:MCP 记链接用户 5000/时,兄弟会话共享同桶;REST/CCR 记 App 安装 15000/时。` — a restatement of readings that live in `platform-readings.md`'s quota section (5000/h at its line 99 and 137, 15000/h at its lines 118 and 131; per-account bucketing at its line 93), which that file's own line 139 says is the one home. No re-wrap; no ceiling moved; no issue number in the operative text (`check:pm-skill-id-lint` green). ## Verification Self-tests (each `node scripts/pm/fleet-write/{file}.mjs --self-test`, exit captured before any pipe): - validate: `✓ 114 cases pass across 10 batteries` (was 93 across 9) - execute: `✓ 90 cases pass across 13 batteries` (was 83 across 12) - dispatch: `✓ 211 cases pass across 19 batteries` (was 200 across 18) Reverse verification — four ablations through `scripts/ablation-replace.mjs` on the committed tree (each: anchor hit exactly once, blob hash changed on disk, the command run under the mutation, restore proven by blob == HEAD and an empty `git diff HEAD`): | leg | mutation | observed | |---|---|---| | A | delete `alone: true` on the row | validate self-test red, 4 of 114 — the two stroke refusals, the row pin, the alone/widen roster | | B | delete the `actions/*` catch-all family | validate red, 1 of 114 — the positive control names `workflows/shard-timings-refresh.yml` and the `…/dispatches/x` sub-path as uncaught | | C | spell the mint input as a literal `permission-actions: write` | validate red, 2 of 114 — the literal map now carries `actions`, and no input reads the validator's output | | D | make the run read-back never match a candidate | dispatch red, 5 of 211 — found/once/candidate, the lagging list, the CLI `--json` run | Gate union derived with `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` (no paths; the change set from the merge base) on head `094be7b5`: 62 commands, each run byte for byte with its exit recorded, then reconciled with `--ran`: `Run reconciliation — 62 derived, 62 run, 0 NOT-MEASURED, 0 UNRUN. — ✓ dispatch-gates --ran: 62 derived famil(ies) accounted for — 62 run, 0 NOT-MEASURED (a DERIVED zero — all 62 recorded an exit code and none of them is 3).`. `check:doc-formula-expressions` first answered exit 3 (PREREQUISITE: `@objectstack/formula` and `@objectstack/lint` not built), was rebuilt under the verify lock and re-run green. The battery `check:pm-dispatch-gates` (nohup, waited on with tail --pid): exit 0, `✓ dispatch-gates self-test: 2011 cases pass.`, `the battery took 1072.7s on this box` (three agents' batteries ran concurrently). eslint over the four scripts: exit 0. NOT MEASURED: a live relay run of the new op. The relay executes from `main`, so the first live `workflow_dispatch` stroke is the first post-merge measurement (the seat that needs the run sends it; the read-back names the run or answers UNCONFIRMED). ## Acceptance notes - `execute.mjs` imports `PERMISSIONS` and never reads it (pre-existing, lint-clean under the repo config); noted, not touched. - The started-run read-back takes the NEWEST run created since the dispatch and SAYS when more than one was created in that window; it does not choose between them silently. A human click on the same workflow within the same minute is the one case that could name the wrong run, and the row carries the run's actor so a seat can see it. - `scripts/pm/dispatch-gates.mjs` is frozen and untouched. ## 维护者速读(草稿) ### 改了什么 舰队中继多了一个 op `workflow_dispatch`:席位可以让 `objectstack-fleet[bot]` 启动白名单上的一个 workflow(目前只有 `shard-timings-refresh.yml`)在 `main` 上的一次运行。白名单是代码里的闭集常量,只能由改白名单的 PR 扩;`ref` 只认 `main`;一次请求只能带这一个动作。中继令牌只在带这个动作的那次运行里多铸 `actions: write`,其它运行的令牌和今天完全一样。席位侧发完会自己回读「启动了哪个 run」,60 秒内没列出来就报 UNCONFIRMED、不重发。 ### 为什么改 你裁了 A(「22369 允许 actions: write」)。此前只有 workflow run 才能产出的解锁物(如分片耗时数据集刷新)要等维护者手点或每周一的定时,席位无法自己启动;`actions: write` 又是一把能取消、重跑、删 run 的宽权限,所以这单的重点是把它关在一个 op、一个文件名、一个 ref、一次请求里,并用自测钉死 op 表碰不到任何别的 `actions/*` 路径。 ### 风险与代价(含回滚) 风险:令牌多了 `actions: write`——但只在带 `workflow_dispatch` 的那一次中继运行里铸,且自测钉住它只能到达 dispatch 端点;若 `create-github-app-token` 改掉「空输入即跳过」的行为,后果是普通 stroke 也带上该权限,自测钉在 workflow 文件和 op 表上,但那一行行为改变本身只会在 action 升级时显现——值得在 dependabot 升该 action 时看一眼。代价:无发布面变动(`skip-changeset`),不改任何包。回滚:revert 本 PR 即可,白名单、权限、技能行一并回到今天;已启动的 run 不受影响。 ### 席位意见 (留空,待席位定稿) ### 你要做的 这是 Tier S(触及 `.claude/**`):席内达档契约复核 PASS 后由席位入队,不需要你的点击。可选:dependabot 下次升 `actions/create-github-app-token` 时,确认 `lib/get-permissions-from-inputs.js` 仍对空输入直接返回(本 PR 在 v3 = 3.2.0 实测)。 --- _Generated by [Claude Code](https://claude.ai/code/session_01JmWtcHfGbC4ncw4GFKWuRA)_ --------- Co-authored-by: objectstack-fleet[bot] <noreply@anthropic.com> Co-authored-by: objectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>
1 parent f66c440 commit dee7692

6 files changed

Lines changed: 434 additions & 56 deletions

File tree

‎.claude/skills/pm-dispatch/references/rest-channel.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
- 出口代理按设计只放 repo-scoped 路径(`/repos/{o}/{r}/...`)加 `/rate_limit`;org 级端点未实测。
88
- ✓ 按席位类别限定:每个 ✓ = 会话门开着的席位实调通过,⛔ 无 ✓ 不当已验证事实。
99
- 403 后 `/rate_limit` 判凭据形态:15000/时 = 凭据活被 repo-scoping 拒;60/时或 auth 错 = 无凭据。
10-
- 两只桶:MCP 记链接用户 5000/时,兄弟会话共享同桶;REST/CCR 记 App 安装 15000/时。
1110
- `objectstack-fleet[bot]` 经 `fleet-write` 中继:五个写工具与 `with-fleet.sh --via auto` 共一选择器。
1211
- 三条件全立才 `dispatch`:`CCR_AGENT_PROXY_ENABLED=1`、会话 id、中继活着;否则 `direct`,印一行。
1312
- 会话 id 由容器 `CLAUDE_CODE_REMOTE_SESSION_ID` 派生;`OS_FLEET_SESSION` 只覆盖本地检出与测试。
@@ -49,6 +48,7 @@
4948
- ✓ `origin/main` 合进 PR head:中继 op `pr_update_branch`(`pull` + 40 位 `expected_head_sha`),真合并提交。
5049
- ⛔ 裸 `PUT .../pulls/{n}/update-branch` 落个人账号;短 SHA 派发前即拒;base 未动 422 = op 报 no-op。
5150
- ✓ 落地经中继 `node scripts/pm/fleet-write/dispatch.mjs --repo O/N --actions-file F`;`--via dispatch` 同载。
51+
- ✓ 启动 run 走 op `workflow_dispatch`:解锁物是白名单 run 才启;入表只经 workflow 自己的 PR。
5252
- ⛔ 裸 `PATCH /pulls/{n}` 带 `{"draft": false}` 回 200 零改;状态码不作数,`GET /pulls/{n}` 才作数。
5353
- 线程自己建:`POST .../pulls/{n}/comments` 带 `commit_id`·`path`·`line`,回读看 `review_threads`。
5454
- ✓ `POST .../ccr/comments/{id}/resolve` · `/unresolve`;`{id}` 是评审评论 id,⛔ 只在自己 PR 上探。

‎.github/workflows/fleet-write.yml‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,10 @@
2020
# alone, that transfer's one target too: `transferIssue` needs issues write on
2121
# both ends) and to the permissions the op table declares (`issues: write`,
2222
# `pull-requests: write`, the `contents: write` GitHub's auto-merge mutations
23-
# require, and the `metadata: read` the sender gate's permission read needs)
23+
# require, and the `metadata: read` the sender gate's permission read needs;
24+
# plus, for a `workflow_dispatch` stroke ALONE, the `actions: write` the
25+
# dispatch endpoint needs — minted from the validator's per-stroke output, so
26+
# every other run's token is exactly the four grants above)
2427
# — and revoked by that action's post step. The workflow's own `GITHUB_TOKEN` gets nothing
2528
# (`permissions: {}` below); the job grants it `contents: read` for the
2629
# checkout and nothing else, so the run's ONLY write path is the App token,
@@ -84,7 +87,9 @@ jobs:
8487
# non-integer number, an over-cap string or an over-cap payload with
8588
# every reason in the step summary and zero writes; on success it hands
8689
# the mint below the repository NAMES its token must reach through
87-
# `$GITHUB_OUTPUT` — one for every stroke, two for a transfer stroke.
90+
# `$GITHUB_OUTPUT` — one for every stroke, two for a transfer stroke —
91+
# and `permission_actions`: `write` for a stroke carrying the one
92+
# `workflow_dispatch` action, EMPTY for every other stroke.
8893
- name: Validate the payload (closed schema, zero writes on refusal)
8994
id: validate
9095
env:
@@ -119,6 +124,15 @@ jobs:
119124
# or branch write — so this grant is spent only by those two mutations.
120125
permission-contents: write
121126
permission-metadata: read
127+
# ONE consumer: the `workflow_dispatch` row — `POST .../actions/workflows/{file}/dispatches`
128+
# on an allowlisted file at `main`. Minted ONLY for a stroke carrying that op: the validator
129+
# writes `permission_actions=write` for such a stroke and an EMPTY value for every other,
130+
# and `actions/create-github-app-token` skips an empty `permission-*` input (its
131+
# `lib/get-permissions-from-inputs.js`: an empty value leaves the set unchanged, measured
132+
# at the v3 tag), so every other run keeps exactly the four grants above. `actions: write`
133+
# also reaches cancel, re-run, delete, cache and artifact endpoints; the op table has no
134+
# row for any of them, and `validate.mjs --self-test` pins that none is reachable.
135+
permission-actions: ${{ steps.validate.outputs.permission_actions }}
122136

123137
# The sender gate, then the actions in order, then the step summary —
124138
# request id · sender and role · session · target · one row per request.

0 commit comments

Comments
 (0)