Skip to content

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 into
mainfrom
claude/issue-19641-fleet-bot-identity-write-gate
Sep 22, 2026
Merged

os-zhuang merged 7 commits into
mainfrom
claude/issue-19641-fleet-bot-identity-write-gate

Conversation

@hotlong

@hotlong hotlong commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

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 Cross-container write pacing without shared storage: measure the shared x-ratelimit-used counter as the fleet gate's cross-container signal; the per-container conservative profile is the knob until then #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:

同意 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 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 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 feat(spec): ISecurityService gains resolveEffectiveObjectPermissions, 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 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 [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): ISecurityService gains resolveEffectiveObjectPermissions, the effective-object-permission reader (#19539) #19540 and feat(spec): ISecurityService gains the effective-object-permission reader — the packages/spec half of objectstack-ai/objectstack#18783, split out under 强制条款② #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

…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>
@github-actions github-actions Bot added size/xl ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation labels Sep 22, 2026
@hotlong hotlong added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 22, 2026
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>
hotlong and others added 2 commits September 22, 2026 12:56
…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>
hotlong and others added 2 commits September 22, 2026 13:38
…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>
@os-zhuang
os-zhuang added this pull request to the merge queue Sep 22, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci/cd dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants