Repository navigation
Commit 2ad2382
feat(pm): fleet GitHub writes act as objectstack-fleet[bot] behind one fleet-wide write gate (token minter · lease + batch gap · issue-create door · with-fleet wrapper) (#19642)
Fixes #19641
Clause-②: no
## What this lands
Two seat accounts were restricted by the platform on 2026-09-21. The
token the seats wrote with was **user-to-server** (`GET /user` answered
a login; every write booked against a person), and the trigger was
**rate shape**, not quota: `X-RateLimit-Remaining` never left
15000/15000, while 17 issues in 29 s — N processes each sleeping its own
1.2 s — got the account restricted for 08:16–08:52 UTC, after which
every push by it got no `github-actions` check-suite. Both halves of the
answer, mechanical:
- **`scripts/pm/fleet-token.mjs`** — the fleet's identity. An RS256 App
JWT built on node `crypto` (zero dependencies): `iat = now − 60`, `exp =
now + 600` (the ceiling; `buildAppJwt` refuses longer), `iss =
OS_FLEET_APP_ID`. `POST /app/installations/{id}/access_tokens`, then
`GET /app` and `GET /users/<slug>[bot]` for the identity. Cache at mode
0600 beside the throttle's log, refreshed 5 min early; minting takes the
write lease and re-reads the cache after the wait, so N processes
starting together mint once. `--export` (for `eval`), `--print`,
`--status` (redacted), `--refresh`; `getFleetToken()` for importers.
`OS_FLEET_PRIVATE_KEY` as PEM, PEM with literal `\n`, or base64 —
detected. Every log line, status line, error message and stack is
redacted; the self-test feeds a known token and a known key through
every one of those paths and greps them all.
- **`scripts/pm/write-pace.mjs`** — the gate, extended rather than
duplicated. Rule ④, the **lease**: one write in flight fleet-wide, held
from reservation to response (through the gap sleep), a FIFO ticket
queue beside a `mkdir` exclusion primitive (the shape of
`os-verify-lock.sh`, whose `flock` this host lacks), dead or over-held
holders broken and said out loud, a 15-minute bounded wait that refuses
with exit 10 rather than spinning. Rule ⑤, **batch mode**:
`--announce-batch N` / `announceBatch()` for N ≥ 5 raises the gap to 30
s for that identity's next N writes, fleet-wide through the shared log,
and prints the count and the estimate. `--run [--read] [--kind K]
[--batch N] -- <command…>` is the door for shell callers. The
self-test's rule ④ and ⑤ batteries spawn **real child processes** and
read their timestamps: no two gated commands overlapped, consecutive
reservations sat ≥ the gap apart by the log's own stamps, and a batch
announced in one process paced two others.
- **`scripts/pm/issue-create.mjs`** — the door a new card leaves through
(creation had no tool; a bare `curl` loop is where the burst came from).
Prose from files, never argv; one POST behind both halves of the
throttle; a read-back; the exit-code ladder 0 · 2 · 3 · 4 · 5 · 10. This
PR's own card, its claim comment and its assignee were written through
`issue-create.mjs`, `post-stamped.mjs` and `label-write.mjs` — through
this branch's throttle, behind the lease.
- **`scripts/pm/with-fleet.sh`** +
**`scripts/pm/fleet-git-credential.sh`** — mint → `eval` the exports
(`GITHUB_TOKEN`, `GH_TOKEN`: the existing `process.env.GITHUB_TOKEN ??
process.env.GH_TOKEN` call sites change by **zero** lines) → git
identity from the environment (committer = the bot by its **user** id;
author = the bot, or a real login via `OS_FLEET_GIT_AUTHOR_LOGIN`,
resolved by the minter) → git credential through `GIT_CONFIG_*` env
pairs that reset the helper list and add the fleet helper (nothing on
disk; no token in any URL; `git remote -v` unchanged) → the command
behind `write-pace.mjs --run`. Its self-test reads a fixture cache in
place of the platform and proves what git sees, including `git
credential fill` answered by the fleet helper and not the host keychain.
- **`label-write.mjs`, `close-cards.mjs`** — release the lease on a
transport that threw (no response will come). **`AGENTS.md`** — the
rule, one paragraph. **`lint.yml`, `package.json`** — the three new
self-tests run in CI.
## Deviations from the brief, stated rather than hidden
- The brief asked for a gate modelled on `os-verify-lock.sh`. That
script's primitive is `flock`, which macOS does not ship — on this host
it runs in its declared unlocked mode — and the repo already carried a
cross-process write throttle with a `mkdir` lock (`write-pace.mjs`). A
second lock beside it would be the second implementation the
route-ownership rule forbids, so the lease and the batch mode extend
`write-pace.mjs`; the ticket-queue **shape** of `os-verify-lock.sh` is
mirrored, its primitive is not.
- The brief asked for exponential back-off (2/4/8/16 s) on a 403/429
without `Retry-After`. Not done: that convention is for a dropped
transport, and a secondary-limit refusal retried after 16 s inside one
process is precisely the per-process pacing that produced the burst.
Rule ③ (a 30-minute stop marker, or the `Retry-After` when it reaches
further, refused loudly by every seat sharing the identity) is the
conservative end of the platform's own guidance and stays.
- The brief asked for the token cache in the session scratchpad. It
lives beside the throttle's log
(`~/.cache/objectstack-pm/fleet-token.json`, `OS_FLEET_TOKEN_CACHE_FILE`
to move it): N sessions each minting into their own scratchpad is the
concurrent minting the lock exists to prevent.
- The brief spelled the bot's noreply address with the App id. GitHub's
noreply form uses the bot **user** id (`332303061` for
`objectstack-fleet[bot]`; the App id is `5028251`); the minter resolves
it from `GET /users/<slug>[bot]` and the wrapper uses it.
- **The lease and the token cache are FILESYSTEM-scoped, so "fleet-wide"
reaches exactly as far as one filesystem — and the maintainer has
confirmed there is no shared volume to mount into the cloud
containers.** On a host where every session shares one home the lease
serialises the whole fleet; inside one cloud container it covers the
seat and every subagent it fans out into worktrees, which IS the shape
of the incident (one seat's fan-out, each process pacing itself). Across
containers it is not shared: N containers are N independently paced
streams against one identity. The file's header now states that boundary
rather than implying more, and the follow-up is filed as
#19669 — measure the installation's shared
`x-ratelimit-used` counter (carried on every response, zero extra
requests) as a cross-container signal, and meanwhile adopt this file's
overrides as a declared conservative per-container profile. A lock ref
on GitHub was considered and rejected there: two extra write requests
per content write against the platform's own hourly content limit.
## Findings the maintainer needs
- **Installation permissions — resolved.** At 2026-09-22T03:10Z the
`objectstack-fleet` installation carried no `pull_requests`; the
maintainer added it at the App and the organization accepted it: the
token minted at 05:10Z carries `actions:write checks:write
contents:write issues:write metadata:read pull_requests:write
workflows:write`.
- `.claude/skills/pm-dispatch/references/rest-channel.md` still teaches
bare `curl -X POST … ready_for_review` and `curl -X PUT … auto_merge`
recipes. `.claude/**` is Tier S and untouched here; a follow-up should
re-point them at `scripts/pm/with-fleet.sh -- gh api …`.
- `scripts/pm/ensure-pm-labels.sh` issues `gh label create` / `gh label
edit` writes outside the gate — a pre-existing shell path, left as is
here.
## Two defects the live run found, fixed in the same PR
- **A gated tool under `with-fleet.sh` queued behind its own wrapper.**
`--run` held the lease for the whole command, and `issue-create.mjs`
takes the lease for its POST, so the tool waited until the two-minute
hold bound broke its parent's lease (the write still landed). Now
`--run` names the holding pid in `OS_PM_WRITE_LEASE_HOLDER`, a child
whose acquire finds that pid holding inherits the turn at once (still
reserving its own slot, never releasing a lease it did not take), and
`with-fleet.sh` runs a roster tool with identity only — no second gate
around a tool that gates itself. Pinned in both self-tests.
- **`--print` in a shell with no inputs and no session token printed
nothing**, so a caller sent an empty bearer and read a 401 that looked
like a revoked token. A fresh cache is now served as is in that shell; a
stale one refuses naming both routes.
## Where the inputs live — organization variables, the maintainer's
decision
Configuring `OS_FLEET_*` into every Claude account's cloud environment
did not scale, so the maintainer holds all three — the private key
included — as **organization variables** of `objectstack-ai`, shared
with the board repo. `fleet-token.mjs` reads whatever the environment
lacks with the session's own GitHub token, through the REPO endpoints
only: the repository variable first, then `GET
/repos/{repo}/actions/organization-variables` (the `/orgs/` endpoint
needs an org-level permission a seat's token does not carry — measured
403 with a `repo`-scoped token, 200 through the repo endpoint). The
environment wins per variable; `OS_FLEET_INPUTS_FROM_GITHUB=0` closes
the route; `OS_FLEET_VARIABLES_REPO` names another board. A key read
this way lives in memory for one mint and is never written anywhere —
the cache stays token-only, and the self-test pins that.
The trust boundary this widens, stated in the file's header and here: a
repository or organization variable is plaintext, readable by every
collaborator and by every App installation holding `actions: read` on
the repo, so every one of them can mint as the fleet. The maintainer
took the decision with that consequence in front of them (a Secret would
not serve: no API reads a Secret back). Whether a fleet container's
egress proxy allows the two repo endpoints is the one thing not
measurable from this host — online step 1 from a cloud session answers
it.
## Ruling — `AGENTS.md` line ceiling 1109 → 1116
`AGENTS.md` sat at its shrink-only ceiling (1109, headroom 0); the
write-gate rule this PR adds costs six lines after compression from
sixteen. `check:pm-skill-ratchet` requires a maintainer ruling quoted in
the raising PR's body. The maintainer's answer to option A (raise the
ceiling to 1116, ruling quoted here; option B was compressing seven
lines of other rules), verbatim and untranslated, recorded on #19641 in
[comment
5771401251](#19641 (comment)):
> 同意 A(推荐)
The `CEILINGS` row in `scripts/pm/check-skill-line-ratchet.mjs` moves
1109 → 1116 and carries the same quotation. No other ceiling changes.
## Governed surface
The diff touches `AGENTS.md` (Tier H). The PR is pushed and left here:
no seat arms auto-merge; landing waits for the maintainer's approving
review. Nothing publishes from any released package, so this PR carries
the `skip-changeset` label instead of a changeset.
## Verification — exit codes captured before any pipe
| command | exit |
|:--|:--|
| `node scripts/pm/write-pace.mjs --self-test` (109 cases · 12 batteries
· real child processes) | 0 |
| `node scripts/pm/fleet-token.mjs --self-test` (73 cases · 8 batteries
· offline) | 0 |
| `node scripts/pm/issue-create.mjs --self-test` (34 cases · 5 batteries
· offline) | 0 |
| `bash scripts/pm/with-fleet.sh --self-test` (19 cases · fixture cache
· throwaway repo) | 0 |
| `label-write` · `close-cards` · `post-stamped` · `sweep-closed-cards`
· `sweep-stale-finding` `--self-test` | 0 · 0 · 0 · 0 · 0 |
| `pnpm lint` (through `os-verify-lock.sh`) | 0 |
| `node scripts/check-commit-card-trailers.mjs --range
origin/main..HEAD` | 0 |
| `check:pm-skill-id-lint` · `check:pm-governed-prose` ·
`check:self-test-wired` · `check:pm-skill-ratchet` (after the ruled
raise) | 0 · 0 · 0 · 0 |
| `check:adr-anchors` · `check:doc-authoring` ·
`check:corpus-claim-drift` · `check:issue-citations` ·
`check:cross-package-test-inputs` · `check:workflow-step-name-quoting` ·
`check:required-contexts` (and the rest of the AGENTS.md / lint.yml
readers) | 0 |
No TypeScript package is touched, so no `typecheck` applies. Four gates
in the same local sweep exited non-zero for reasons outside this diff:
`check:agent-test-spelling` (a finding in the untracked
`.claude/settings.local.json`), and `check:dev-prereqs` / `check:i18n` /
`check:i18n-coverage` (this worktree has no built `packages/spec/dist`;
the last two exit 3 = nothing measured). CI checks out and builds fresh.
**Declared narrowing — verification ran UNLOCKED.**
`scripts/pm/os-verify-lock.sh`
could not take the shared verify lock on this host: no usable `flock`.
The shared
verify lock is declared Linux-only (`flock` is util-linux, and a stock
macOS does
not ship it), so the command below was run directly, without the lock —
a declared narrowing, not a silent one. No serialization guarantee held
for this
run, nor for any sibling agent in this container while it ran.
pnpm lint
## Online acceptance — for the maintainer, with the injected
`OS_FLEET_*`
1. `node scripts/pm/fleet-token.mjs --print` returns a token; `curl -sI
-H "Authorization: Bearer $(node scripts/pm/fleet-token.mjs --print)"
https://api.github.com/rate_limit` shows an empty `X-OAuth-Scopes` and
`X-RateLimit-Limit` ≥ 5000. **Done 2026-09-22T05:10Z from this Mac**,
with no `OS_FLEET_*` in the shell (inputs read from the organization
variables): exit 0, minted for `objectstack-fleet[bot]` (user id
332303061), `x-ratelimit-limit: 15000`, no `x-oauth-scopes` header at
all (a server-to-server token sends none).
2. ⭐ `GET /user` with that token **must fail (403)** — a
server-to-server token has nobody behind it. A login in the answer means
the token is still user-to-server: the migration did not take — stop.
**Done**: HTTP 403 `Resource not accessible by integration` — nobody
behind the token; the migration took.
3. `scripts/pm/with-fleet.sh -- node scripts/pm/issue-create.mjs
--title-file t.txt --body-file b.md` on a test card, and
`scripts/pm/with-fleet.sh -- gh pr create …` on a test branch: both must
show the author as `objectstack-fleet[bot]`. The PR half needs the
`pull_requests` permission accepted on the installation first. **Done
2026-09-22T05:2x–05:34Z**:
[#19663](#19663)
created by `objectstack-fleet[bot]` (issue-create through the wrapper);
an empty commit committed as `objectstack-fleet[bot]
<332303061+objectstack-fleet[bot]@users.noreply.github.com>`, pushed as
the bot, and
[#19664](#19664)
opened by `objectstack-fleet[bot]` (type Bot) — its head got **10**
`github-actions` check-suites within a minute. Both closed by the same
identity, the branch deleted.
4. ⭐ The open question: with that token, `scripts/pm/with-fleet.sh
--kind 'git push' -- git push origin HEAD` of one real commit onto
`claude/issue-19354-effective-object-permission-rescue`, then `GET
/repos/objectstack-ai/objectstack/commits/<new sha>/check-suites` — is
the `github-actions` suite count > 0? Every push since 2026-09-21 08:52
read 0. > 0 means the block was the identity and PR #19540 is unblocked;
0 means the restriction is not only on the person — report either result
as it is. (That PR and its card answer 404 to every other account,
consistent with the author's content being hidden; only the maintainer
can read it.) **Done, and the answer is 0.** Empty commit `57f7dfdb`
committed and pushed onto that branch as `objectstack-fleet[bot]` at
05:32Z; at +60 s and again at +150 s: check-suites total 5 (`claude`,
`cloudflare-workers-and-pages`, `fly-io`, `objectstack-fleet`,
`vercel`), **`github-actions` 0**, workflow runs by `head_sha` 0 — while
the bot's own PR #19664 got 10 at the same minute. So the restriction is
on that pull request (or its author), not on who pushes: changing the
pusher does not revive it. The remedy is a fresh PR from a fresh branch
carrying the same commits. The bot token, like every other account,
reads #19540 and #19354 as 404.
5. `node scripts/pm/write-pace.mjs --status` after the above shows the
reservations under the App key for the mint and under the token's key
for the writes, and the lease free.
## Suggestion — not an instrument
A scan over `scripts/`, `.claude/` and `.github/` for bare write calls
(`curl -X POST|PATCH|PUT|DELETE`, `fetch(` with a write verb outside
`scripts/pm`, `gh api -X …`, `gh label create`) would make the AGENTS.md
rule mechanical and would have caught the loop that caused this. Not
added here: a new instrument takes the maintainer's word.
---
_Generated by [Claude
Code](https://claude.ai/code/session_local_fce2b4bd-ec0d-4e1c-8395-224cd17995e6)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 97f4f8c commit 2ad2382
11 files changed
Lines changed: 2883 additions & 53 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1355 | 1355 | | |
1356 | 1356 | | |
1357 | 1357 | | |
1358 | | - | |
| 1358 | + | |
1359 | 1359 | | |
1360 | | - | |
| 1360 | + | |
| 1361 | + | |
| 1362 | + | |
| 1363 | + | |
| 1364 | + | |
1361 | 1365 | | |
1362 | 1366 | | |
1363 | 1367 | | |
| 1368 | + | |
| 1369 | + | |
| 1370 | + | |
| 1371 | + | |
| 1372 | + | |
| 1373 | + | |
| 1374 | + | |
| 1375 | + | |
| 1376 | + | |
| 1377 | + | |
| 1378 | + | |
| 1379 | + | |
| 1380 | + | |
| 1381 | + | |
| 1382 | + | |
| 1383 | + | |
| 1384 | + | |
| 1385 | + | |
| 1386 | + | |
1364 | 1387 | | |
1365 | 1388 | | |
1366 | 1389 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
413 | 413 | | |
414 | 414 | | |
415 | 415 | | |
| 416 | + | |
| 417 | + | |
| 418 | + | |
| 419 | + | |
| 420 | + | |
| 421 | + | |
| 422 | + | |
416 | 423 | | |
417 | 424 | | |
418 | 425 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
88 | 88 | | |
89 | 89 | | |
90 | 90 | | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
91 | 94 | | |
92 | 95 | | |
93 | 96 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1495 | 1495 | | |
1496 | 1496 | | |
1497 | 1497 | | |
1498 | | - | |
| 1498 | + | |
| 1499 | + | |
| 1500 | + | |
| 1501 | + | |
| 1502 | + | |
| 1503 | + | |
| 1504 | + | |
| 1505 | + | |
| 1506 | + | |
| 1507 | + | |
| 1508 | + | |
1499 | 1509 | | |
1500 | 1510 | | |
1501 | 1511 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
141 | 141 | | |
142 | 142 | | |
143 | 143 | | |
144 | | - | |
| 144 | + | |
145 | 145 | | |
146 | 146 | | |
147 | 147 | | |
| |||
455 | 455 | | |
456 | 456 | | |
457 | 457 | | |
| 458 | + | |
458 | 459 | | |
459 | 460 | | |
460 | 461 | | |
| |||
| 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 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
0 commit comments