Repository navigation
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
Merged
os-zhuang merged 7 commits intoSep 22, 2026
Conversation
…e fleet-wide write gate Two seat accounts were restricted by the platform in one day. The token the seats wrote with was user-to-server (every write booked against a person), and the trigger was rate SHAPE, not quota: 17 issues in 29 s from N processes each pacing itself. This lands both halves of the answer. - scripts/pm/fleet-token.mjs: an RS256 App JWT on node crypto (no dependency); the installation token minted and cached at 0600, refreshed five minutes early, minting serialised through the write lease; --export / --print / --status; PEM, PEM-with-literal-\n and base64 key forms; every log, status, error and stack redacted, with a self-test that feeds a known token and key through every output path and greps them all. - scripts/pm/write-pace.mjs: rule 4, the fleet-wide LEASE (one write in flight, FIFO tickets, dead-holder reaping, a bounded wait that refuses loudly) and rule 5, BATCH MODE (N >= 5 raises the gap to 30 s for the next N writes, announced with the count and the estimate, fleet-wide through the shared log); --run for shell callers; the self-test measures both on real child processes by their timestamps. - scripts/pm/issue-create.mjs: the one door a new card leaves through: prose from files never argv, one POST behind both halves of the throttle, a read-back, the exit-code ladder. - scripts/pm/with-fleet.sh + fleet-git-credential.sh: mint, export (the existing GITHUB_TOKEN ?? GH_TOKEN call sites change by zero lines), git identity (committer = the bot by its USER id; author = the bot or a real login), git credential through GIT_CONFIG_* env pairs with nothing on disk, then the command behind the gate. - label-write and close-cards release the lease on a transport that threw; AGENTS.md carries the rule; lint.yml runs the three new self-tests. Clause-②: no Claude-Session: https://claude.ai/code/session_local_fce2b4bd-ec0d-4e1c-8395-224cd17995e6 Co-authored-by: Claude <noreply@anthropic.com>
The rule stays; the detail it restated lives in each script's header, which the sentence now names as the authority. Claude-Session: https://claude.ai/code/session_local_fce2b4bd-ec0d-4e1c-8395-224cd17995e6 Co-authored-by: Claude <noreply@anthropic.com>
…ntainer's ruling The write-gate rule costs six lines on a file at headroom 0. The ruling that buys them is quoted, verbatim and untranslated, beside the CEILINGS row and in the PR body: 「同意 A(推荐)」. Claude-Session: https://claude.ai/code/session_local_fce2b4bd-ec0d-4e1c-8395-224cd17995e6 Co-authored-by: Claude <noreply@anthropic.com>
…bles when the environment has none The maintainer holds OS_FLEET_APP_ID, OS_FLEET_INSTALLATION_ID and OS_FLEET_PRIVATE_KEY as organization variables, so a fleet container needs no configuration of its own. Absent inputs are read with the session's GitHub token through the REPO endpoints — the repository variable, then the organization variables shared with the repo (the /orgs/ endpoint needs an org-level permission a seat's token does not carry). The environment wins per variable; OS_FLEET_INPUTS_FROM_GITHUB=0 closes the route; a key read this way stays in memory for one mint and is never written anywhere. The header states the trust boundary a plaintext variable widens. Measured from this host with a repo-scoped token, no OS_FLEET_* in the shell: the mint succeeded as objectstack-fleet[bot]; GET /user with the token answers 403 (nobody behind it); x-ratelimit-limit 15000 with no x-oauth-scopes header. Claude-Session: https://claude.ai/code/session_local_fce2b4bd-ec0d-4e1c-8395-224cd17995e6 Co-authored-by: Claude <noreply@anthropic.com>
…stead of queueing behind it Measured on the first live run: with-fleet.sh --run held the fleet write lease for the whole command, and the command was issue-create.mjs, which takes the lease for its own POST — so the tool queued behind its parent until the two-minute hold bound broke the lease. Two repairs: - write-pace --run names the holding pid in OS_PM_WRITE_LEASE_HOLDER for its child; a child whose acquire finds that pid holding the lease inherits the turn at once, still reserves its own slot, and never releases a lease it did not take. The self-test measures a gated child under --run writing well inside the hold bound. - with-fleet.sh runs a scripts/pm tool on the write-pace roster with identity only: a tool that calls both halves of the throttle itself needs no second gate around it. The roster is read from write-pace, not copied. Claude-Session: https://claude.ai/code/session_local_fce2b4bd-ec0d-4e1c-8395-224cd17995e6 Co-authored-by: Claude <noreply@anthropic.com>
… no inputs A shell with neither OS_FLEET_* nor a session token could resolve nothing, so --print printed nothing and the caller sent an empty bearer — measured as a 401 that read like a revoked token. A fresh cache is served as is in that shell; a stale one still refuses, naming both routes, never the expired token. Claude-Session: https://claude.ai/code/session_local_fce2b4bd-ec0d-4e1c-8395-224cd17995e6 Co-authored-by: Claude <noreply@anthropic.com>
One filesystem, and no further: on a shared-home host that is the whole fleet, inside a cloud container it is the seat and every subagent it fans out into worktrees — which is the shape of the incident. Across containers nothing is shared and there is no volume to mount, so N containers are N independently paced streams against one identity. The installation's own x-ratelimit-used counter is the candidate cross-container signal; it is unmeasured, so nothing acts on it yet and the knob stays this file's overrides. Claude-Session: https://claude.ai/code/session_local_fce2b4bd-ec0d-4e1c-8395-224cd17995e6 Co-authored-by: Claude <noreply@anthropic.com>
github-merge-queue
Bot
removed this pull request from the merge queue due to failed status checks
Sep 22, 2026
os-zhuang
approved these changes
Sep 22, 2026
os-zhuang
deleted the
claude/issue-19641-fleet-bot-identity-write-gate
branch
September 22, 2026 07:26
This was referenced Sep 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 /useranswered a login; every write booked against a person), and the trigger was rate shape, not quota:X-RateLimit-Remainingnever 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 nogithub-actionscheck-suite. Both halves of the answer, mechanical:scripts/pm/fleet-token.mjs— the fleet's identity. An RS256 App JWT built on nodecrypto(zero dependencies):iat = now − 60,exp = now + 600(the ceiling;buildAppJwtrefuses longer),iss = OS_FLEET_APP_ID.POST /app/installations/{id}/access_tokens, thenGET /appandGET /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(foreval),--print,--status(redacted),--refresh;getFleetToken()for importers.OS_FLEET_PRIVATE_KEYas 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 amkdirexclusion primitive (the shape ofos-verify-lock.sh, whoseflockthis 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 barecurlloop 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 throughissue-create.mjs,post-stamped.mjsandlabel-write.mjs— through this branch's throttle, behind the lease.scripts/pm/with-fleet.sh+scripts/pm/fleet-git-credential.sh— mint →evalthe exports (GITHUB_TOKEN,GH_TOKEN: the existingprocess.env.GITHUB_TOKEN ?? process.env.GH_TOKENcall sites change by zero lines) → git identity from the environment (committer = the bot by its user id; author = the bot, or a real login viaOS_FLEET_GIT_AUTHOR_LOGIN, resolved by the minter) → git credential throughGIT_CONFIG_*env pairs that reset the helper list and add the fleet helper (nothing on disk; no token in any URL;git remote -vunchanged) → the command behindwrite-pace.mjs --run. Its self-test reads a fixture cache in place of the platform and proves what git sees, includinggit credential fillanswered 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 isflock, 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 amkdirlock (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 extendwrite-pace.mjs; the ticket-queue shape ofos-verify-lock.shis 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 theRetry-Afterwhen 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_FILEto 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 (
332303061forobjectstack-fleet[bot]; the App id is5028251); the minter resolves it fromGET /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 Cross-container write pacing without shared storage: measure the shared
x-ratelimit-usedcounter as the fleet gate's cross-container signal; the per-container conservative profile is the knob until then #19669 — measure the installation's sharedx-ratelimit-usedcounter (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
objectstack-fleetinstallation carried nopull_requests; the maintainer added it at the App and the organization accepted it: the token minted at 05:10Z carriesactions:write checks:write contents:write issues:write metadata:read pull_requests:write workflows:write..claude/skills/pm-dispatch/references/rest-channel.mdstill teaches barecurl -X POST … ready_for_reviewandcurl -X PUT … auto_mergerecipes..claude/**is Tier S and untouched here; a follow-up should re-point them atscripts/pm/with-fleet.sh -- gh api ….scripts/pm/ensure-pm-labels.shissuesgh label create/gh label editwrites outside the gate — a pre-existing shell path, left as is here.Two defects the live run found, fixed in the same PR
with-fleet.shqueued behind its own wrapper.--runheld the lease for the whole command, andissue-create.mjstakes 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--runnames the holding pid inOS_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), andwith-fleet.shruns a roster tool with identity only — no second gate around a tool that gates itself. Pinned in both self-tests.--printin 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 ofobjectstack-ai, shared with the board repo.fleet-token.mjsreads whatever the environment lacks with the session's own GitHub token, through the REPO endpoints only: the repository variable first, thenGET /repos/{repo}/actions/organization-variables(the/orgs/endpoint needs an org-level permission a seat's token does not carry — measured 403 with arepo-scoped token, 200 through the repo endpoint). The environment wins per variable;OS_FLEET_INPUTS_FROM_GITHUB=0closes the route;OS_FLEET_VARIABLES_REPOnames 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: readon 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.mdline ceiling 1109 → 1116AGENTS.mdsat 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-ratchetrequires 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:The
CEILINGSrow inscripts/pm/check-skill-line-ratchet.mjsmoves 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 theskip-changesetlabel instead of a changeset.Verification — exit codes captured before any pipe
node scripts/pm/write-pace.mjs --self-test(109 cases · 12 batteries · real child processes)node scripts/pm/fleet-token.mjs --self-test(73 cases · 8 batteries · offline)node scripts/pm/issue-create.mjs --self-test(34 cases · 5 batteries · offline)bash scripts/pm/with-fleet.sh --self-test(19 cases · fixture cache · throwaway repo)label-write·close-cards·post-stamped·sweep-closed-cards·sweep-stale-finding--self-testpnpm lint(throughos-verify-lock.sh)node scripts/check-commit-card-trailers.mjs --range origin/main..HEADcheck:pm-skill-id-lint·check:pm-governed-prose·check:self-test-wired·check:pm-skill-ratchet(after the ruled raise)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)No TypeScript package is touched, so no
typecheckapplies. 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), andcheck:dev-prereqs/check:i18n/check:i18n-coverage(this worktree has no builtpackages/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.shcould not take the shared verify lock on this host: no usable
flock. The sharedverify lock is declared Linux-only (
flockis util-linux, and a stock macOS doesnot 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.
Online acceptance — for the maintainer, with the injected
OS_FLEET_*node scripts/pm/fleet-token.mjs --printreturns a token;curl -sI -H "Authorization: Bearer $(node scripts/pm/fleet-token.mjs --print)" https://api.github.com/rate_limitshows an emptyX-OAuth-ScopesandX-RateLimit-Limit≥ 5000. Done 2026-09-22T05:10Z from this Mac, with noOS_FLEET_*in the shell (inputs read from the organization variables): exit 0, minted forobjectstack-fleet[bot](user id 332303061),x-ratelimit-limit: 15000, nox-oauth-scopesheader at all (a server-to-server token sends none).GET /userwith 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 403Resource not accessible by integration— nobody behind the token; the migration took.scripts/pm/with-fleet.sh -- node scripts/pm/issue-create.mjs --title-file t.txt --body-file b.mdon a test card, andscripts/pm/with-fleet.sh -- gh pr create …on a test branch: both must show the author asobjectstack-fleet[bot]. The PR half needs thepull_requestspermission accepted on the installation first. Done 2026-09-22T05:2x–05:34Z: #19663 created byobjectstack-fleet[bot](issue-create through the wrapper); an empty commit committed asobjectstack-fleet[bot] <332303061+objectstack-fleet[bot]@users.noreply.github.com>, pushed as the bot, and #19664 opened byobjectstack-fleet[bot](type Bot) — its head got 10github-actionscheck-suites within a minute. Both closed by the same identity, the branch deleted.scripts/pm/with-fleet.sh --kind 'git push' -- git push origin HEADof one real commit ontoclaude/issue-19354-effective-object-permission-rescue, thenGET /repos/objectstack-ai/objectstack/commits/<new sha>/check-suites— is thegithub-actionssuite count > 0? Every push since 2026-09-21 08:52 read 0. > 0 means the block was the identity and PR feat(spec):ISecurityServicegainsresolveEffectiveObjectPermissions, the effective-object-permission reader (#19539) #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 commit57f7dfdbcommitted and pushed onto that branch asobjectstack-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-actions0, workflow runs byhead_sha0 — while the bot's own PR [test] fleet identity check — a PR authored by objectstack-fleet[bot], closed by it right after #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 feat(spec):ISecurityServicegainsresolveEffectiveObjectPermissions, the effective-object-permission reader (#19539) #19540 and feat(spec):ISecurityServicegains the effective-object-permission reader — thepackages/spechalf of objectstack-ai/objectstack#18783, split out under 强制条款② #19354 as 404.node scripts/pm/write-pace.mjs --statusafter 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 outsidescripts/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