Skip to content

[test] fleet identity check — a PR authored by objectstack-fleet[bot], closed by it right after - #19664

Closed
objectstack-fleet[bot] wants to merge 1 commit into
mainfrom
fleet-test/identity-check
Closed

objectstack-fleet[bot] wants to merge 1 commit into
mainfrom
fleet-test/identity-check

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Online acceptance, step 3, for #19641 / #19642: this pull request was opened through scripts/pm/with-fleet.sh -- gh pr create, by the fleet's App installation token behind the fleet write gate, on a branch whose one empty commit was pushed the same way. Its only purpose is the author line. Closed by the same identity immediately; the branch is deleted with it. Nothing to review.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Closed by the same identity that opened it: the author line was the whole test.

@objectstack-fleet
objectstack-fleet Bot deleted the fleet-test/identity-check branch September 22, 2026 05:36
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…e fleet-wide write gate (token minter · lease + batch gap · issue-create door · with-fleet wrapper) (objectstack-ai#19642)

Fixes objectstack-ai#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
objectstack-ai#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 objectstack-ai#19641 in
[comment
5771401251](objectstack-ai#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**:
[objectstack-ai#19663](objectstack-ai#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
[objectstack-ai#19664](objectstack-ai#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 objectstack-ai#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 objectstack-ai#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 objectstack-ai#19540 and objectstack-ai#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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

0 participants