Repository navigation
Fix bundled blueprint test assertions on Windows - #653
Merged
Maximo-Guk merged 1 commit intoOct 5, 2026
Merged
Conversation
Node stores a symlink target with backslashes on Windows, and the importer reports ignored paths with the platform separator, so three assertions that spell those paths with forward slashes fail there. Compare against the platform form instead; on POSIX the expectations are unchanged.
This was referenced Oct 3, 2026
Member
|
@ask-bonk review this |
|
LGTM! |
Maximo-Guk
approved these changes
Oct 5, 2026
bashandbone
added a commit
to knitli/knitli-os
that referenced
this pull request
Oct 10, 2026
* Stream git packs through consumePack instead of buffering them (#656)
* Stream git packs through consumePack instead of buffering them
Mounting a large repo as a worktree exceeded the Overseer's 128 MB
memory limit. consumePack collected the whole pack, inflated every
object, and deflated each one again before storing anything, so a
TypeScript- or vscode-size mount held 173-224 MiB of buffers at once.
decodePackStream replaces decodePackBytes. It decodes the pack in one
pass, keeping only each entry's oid by offset, and resolves every delta
base through resolveBase. It reads the stream with a BYOB reader in
64 KiB chunks, enforces the pack size cap as bytes arrive, and hashes
exactly the bytes before the trailer. A failed decode cancels the
source. Every existing malformed-pack check is kept. MAX_DELTA_DEPTH is
gone because nothing recurses any more.
consumePackFromGatekeeper stores blobs of up to 1 MiB as they arrive.
It holds commits, trees, tags and oversized blobs (a later delta may
name one as its base) and stores them in one transaction once the
trailer verifies, so a failed pack never leaves a commit that
fetchCommit would treat as mounted. #storeVerifiedObject now handles
the oversized case for both put() and consumePack.
A delta must now follow its base. That holds for every pack git
upload-pack sends a fetch with no haves, which is the only kind the
gatekeepers request.
Real depth-1 mount packs (react, next.js, TypeScript, vscode, and a
52 MiB internal repo) went through the real code on Durable Object
SQLite in workerd. Every object was stored, or measured if oversized.
A pack with a corrupted trailer left none of its commits or trees.
* Use native node:zlib for loose git objects
Mounting a vscode fork on a preview exceeded the Overseer's 30 s
per-invocation CPU limit. In local workerd, deflating every stored
object with pako in encodeLooseObject was the largest single cost of
consumePackFromGatekeeper (about 4 of 10 s for vscode).
encodeLooseObject and decodeLooseObject now use workerd's native
node:zlib (deflateSync/inflateSync, available under nodejs_compat).
The output is standard zlib at the same default level, so stored
records stay readable by isomorphic-git and by records written before.
decodeLooseObject views inflateSync's Buffer as a plain Uint8Array, so
the payloads it hands out stay plain arrays. The pack decoder keeps
pako, which it needs to find where each entry's zlib stream ends.
consumePackFromGatekeeper in local workerd, three runs each, real
Overseer DO storage, workerd process CPU:
- vscode (23,289 objects): 10.3 s -> 7.4 s
- TypeScript (65,040 objects): 18.6 s -> 13.3 s
Every object was stored or measured, oids recompute, isomorphic-git
reads a sample byte-for-byte, and stored bytes and SQLite size are
unchanged. Whether a vscode mount now fits the production limit is
not yet verified.
* Store a pack's held objects one per transaction
A vscode mount still reset the Overseer on the preview, now on CPU: the
invocation running consumePack used 32.5 s against the 30 s default.
Run on Cloudflare against the real vscode mount pack, in a throwaway
Worker's SQLite Durable Object, consumePackFromGatekeeper used 23.7-98 s
of CPU, and 3 of 7 runs were reset on a storage timeout. Most of the
excess was the one transaction around the held commits and trees: each
metadata put inside it opens a nested transaction, and ~29k of those in
one transaction cost 14-18 s there. Locally the whole transaction takes
under 1 s.
Held objects are now stored one per transaction, like the small blobs,
with commits last. No await separates the stores, so they still reach
disk together. A store that throws rolls back only its own object, and
with commits last no commit is stored before the pack's trees. A new
test covers a tree the cache refuses (mode 100664, which git only
reports as informational) arriving after its commit, as in the packs
git sends.
Same production round, interleaved: vscode 13.1-14.5 s (was 23.7-27.4
s), every object stored. TypeScript-size packs (65k objects) still take
about 38 s (was 48.6 s), over the default limit.
* Reject a pack entry whose size varint overflows
The decoder checks each entry's declared size against maxObjectSize
before inflating it, but a size varint with enough zero continuation
digits overflows its multiplier to Infinity, and 0 * Infinity leaves a
NaN size that the comparison lets through. On a 1,024-byte cap such an
entry inflated 8 MiB before failing.
The varint loop now rejects the entry once the next digit would be
worth more than the cap, since no size within the cap needs it.
* Deflate loose git objects at level 1
Deflating each object in encodeLooseObject is the largest single CPU
cost of storing a pack, and CPU is what still stops large mounts. On
this repo's 1,378 tracked files of up to 64 KiB, level 1 deflated 1.53x
faster than the default level 6 and stored 11% more bytes. In local
workerd a vscode first pull went from 7.3 s to 6.2 s with 12% more
stored bytes, and a TypeScript one stored 9% more.
Any zlib level is a valid loose object: oids hash the inflated bytes,
isomorphic-git and decodeLooseObject read every level, and rows
already stored at level 6 stay readable. Push packs are re-compressed
by buildPackBytes, so they are unaffected. The extra bytes are kept
for as long as the workspace is, since nothing repacks the store.
* Leave objects a gatekeeper already stored untouched
A pull sends no haves, so a retried pull, or one for another commit of
an already-mounted repository, delivers mostly objects the store
already holds. #storeVerifiedObject rewrote each of them: a fresh
deflate, an object put and a metadata put, for no change.
An object that is already present, measured and proven on this
gatekeeper's remote is now left as it is. Nothing it would write can
differ: the payload is fixed by the oid, its referents were recorded
and its pending-push marks propagated when it was first stored, and
marking walks descend into present objects themselves. Another
gatekeeper's copy is still recorded as that gatekeeper's proof.
In local workerd, delivering the same vscode pack again went from
6.7-7.9 s to 2.8-3.2 s.
* Test consumePack on a pack streamed over Workers RPC
The consumePack tests hand the decoder a byte stream. A gatekeeper
sends a default stream from another Worker, which the decoder's BYOB
reader refuses when handed one directly ("This ReadableStream does not
support BYOB reads"); it works only because Workers RPC delivers it as
a byte stream. A new test sends a real pack that way, from a Worker
Loader worker in 100-byte chunks, and checks every object is stored
with its bytes intact.
* Correct the GitCache.consumePack contract doc
It said consumePack is exactly equivalent to decoding the pack and
put()ting each object. Since packs are streamed that no longer holds:
a delta must follow its base, and a pack that fails can leave some of
its objects stored, though never a commit without its trees. The doc
also now says what the result holds and that an object too large to
store is left out of it rather than thrown on.
* Report why a git fetch failed, not how its stream ended
When the fetch response fails -- a server ERR line, the transfer
limit, a truncated response -- consumePack sees the pack stream it
reads across RPC only end early, and rejects with "disconnected
prematurely". That is what gitPull reported, so an agent mounting a
repo too large to fetch was told the connection dropped, and retried.
demuxGitFetchResponse takes an optional onFailure callback, and
pullGitObjectsIntoCache rethrows the failure it reports in place of
consumePack's rejection. A consumePack failure of its own still
surfaces when the fetch itself succeeded.
* Let an admin state an added model's image input and reasoning levels (#657)
* Let an admin state an added model's image input and reasoning levels
An added AI Gateway model the runtime has no entry for gets provider
defaults, and "Behaves like" borrows another model's flags wholesale. An
added model can now carry two optional stated facts: whether it takes
images, and which reasoning levels it accepts. Per field the order is the
runtime's own entry, then what is stated, then what the model it behaves
like lends, then the provider defaults.
* Fix bundled blueprint test assertions on Windows (#653)
Node stores a symlink target with backslashes on Windows, and the importer
reports ignored paths with the platform separator, so three assertions that
spell those paths with forward slashes fail there. Compare against the
platform form instead; on POSIX the expectations are unchanged.
Co-authored-by: snowyukitty <[email protected]>
* fix(gatekeeper-github): refresh expiring tokens instead of breaking after 8 hours (#661)
* fix(gatekeeper-github): refresh expiring user tokens instead of dying after 8 hours
GitHub enables expiring user tokens by default for OAuth apps registered
since 2026-08-14. The gatekeeper kept only the access token, so every
connection on such an app failed with Bad credentials eight hours in.
UserAccount now stores its grant through the kit's CredentialCoordinator,
with the code exchange and refresh going through the kit's OAuthClient:
an expiring grant is refreshed shortly before it expires, concurrent reads
share one refresh, both rotated tokens are stored together, and GitHub's
bad_refresh_token marks the grant dead and notifies the Workshop once.
Non-expiring grants, including ones stored in the old layout, are served
as before.
A 401 is now adjudicated against the token the request presented: a token
a refresh or reconnect replaced mid-request fails as retryable instead of
marking the account expired, and a rejection of the current token
refreshes past it where the grant can.
Fixes #641
* fix(gatekeeper-github): fence revoke before awaiting GitHub, re-arm legacy expiry latch
Review follow-ups:
- revoke() now moves the credential fence before its first await, so a
refresh landing mid-disconnect is discarded and its tokens revoked
instead of being stored and then deleted unrevoked.
- Migrating a pre-refresh grant re-arms the expiry latch: that layout
latched before delivering, so a failed delivery left a dead account
showing as connected.
- prepareReconnect no longer resets the latch by hand; every credential
replacement re-arms it.
* test(gatekeeper-github): race the in-flight refresh from the test, not the fake's handler
* fix(gatekeeper-github): replay configurator and observer reads past an in-flight token refresh
A refresh invalidates the token a request already carries, so a configurator
lookup or hasRepoAccess check overlapping one failed with GitHub's 401 although
the replacement token works. Both now run through withAccountApi, which
adjudicates the rejected token with the account and, for these replay-safe
reads, reruns the call once with the replacement. A rejection the account
confirms as grant death now also notifies the Workshop from these paths.
* fix(gatekeeper-github): replay a review apply's follow-up reads past a token refresh
applyAction(postReview) creates the review, then reads its comments back before
recording the action applied. A refresh that replaced the token one of those
reads carried failed the apply after GitHub had accepted the review, leaving
the action pending, so approving it again posted a second review. Those reads
(and the review-comment sync they can fall back to) now go through #readApi,
which reruns a read once with the replacement token. Mutations stay
non-replayable.
* test(gatekeeper-github): dispose the review helper's param stubs
Under the 2026-09-04 compatibility date (rpc_params_dup_stubs semantics) a
caller keeps ownership of stubs it passes as params, so it must dispose them.
* Change blueprint format to git and enable receiving blueprint updates (#659)
A blueprint now encodes a git packfile instead of a Yjs snapshot.
When you update a blueprint, people who have created gadgets from the old blueprint are now given the option to update it. For now, this does not happen automatically -- you just see a dot on the blueprints button and from there can choose to update. (We can consider implementing auto-update later perhaps.)
If local changes have been made since the blueprint was first instantiated, an agent is spawned to review the merge and fix any conflicts.
You can also choose to switch blueprints when updating. This allows you to e.g. switch from an original blueprint to some other user's customized version of it.
This change also changes the way merges are handled, both for the purpose of merging updates from blueprints, but also for merging a chat branch with changes that have landed in a gadget's mainline from other chats: merges are no longer encoded as OTs but rather as a git commit, which allows handling much larger diffs.
* Test that each agent request extends the one before it (#663)
Providers cache a prompt by its prefix, so a request that doesn't start with the whole previous request pays again for everything after the first difference. The test runs two turns, one with a tool step, and checks that each request keeps every field but its messages unchanged and starts its messages with those of the request before.
* fix(gatekeepers): latch expiry after delivery and cap connect-form reads (#668)
Supabase, Linear, Spotify, ZoomInfo and Home Assistant set expiredNotified
before awaiting credentialsExpired(), so one failed callback (a Workshop
restart mid-deploy is enough) silenced the reconnect prompt for good, and
in four of them the RPC error replaced the auth error the caller saw. They
now notify through the kit's notifyCredentialsExpiredOnce, which latches
only after delivery, and re-arm with clearCredentialExpiryLatch.
Home Assistant and the MCP gatekeeper parsed their connect form with
formData() before the account checked the nonce, so anyone holding a
connect URL could make the Worker buffer a body as large as the plan
allows into a 128 MB isolate. Both now read it through readTextCapped
(16 KiB), which accepts a Request as well as a Response.
gatekeeper-kit:
- connect-handshake adds isConnectAttempted/markConnectAttempted on the
connectAttempted key the internal no-nonce connect flows already store,
and USAGE states the at-most-once complete() rule they enforce.
- connectMutationError's JSDoc notes that a form page served with
Referrer-Policy: no-referrer, as htmlResponse pages are, submits with
Origin: null (checked in Chromium), so a guarded form needs same-origin.
Also clarifies the Cloudflare gatekeeper's omitted-scope comment (no
behavior change), and updates the kit plan: status, the complete-once
rule, the Origin: null constraint on §4.3 and tokenAuth, package-name
fixes, and the candidate leaves reviewed and not built.
Records the kit's admission rule in its AGENTS.md and plan §1: a leaf
abstracts behavior several gatekeepers share under one contract, and an
unusual gatekeeper gets plain TypeScript seams, not a provider-specific
variant. Plan entries that leaned on a single consumer now follow it:
ironclad's split-key handshake gets no kit variant, OAuth discovery and
client registration wait for a second consumer outside the MCP SDK, and
scope helpers wait for a second gatekeeper on Google's grant model.
* Share OpenAI's prompt cache across chats (#615)
GPT-5.6 and later keep a separate prompt cache for each prompt_cache_key, and pi sets one per chat, so every new chat wrote the tools and static system prompt again. Drop pi's key on these models, so chats share one cache, as they already do on Anthropic. Older models route by the key, so they keep it.
The project-specific part of the system prompt now starts with a random salt stored per workspace, so nobody without that prompt can probe the shared cache for a project's prompt or chats. Requests without it, such as compaction, keep pi's per-chat key.
* Retry a model request that fails transiently (#645)
* Retry a model request that fails before it streams anything
A transient provider failure, such as a stream that ends early or a 5xx, ended the turn with an error. runAgent now runs the pass again, up to 2 times, when pi calls the failure retryable and the request streamed nothing, so the client has no partial output to withdraw.
* feat: retry transient model failures even after partial output
A transient failure is now retried whether or not the failed request already
streamed text, reasoning or tool calls, so background agents with no one to
press Retry recover from mid-stream drops too. The failed request still
persists nothing; the retry starts from the last saved step.
Before retrying, the agent emits a new "streamReset" stream event. The chat
UI handles it like an error message: it drops the streamed text and
reasoning, tool-call cards, the active-file marker and all edit previews, so
the retry's output isn't appended to the failed attempt's.
* Type the retry test and reset the stream only before a retry
The retry test now reaches OverseerImpl through a typed view, as agent-tracing.test.ts does, instead of any. runAgent emits streamReset when it actually retries, so a failure that ran out of retries ends with the error message alone, as the event's docs say.
---------
Co-authored-by: Cloudflare OS PRs <[email protected]>
* Cut prompt cache write costs (#664)
On Anthropic, keep the head of an agent's request (tools and static system prompt) in the cache for 1 hour instead of 5 minutes. Every chat of the agent's kind shares it, so a chat that resumes after a pause of more than 5 minutes reads it instead of writing it again. The chat's own messages keep 5 minutes, because a 1-hour write costs 2x instead of 1.25x on every new token.
One-shot calls (compaction summaries, chat and gadget titles, binding names) now send cacheRetention: "none". Their prompts are sent once, so a cache write only added its premium: 1.25x on Anthropic and on GPT-5.6 and later, where "none" selects explicit caching with no breakpoints. A gadget's model binding keeps caching, since a gadget may send the same system prompt many times, and so does the admin's gateway model test, so that a model that rejects the cache fields fails the test rather than the first agent turn.
* Bump wrangler and miniflare so local workerd is 1.20261001.1 (#684)
Cloudflare's Web Search API is reached through the Workers AI binding's
websearch() method, which shipped in workerd 1.20260924.1. Our generated
worker-configuration.d.ts files predate it (they were last generated
with workerd 1.20260831.1), and local runs used workerd 1.20260921.1, so
the method was neither typed nor present in local dev.
This bumps wrangler to 4.147.0 and miniflare to 5.20261001.0-alpha,
which bring workerd 1.20261001.1, raises @cloudflare/workers-types to
wrangler 4.147's peer range (^5.20261001.1), and regenerates every
worker-configuration.d.ts with pnpm types:generate.
* Allow gatekeeper facets to mint persistent stubs (#677)
* feat(overseer): mint gatekeeper facet stubs through ctx.restore()
A gatekeeper facet reached through a bare ctx.facets.get() stub cannot call its
own ctx.restore(), so it cannot mint a persistent stub to itself for a push
handler to deliver events through. Route every gatekeeper facet stub through
the Overseer's ctx.restore(), as gadget facets already are; [restore]() checks
only that the connection still exists, so such stubs stop restoring once it is
removed. addGatekeeper keeps the raw facet for its pre-publication describe().
* docs(agent): fix the persistent-stub example's import; clarify restore params and `self`
The system prompt's restore example imported `Greeter` from cloudflare:workers, which exports
no such name and collides with the class the example declares, and omitted the `RpcTarget` the
class extends, so copying it failed to load. Also say that a Gadget may hold many persistent
stubs, each restored from its own params, which is how one Gadget tells several hooks apart.
Restore params may hold persistent stubs, and `self` is one, so code that receives it stores it
as it is. Agents applied the subscription advice to `.dup()` a received callback to `self`, which
calls a reserved method and yields an unstorable RpcPromise, and read "must be serializable" as
ruling `self` out of params. "e.g. to a subscription method" is gone: it invited subscribing
`self` as a hook whose deliveries carry live capabilities it cannot store.
* Re-encode oversized PNG chat attachments as WebP (#672)
* Re-encode oversized PNG chat attachments as WebP
PNG is lossless, so a photograph saved as PNG stays over the 1MB chat
attachment cap however far it is resized. When the resized PNG is still
too large, encode it once more as WebP at the same 0.85 quality, which
keeps transparency, and report the MIME type the browser actually
produced so a browser without a WebP encoder still sends a matching type.
Fixes the PNG half of #514
* Fall back to JPEG when WebP encoding is unavailable
Safari encodes WebP requests as PNG, so an oversized PNG still failed
the upload limit there. Try PNG, WebP, then JPEG, keeping the first
encoding under the cap, and restore test stubs in afterEach.
---------
Co-authored-by: Nathan Disidore <[email protected]>
* Bump @modelcontextprotocol/client from 2.0.0 to 2.2.0 in /packages/mcp-shared (#686)
* Bump @modelcontextprotocol/client in /packages/mcp-shared
Bumps [@modelcontextprotocol/client](https://github.com/modelcontextprotocol/typescript-sdk) from 2.0.0 to 2.2.0.
- [Release notes](https://github.com/modelcontextprotocol/typescript-sdk/releases)
- [Commits](https://github.com/modelcontextprotocol/typescript-sdk/compare/@modelcontextprotocol/[email protected]...@modelcontextprotocol/[email protected])
---
updated-dependencies:
- dependency-name: "@modelcontextprotocol/client"
dependency-version: 2.2.0
dependency-type: direct:production
...
Signed-off-by: dependabot[bot] <[email protected]>
* Update pnpm-lock.yaml for @modelcontextprotocol/client 2.2.0
---------
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Nathan Disidore <[email protected]>
* Google Chat new-message hooks (#681)
* feat(gatekeeper-google): Google Chat new-message hooks
ChatSpace and ChatThread gain subscribeNewMessages(hook): once the user enables
the hook, each new message anyone else posts reaches hook.receiveMessage() as a
live ChatMessageEntry on the hook's approval queue, so a reply queues an action
like any other write.
Delivery uses one Workspace Events user-auth subscription per (Google account,
space), pushed through Pub/Sub to POST /gatekeeper/google/pubsub, which accepts
only Google-signed tokens for that audience from the configured service
account. A ChatHookDriver per space collapses duplicate pushes, delivers from
its alarm with backoff, renews subscriptions every 3 h (retrying a failed
renewal every 15 min), and refuses a subscription Google attributes to another
account. The facet mints the delivery stub to itself with ctx.restore(); the
controller is a loopback entrypoint so that removing a connection, which
deletes the facet, still reaches disable().
Hooks are off unless PUBSUB_TOPIC and PUBSUB_PUSH_SERVICE_ACCOUNT are set.
* feat(gatekeeper-google): hand each Chat hook delivery the conversation it watches
A delivery carried only the new message, whose capability reaches the message and its thread.
Where a conversation has no threads, as in a direct message or group chat, `reply()` throws, so a
hook there could read a message but never answer it, despite an approval text promising replies.
`receiveMessage()` now also receives `conversation`: the ChatSpace or ChatThread whose
`subscribeNewMessages()` created the hook, built from the delivery's own scope. That is exactly
what the subscriber already held, so nothing new is granted; the approval text now says the hook
can read that conversation or thread and queue writes there for approval.
* fix(gatekeeper-google): keep hook errors out of the drop log; reactivate adopted subscriptions
A message dropped after its eighth failed delivery logged the error, which is usually the
gadget's own exception and can quote the private message it failed on (a JSON.parse error
does). Log only the event, as the scheduler does for failed callbacks; the gadget's author
already sees the exception in its own logs.
Adopting the subscription an unanswered create left renewed it without reactivating it, so a
suspended one stayed suspended until the next renewal. renew() now reactivates a suspended
subscription first, for both the adoption path and the periodic renewal.
* GitLab gatekeeper, for gitlab.com and self-hosted instances (#531)
* Plan: GitLab gatekeeper as a GitHub-gatekeeper mirror
Design record for packages/gatekeeper-gitlab: instance configuration for
self-hosted GitLab (GITLAB_URL / GITLAB_API_URL plus an optional Access
service token), rotating-credential OAuth, the same three resource
granularities and worktree (git pull/push) contract as gatekeeper-github,
and the git-layer extraction into gatekeeper-kit that both consume.
Verification is documentation-derived fixtures plus two live checkpoints
and a staging soak; the doc records which documented facts overturned
the first-draft decisions (scopes, identity source, request-changes,
redirects).
* Move the git layer from gatekeeper-github into gatekeeper-kit
git-transport (protocol-v2 fetch framing, send-pack ref updates) and
git-diff (tree-to-tree diff over an injected TreeDiffSource) move
verbatim; git-commits splits, its provider-neutral half (commit-id
validation, raw commit-object parsing, commit advertising) becoming
git-objects while the GitHub REST adapters stay behind. The three
become kit leaf modules (./git-transport, ./git-objects, ./git-diff) so
the GitLab gatekeeper can share them instead of carrying a second copy
of the pkt-line composer.
Comments change in the moved code: 'GitHub' becomes 'the provider'
where the protocol is generic, and filterSpecForHints's limits are
recorded as verified against GitHub and conservative elsewhere.
git-diff's output types get vendor-neutral names (GitDiffFile/Hunk/Line);
gatekeeper-github's agent-facing types.d.ts keeps its structurally
identical GitHubPullRequestDiff* names, since that text must stay
self-contained. diffGitTrees returns TreeDiffFile, the same shape with
the only statuses a tree walk produces, so a gatekeeper whose provider
never reports "copied" can say so. commitDetailsFromGitObject takes a
commitUrl callback instead of a repo URL (GitLab's commit pages live
under /-/commit/), and github wraps it to add authorAccount: null.
Per-page advertising becomes a hook rather than a cursor class: the
moved CommitAdvertisingCursor was not an RpcTarget, so each gatekeeper
would have carried its own RPC wrapper around it. git-objects now
exports advertisePages(advertiser, commitIds, {withhold}), and
./cursors a generic PageHookCursor that runs a hook on each page before
returning it, serializing calls and holding a page whose hook threw so
the retry re-offers it. github's AdvertisingCursor goes, its
SessionGitCache.wrap is the hook on a PageHookCursor, and the
simulated-commit filter each call site repeated becomes the withhold
option, which advertiseCommits takes too.
Two behavior changes, both on failure paths:
- advertiseCommits marked an id advertised before its advertisement
landed, so one failed advertiseCommit() suppressed that id for the
rest of the walk -- and the failed page was lost with the inner
cursor already past it, so the retry returned the next page, whose
ids could then reach the caller unadvertised. advertisePages records
an id only once its advertisement resolves, and PageHookCursor holds
the page.
- parseGitIdentity's pattern let the lazy name group and the \s* after
it both consume one run of spaces (CodeQL's js/polynomial-redos); the
name now runs to the first '<' and is trimmed afterwards. git itself
strips angle brackets from names, so nothing well-formed parses
differently, and the kit's identity tests cover the trimmed, empty,
and dateless forms.
Otherwise behavior-neutral: gatekeeper-github's node and workerd suites
pass unchanged apart from import paths; the moved node tests run in the
kit, and the hook and the cursor have their own.
* gatekeeper-gitlab: package skeleton, agent-facing API, and HTTP client
The first slice of the GitLab gatekeeper (plans/gitlab-gatekeeper.md,
commit 3): everything that can be reviewed before any Gatekeeper
implementation exists.
types.d.ts is the agent-facing API -- gatekeeper-github's transposed
method for method into GitLab's vocabulary (project / merge request,
source / target branch, opened|closed|merged states, #~N and !~N
provisional references), with a shared GitLabIssuable base because MR
states are not a subset of issue states, GitLab's merge and review
options (squash / removeSourceBranch; comment / approve / requestChanges
via draft notes), and thread resolution. The worktree-oriented contract
(resolveRef, getCommit, listCommits, push, then createMergeRequest) is
GitHub's verbatim.
gitlab-api.ts is the instance-parameterized HTTP client: every request
goes to a caller-supplied API origin with caller-supplied headers (the
Cloudflare Access service-token pair for instances behind Access), so
gitlab.com and self-hosted instances are the same code. It never follows
redirects -- a renamed project answers its old path with a 301 to its
numeric-id URL, and a followed 301 turns a POST into a GET -- and maps
GitLab's three documented error-body shapes. OAuth helpers cover PKCE
S256 (sent alongside client_secret), code exchange, refresh with rotation
(the response carries a new refresh token and the old one dies), and
revoke; expiry is read from expires_in, never assumed. Git smart-HTTP
POSTs use the documented oauth2 basic-auth username and reject redirects
and non-git content types with an Access-specific message.
Bodies are read under a cap (error bodies under a small one, quoted as
an excerpt), and listings report their next page from x-next-page: a
short page is not the end, because notes and discussions are filtered
after GitLab paginates them.
Fixtures are GitLab's own documentation examples, labelled source: docs;
the plan's live checkpoints replace them with captured responses. The
node suite covers path encoding (whole-name branch encoding, nested
project paths), every endpoint's query composition, the error shapes,
redirect refusal, OAuth bodies and grant parsing, and the git framing.
Also: the release-manifest fixture bundle and golden for the new
deployable package, and a README with a self-hosted-instances section.
* gatekeeper-gitlab: accounts, OAuth, resources, configurators, and every read
The read side of the GitLab gatekeeper (plans/gitlab-gatekeeper.md,
commit 4): a connectable, observable gatekeeper whose sessions can read
projects, issues, merge requests, discussions, diffs, threads, branches,
tags, commits, and history. Actions are declared (their records are what
the read side overlays) but not yet prepared or applied; the session
methods that would submit them say so.
Accounts (gitlab.ts): the two-stage nonce connect flow from the kit,
with a PKCE verifier minted alongside the OAuth-stage nonce; auth-only
grants request read_user and self-destruct; full grants request api and
write_repository (GitLab documents Git-over-HTTPS for OAuth tokens only
through the *_repository scopes). UserAccount refreshes under a mutex --
GitLab rotates refresh tokens, so two callers racing a refresh would
redeem the same single-use token -- re-checks inside the lock, and
writes the rotated pair in one transaction before any caller sees the
new token. invalid_grant is terminal: credentialsExpired once, and the
refusal is stored with the grant, so neither a burst of callers nor a
restarted object sends the dead token again. 5xx is transient (the
refresh token survives, behind a short cooldown). Identity is GET
/user's primary email when confirmed_at is set.
The account records the scopes each grant was requested with (the token
response carries none). That record is how a grant left by the
incubating gatekeeper this package replaces -- a live read_api token
with no scopes key -- is handled: reads it can serve, describe() and the
observer probe, keep working, and ensureResources() answers it with the
reconnect() URL, the interface's own hook for widening a grant, which
the Workshop opens before binding a resource. A 401 goes straight to the
reconnect message with no refresh-and-replay: expiry is handled ahead of
time from expires_in, so a 401 on a clock-fresh token means revocation.
A 401 retires the grant only from GET /user and the git endpoints, the
requests that assert nothing but the bearer; GitLab documents 401 as
merge's own "no permission" answer, so every other endpoint's 401 is
that operation's error. Facts derived from a grant are fenced to it:
connect and reconnect mint a grantId (a refresh keeps it), the observer
probe's user id is stored against the grant that answered /user, and a
membership row is discarded if the grant moved between the two reads.
What GitLab says about a token is fenced to the token: every grant
write, refresh included, mints a credentialId, getCredential() hands it
out with the token, the HTTP client stamps it on a failed request's
error, and the one reporting path (withAccountApi, shared by the
entrypoint and the DO) reports the refusal to the account for that token
-- dropped once a refresh or reconnect has replaced it, since GitLab
invalidates an access token when it issues the next, and a request still
in flight with the old one comes back 401 after the refresh. A reconnect
records the grant it replaces, and one another reconnect overtook -- its
code exchange still running while the newer flow finished -- is not
committed over the newer grant: its tokens are revoked instead. The
notice is the kit's credential-expiry latch, which latches only after
the Workshop has acknowledged, so a callback that fails transiently is
retried by the next refusal rather than silencing the account; every
grant write re-arms it.
Resources: URL patterns built from the configured instance and ordered
most-specific first, since :project+ also matches issue and MR URLs and
resolution is first-match, and accepting GitLab's tab suffixes
(/merge_requests/7/diffs) so such a URL is not mistaken for the whole
project; getGatekeeperClassFor parses nested namespaces via GitLab's /-/
separator and refuses an issue or MR route whose number is malformed
rather than falling through to the project. The instance URLs are
validated at use: https origins (http on loopback for a GitLab run
locally), since the API origin receives every token and the OAuth
exchanges the client secret; a path is refused rather than dropped,
because every URL here is built from the root, and dropped it would send
tokens to whatever answers there. Configurators mirror GitHub's three,
learning the instance origin from the ui capability; each carries its
own copy of the URL grammar (the configurator build strips runtime
imports), kept in step with parseResourceUrl by a round-trip test. The
observer probe requires current Reporter membership whatever the
project's visibility: a Guest can see a private project's issues but not
its repository, and a public project can still hold confidential issues
and internal notes that only members see -- so, unlike GitHub's public
repositories, visibility alone admits nobody. It reads the membership
from members/all?user_ids[]= (effective: inherited and shared-group
access folded in; a non-member is the documented empty list) rather than
the project's permissions object, which a spot check on the internal
instance showed reporting null for group-inherited access -- the access
~90% of a project's members hold there -- and denies a row whose
expires_at has arrived, since GitLab sweeps expired rows only daily.
Reads (gitlab-gatekeeper.ts): a TTL cache with a generation counter (no
ETags), queued-action overlays and provisional #~N / !~N references, the
diff_refs inversion isolated in one helper (GitLab's base_sha is the
merge base), merge request commits reversed to oldest-first, MR details
joined with /approvals -- left out, not empty, when GitLab will not show
them, since an empty list would say nobody approved. A sha GitLab did
not give (a listing row carries no diff_refs) is null, never an empty
string that reads as a commit id. Opening an issue or merge request
capability is not an observation: the caller learns only that the number
exists, and the stub's getDetails() records the read. A thread is read
whole from the discussions endpoint: the notes endpoint omits every
reply ("items of type DiscussionNote are not returned as part of the
Note API") and the discussions endpoint takes no order_by or since, so
there is nothing to sync incrementally against. Each discussion goes to
readDiscussion() or readDiffThreads() by its root note's position, so a
diff thread's replies stay with the thread whether or not GitLab repeats
the position on them. A multi-line diff comment's target takes both ends
from line_range -- the top-level line is where the note was left, which
GitLab does not keep equal to the range's end; a note on an image is a
comment on the file, since a point in a picture names no line. A patch's
final newline ends its last line rather than starting a blank one an
agent could comment on. Diff pages are cached under the merge request's
whole revision, not its head alone: GitLab recomputes the diff under an
unchanged head when the target branch moves. A comparison GitLab timed
out is refused rather than served: its diffs may be incomplete with
nothing to say so. Listings follow GitLab's x-next-page rather than
counting rows, since a page can be short mid-listing, and serve each
item once: a queued create applied while a cursor is drained would
otherwise list both its ~N and the real row. Each page a listing serves
advertises its commit ids through the kit's advertisePages, which counts
an id as advertised only once its advertisement has landed. Listings
check resultsPerPage once at the session boundary (a positive integer,
capped at one remote page), order a popularity listing by the upvotes it
carries for that purpose with GitLab's id-descending tie-break, and
inject a created branch under a search only where GitLab's own anchor
and wildcard rules would list it. Branch reads already overlay queued
pushes' simulated heads; the pending-chain simulation lands with push,
and with it the git-cache read of a commit GitLab does not have yet,
limited to what those pushes carry.
Tests: Node for normalization, URL parsing, the configurator/parser
round trip, instance-URL validation, the comparator and search rules,
the position mapping; workerd through a TestHooks facet against a GitLab
faked at fetch, which stands behind Access at an API origin of its own
and refuses a request sent anywhere else or without the service token
(refresh rotation and collapse, terminal vs transient failures and a
refused refresh token kept refused until a reconnect, the expiry notice
fenced to its token and latched only once heard, an overtaken reconnect
discarded, the under-scoped grant remedy, a disconnect racing the code
exchange, a reconnect racing the observer probe, resource ordering, the
roles and expiry table, and the reads' shapes, paging, and diff cache).
The Workshop callback is a WorkerEntrypoint stand-in, since only a
persistent stub survives the account's storage. Decorated
WorkerEntrypoints are reached through undecorated test subclasses: the
capnweb-validate vite plugin's in-memory transform hides them from
ctx.exports, where the production build does not.
* gatekeeper-gitlab: actions and their simulation
The write side of the GitLab gatekeeper (plans/gitlab-gatekeeper.md,
commit 5). Every non-git mutation of an issue or merge request is now a
queued action that reads back as if it had landed: create issue / merge
request (provisional ~N ids, referenced as #~N and !~N until the real
number is known), set title and description, add and remove labels,
close and reopen, comment, review, reply to and resolve diff threads,
and merge. Push follows in the next commit.
GitLab's shape shows in a few places. Assignees are usernames in the API
and ids on GitLab, so they resolve at prepare time and a typo fails now
rather than at apply; a merge request takes labels and assignees as an
issue does. Reads that only prepare an action record no observation
(#593): they show the caller nothing. Labels use add_labels and
remove_labels -- atomic deltas, so a label a human adds meanwhile is
never clobbered -- and revert undoes only what the action changed (a
label that was already there is not removed by reverting its addition).
Resolving a thread records the state the caller saw, queued resolutions
included, so reverting the resolve of an already-resolved thread leaves
it resolved. A merged merge request cannot be reopened, so prepare
refuses it. Draft status is GitLab's reading of the title, so a
provisional merge request reports the draft flag its title will earn
(one function for the title GitLab will receive, used by simulation and
apply alike) and a queued retitle recomputes it. Merge binds the source
head observed at queue time as sha -- one divergence from GitHub, where
only an agent-supplied expectedHeadSha is sent: an approval names a
state, and commits pushed between approval and apply must not merge
under it -- and maps GitLab's documented codes. 405 is also GitLab's
answer once the merge request is merged, as it is when an apply retries
a merge whose reply was lost, so it re-reads the merge request: merged
at the bound head is that merge and succeeds, and otherwise
detailed_merge_status names the reason, with CI's spelled out since
pipelines are not exposed. 409 means the head moved since the merge was
queued, 422 that the branch cannot be merged, 401 no permission.
applyAction is idempotent for every action type: an action already
recorded as applied reports success, since the overseer records
completion only after the reply and a lost one re-delivers the apply
(GitHub does this for push alone). Rejecting a create retires every
action that depends on the provisional -- one that targets it, and one
whose text names it as #~N / !~N, which apply could never rewrite; a
reference is a whole token, as the rewrite reads it, so #~10 does not
name ~1, and the text fields are the ones the rewrite reads, listed once
so the two cannot drift -- and recurses through a retired create, whose
own provisional will never exist either. The Workshop resolves only the
root of such a cascade, so the retired records' cards stay pending until
the user discards them: discarding an action already rejected succeeds,
and applying one explains that it, or something it depended on, was
discarded. Listings drop the rows already served as overlaid injected
items in the cursor's filter, after the remote page has been counted --
thinning the page first made a full page read short and ended the walk
early.
A review is GitLab's Draft Notes API composed into one action, and the
one action GitLab makes multi-call. A review was written against one
revision -- its comments are positioned in that diff, an approval means
that head -- so every attempt first reads the live merge request and
fails clean if the head has moved, before anything is posted. GitLab's
own approve carries the reviewed sha as a compare-and-swap (409 on a
stale head), but only on the attempt that runs it; a retry resumed after
a push would otherwise skip a recorded approval and publish comments
into a diff that no longer exists. approve runs first for an approve
decision, so its own refusals also land before anything is posted. Then
one draft per diff comment, published as the decision requires, then the
summary as an ordinary note.
bulk_publish is the only call that records a reviewer state, but it
publishes every draft the user has on the merge request, a human's
parked drafts included. comment and approve have no state to record (an
approval sets its own), so they publish their drafts one at a time and
never touch the user's -- at the cost of a grouped review: each comment
arrives as its own note, and a comment review does not mark the user as
having reviewed. requestChanges, whose reviewer state is the review,
bulk-publishes with reviewer_state requested_changes and refuses while
the user has drafts of their own there -- checked before its drafts are
created and again just before publishing; the round trip between that
read and the publish is the window GitLab's API leaves. Instances older
than 19.2 ignore reviewer_state without an error and would publish the
request as a plain comment, so there it is refused at queue time, as is
an empty comment or requestChanges review. GitLab does not paginate the
draft listing, so it is read in one request.
apply is offered a retry on failure and owes the queue idempotence, so
each step is recorded on the action record as it lands
(progress.{approval, comments, requestedChanges, summary}). A step
GitLab could carry out without the answer arriving is recorded as under
way first, and a retry asks GitLab what became of it before repeating
it: a draft whose creation went unanswered is adopted if an unclaimed
draft has its body and anchor; a recorded draft no longer listed was
published, or deleted by the user, who is taken at their word; a lost
summary is looked for among the account's newest notes. GitLab answers
401 both when the user may not approve and when they already have, so
the approvals are read first and the approval's provenance recorded. A
discard takes back what the review left unpublished before the record
is retired, so a cleanup that fails leaves the discard retryable: it
deletes the review's parked drafts, leaves alone an approval the account
held before the review, and withdraws the review's own only while GitLab
still dates it as the one the review made -- or cannot say, on an
instance that reports no approved_at.
Diff positions carry the inversion outbound (our mergeBaseSha is
GitLab's base_sha), both paths always, and follow GitLab's rule for
naming a line: an added line by new_line alone, a removed line by
old_line alone, and an unchanged line by both -- its number on each
side, which the hunk walk that also produces line_range's line_codes
supplies. Replies resolve their discussion from the note id through the
discussion list; the session refuses a reply to a provisional comment,
so no alias from provisional ids to published notes is kept (GitHub's
come free with the review POST; here they would cost a round trip).
Tests: workerd scenarios through the TestHooks facet against a GitLab
faked at fetch -- provisional create/read/apply/resolve, a created issue
served once by a listing opened before its create applied, reject
cascades by target, by whole-token textual reference and through a
dependent create, with the retired cards then discarding cleanly; each
mutation's simulation/apply/revert triple, a merge request's labels and
assignees, the draft flag under contradictory inputs and retitles, a
timed-out comparison refused, thread resolution and its revert, and the
merge error mapping, including a merge already landed at the bound head
and a head that cannot be determined. Reviews run against a stateful
fake of GitLab's review endpoints: all three decisions, every line
kind's position and a mixed-side range, the user's drafts left parked or
refused over (before creating, and one started meanwhile), each lost
reply -- draft, publish, bulk_publish, summary, approval -- repeated by
no retry, a moved head caught by the live read and by approve's own
409, a discard's approval and draft cleanup across provenances, and an
instance too old to request changes. Plus the session refusing to reply
to or resolve an unposted comment before preparing anything, an
already-applied action re-delivered, a touched issue on a full listing
page, a refused submission leaving no trace, and a node test pinning
the line-position walk.
* gatekeeper-gitlab: git pull, push, and the simulation of queued pushes
The worktree loop (plans/gitlab-gatekeeper.md, commit 6; plans/worktrees.md
§3 is the design record, applied verbatim). gitPull is smart-HTTP protocol
v2 through the kit's transport against the configured API origin with the
documented oauth2 basic-auth username; the gatekeeper contributes framing
and retains nothing. push binds {branch, expectedOldSha, newSha, force} at
queue time from the live branch head overlaid with earlier queued pushes
(so stacked pushes compose), refuses a non-fast-forward via
GitCache.isAncestor() before anything is queued (branch creation exempt),
declares pushedCommits, applies buildPack()'s bytes behind receive-pack's
compare-and-swap on the queue-time head, treats a CAS failure as success
iff the branch already sits at newSha, and reverts by ref rollback (or
deletion for a created branch) with an empty pack. GitLab's pre-receive
reason -- a protected branch, a push rule -- is passed through in the
error, never matched. The branch-head read that binds a push shows the
caller nothing, so it records no observation (#593).
Reads of a branch with queued pushes show the world as if they had
landed: listBranches injects created branches and overlays moved heads;
resolveRef/getCommit serve the simulated head from the cache (reported
fromCache, so sessions withhold the advertisement); listCommits injects the
locally filtered pending chain ahead of GitLab's history from the chain's
anchor; and a merge request whose source branch has queued pushes reads
its details, diff, commits, and merge base from a simulated comparison:
GitLab's compare(target, anchor) and /merge_base against the anchor,
spliced with the pending chain, and a local tree diff from the merge base
to the simulated head. GitLab has no tree-by-oid endpoint, so a tree the
cache lacks degrades the read to the un-simulated remote view (blobs are
fetched by sha). A full commit id GitLab does not know reads from the
cache only when the simulation stands behind it -- a commit it has
served, or one a queued push's chain reaches -- never merely because the
workspace cache holds it. Two gaps are known and recorded in the plan: the
pending chain follows first parents, where GitLab's history lists every
parent, so a local merge's side branch is missing from listings until its
push lands; and a queued push to a merge request's target branch is not
overlaid onto its comparison.
Rejecting a push retires what was stacked on it. The head it would have
left will never exist, unless another queued push to the branch leaves it
too, so a push bound to that head -- whose compare-and-swap could only
fail, blaming the branch for moving -- is retired and strands its own new
head in turn, and a merge bound to a stranded head goes as well (a merge
now records its source branch when that is in this project). Then a
queued merge request whose source or target branch no longer exists goes,
with everything queued on it. Only a rejection strands a head: a branch
moved by anyone else is the compare-and-swap's to report at apply.
Tests: 17 workerd push scenarios (CAS binding, stacking, no-op, non-ff
refusal, creation, simulated branches/refs/commits/history, a full commit
id served only when a queued push reaches it, a queued MR's diff/commits/
merge base from the pending chain, receive-pack framing with the pack's
bytes passed through untouched, moved-branch failure with GitLab's reason,
desired-state idempotency, rollback and deletion, reject cascades through
a created branch and through stacked pushes and merges, after which a
re-push lists the real history, upload-pack framing with no have lines),
and session-wiring scenarios asserting that every commit-id-bearing read
advertises, cache-served ones excepted, and that mutations, opening a
stub, push, and merge record no observation.
* gatekeeper-gitlab: dev credentials, AGENTS.md, and the skill's reference list
The repo plumbing around the new package: pnpm dev-server maps
GITLAB_CLIENT_ID/GITLAB_CLIENT_SECRET into the worker's CLIENT_ID/
CLIENT_SECRET like the other OAuth gatekeepers, and passes GITLAB_URL,
GITLAB_API_URL and the Access service-token pair through from the shell
or the root .dev.vars, so developing against a self-hosted instance
changes nothing tracked (the README says how); AGENTS.md gains a short
package bullet pointing at the plan and the README, the detail staying
with the code it describes; the write-gatekeeper skill lists the package
as the worked example of porting one gatekeeper's API to another
provider's vocabulary; and the plan's commit sequence is brought in line
with what landed (the kit's advertising hook, the file split, the
review's two publishing paths, the test files as named).
* gatekeeper-gitlab: OAuth callbacks relayed to Worker Previews through the kit
A Worker Preview's hostname cannot be listed on the GitLab application,
so a preview could never complete a connection: GitLab refuses an
authorize request whose redirect_uri it does not know. The kit's
preview-oauth module -- what gatekeeper-google already uses -- answers
that with one stable callback and a relay: a preview sends GitLab the
stable Worker's redirect_uri and a signed, short-lived state carrying
its own callback; the stable Worker's /oauth verifies the state, checks
the return host is one of its <preview>-<worker>.<workers.dev> names,
and forwards GitLab's answer (a code or an error) with the state intact;
the preview then exchanges the code with the redirect_uri it authorized
under, which the oauth-stage nonce now records for that purpose (RFC
6749 requires the exchange to repeat it exactly).
Production is unchanged: with none of OAUTH_ALLOW_PREVIEW_REDIRECTS,
OAUTH_REDIRECT_URI and OAUTH_STATE_SIGNING_SECRET set the kit runs
direct -- the Worker's own callback and the plain doId:nonce state, as
before. A half-configured preview fails at the connect link with the
kit's message rather than at GitLab's redirect check.
Previews do not inherit the stable Worker's secrets (they are seeded
from the Previews Base configuration), which the README now says
alongside the variables to set. Tests drive the default export's fetch
with a preview and a stable environment: the authorize URL names the
stable redirect and a signed state, the stable Worker relays both a
code and a refusal to the preview, the preview's exchange repeats the
stable redirect_uri, production stays direct, and a half-set preview
is refused.
The plan describes the relay in its OAuth flow, lists this commit in
its commit sequence, and records that gatekeeper-github has no relay
yet, so a GitHub connection cannot be completed on a preview.
* gatekeeper-gitlab: cursors on the kit's ArrayCursor, listings that lose and repeat no row
The cursor layer had drifted from the kit and from itself. ArrayCursor was
a copy of the kit's; it is now the kit's. StreamingCursor moved rows from
its buffer into the page before awaiting the next fetch, so a fetch that
threw lost them and the retry skipped past them. It now fetches until the
buffer holds a page and only then serves from it, and it serializes
next(), so two calls can neither serve a buffered row twice nor fetch one
page twice. Its options gain defaults, dropping the pass-through overlay,
filter and comparator that call sites spelled out.
Its dedupe recorded a row's identity when the row was served, so a
provisional ~N served before its create applied stayed recorded as ~N,
and the real row arriving on a later page was served again. The injected
rows already served are now re-keyed at every check; only an injected
row's identity can change, and there are few of them.
SessionGitCache takes the gatekeeper's withhold predicate -- the simulated
heads of queued pushes, which GitLab does not have yet -- and applies it
to every advertisement, where seven session call sites each repeated the
filter.
The kit's cursors module says why no class there carries @validateRpc().
plans/gatekeeper-kit.md documents PageHookCursor and the git-transport,
git-diff and git-objects leaves this branch moved into the kit, and its
deferred entries for the simulated-item cursor and git push simulation
now say what github and gitlab duplicate and that GitHub's side of each
divergence is the bug. The GitLab plan records GitHub's cursor losing
rows as a gap, and the re-keying.
Tests: a workerd listing that serves ~1 on its first page, applies its
create, then reads #77 on page 2 serves the issue once; before the fix it
served it twice.
* gatekeeper-gitlab: approval cards rendered from the staged payload
Each session wrote its own description string, and those strings left
out what the action would post: a comment, a description or a review was
approved without its Markdown being shown, against ActionDescription's
contract that it include everything relevant to the decision. GitHub has
rendered every card from the staged payload since #541 and #565.
describeGitLabAction (gitlab-descriptions.ts) mirrors describeGitHubAction
through the kit's buildDescription. The prose interpolates only the
project and logical ids, every agent-supplied value sits verbatim in a
field, and a note says when #~N / !~N references will be rewritten at
apply. A push never claims to be complete, since its commits cannot be
reviewed as text.
submitActionForApproval now takes only the title, implementsRevert and
pushedCommits from the session and renders the description itself, so
the text shown is always that of what applies. referenceBearingTexts'
doc says the card's rewrite note reads it too. The switches in
applyAction and revert end in `action satisfies never`, so a new action
type fails to compile rather than falling through. The plan's deferred
item asking for exactly this is removed.
Tests: the review card puts agent Markdown in fields and never in the
prose; the push card names the branch and both heads, warns of a force
push, has no current head when it creates the branch, and never claims
completeness; the merge card names the head it is bound to and every
option it sends; sessions pass no description of their own.
* gatekeeper-gitlab: credentials on the kit's CredentialCoordinator
UserAccount hand-rolled what the kit's CredentialCoordinator exists for --
a refresh mutex, a transient-failure cooldown, and grantId, credentialId
and deadGrantId fences -- and the plan deferred adopting it for reasons
that stopped holding once gatekeeper-github adopted it (#661). The grant
now lives in the coordinator, as GitHub's does:
- A refresh redeems the single-use refresh token once however many
callers wait, and stores the rotated pair before serving either.
invalid_grant in a 4xx other than 429 (the kit's isInvalidGrant rule)
is the grant's death, recorded with it and announced once through the
credential-expiry latch. Every other failure -- network, 429, 5xx
whatever its body, an Access redirect -- is transient, and #mintFailure
repeats it to a burst of callers for a minute.
- A refused token is judged against the token the request sent
(reportTokenRejected). One the account has since replaced failed stale:
a replayable read reruns once and any other call fails as retryable. A
refusal of the live token is refreshed past before the grant is called
dead. Permission 401s from merge and approve are still never reported.
- Connects and reconnects record the connection generation they began
under and commit through connect(grant, { ifGeneration }), so a
disconnect or a faster reconnect wins and the loser's tokens are
revoked. The observer probe's cached user id is fenced to the
generation.
- A refresh a disconnect overtook has the pair it minted revoked
(discardMint): the disconnect revoked only the pair it found, which the
refresh had already rotated out. One a reconnect overtook is dropped
unrevoked, since GitLab does not document that revoking one refresh
token spares the rest of the authorization.
- The stub-era keys migrate into the coordinator's record on first read;
a grant with no recorded scopes is still offered a reconnect.
The connect flow uses the kit's handshake (putInitiation, advanceToOAuth,
claimOAuth) and createPkce; /oauth answers 400 for a malformed state id
and consumes the nonce when GitLab refuses. The refresh request no longer
sends redirect_uri, which RFC 6749 does not define for it. Account reads,
the viewer read and the configurators go through withAccountApi as
replayable reads.
storage-schema.md, the README's troubleshooting, and the plan's credential
decisions, storage layout, OAuth helpers and deferred items describe the
coordinator; the deferred "adopt kit credentials" item is removed.
Tests: the account suite drives the real paths -- refresh and rotation,
one redemption for concurrent callers, death only on invalid_grant and
not in a 429 or 5xx body, a token replaced in flight replayed or failed
as retryable, a refused live token refreshed past, reconnects overtaken
or committed after a refresh, a refresh overtaken by a disconnect
(revoked) or a reconnect (not), and the stub-era migration. The observer
probe does not admit a user reconnected mid-read on the previous user's
membership, and the OAuth relay refuses a malformed state id and consumes
a refused attempt.
* gatekeeper-gitlab: quick actions posted as text, diffs over GitLab's limits refused, review fixes
Two review rounds found GitLab doing things on the agent's text, or on
its reads, that the approved action never said.
- GitLab runs a line that starts with /name as a quick action on the
issue or merge request it is posted to, with the user's full authority:
/approve publishes the user's parked drafts and approves with no head
bound, and /move and /clone take an issue and its thread out of the
bound project. #postedText, renamed from #rewriteKnownReferences
because every posted text already went through it, now also escapes
each such line with a backslash before the slash
(escapeQuickActions). That includes the \r GitLab deletes before
matching. Code blocks are escaped too, since what GitLab skips depends
on its version: current releases find paragraphs with a Markdown
pipeline, while 16.x uses one regex that recognises only column-one
``` fences. Simulated reads use the same text. The approval card says
the backslash is added and that it shows inside code blocks, and
types.d.ts tells agents the same.
- When a merge request is over GitLab's diff limits, GitLab stores only
the files collected before the limit, and nothing in them says the
rest exist. #getDiff refuses when changes_count reads "N+", as it
already refuses a timed-out compare. A diff simulated over queued
pushes is computed whole and still served.
- The listing filter runs on GitLab's own rows, so it now matches author
and assignee usernames without regard to case, as User.by_username
does. Before, a differently-cased username emptied the listing.
- A queued commit passes listCommits' author filter as git log --author
does: a case-sensitive substring of `Name <email>`.
- A first connect whose complete() throws now revokes the token pair it
leaves, not just clears it. It revokes the pair stored at that moment,
since the Workshop's describe() can make the account refresh past a
refused token during completion.
- A connect link whose id is not one of this Worker's accounts gets the
invalid-link page rather than a 500. accountFor() guards the lookup
for both the connect route and /oauth.
getProjectMember's doc now says what members/all answers: one row per
user (MembersFinder#distinct_on), the direct membership when there is
one, with the user's effective access level.
The plan records each decision. It moves quick actions out of Punted,
and adds to the gatekeeper-github follow-ups GitHub's 3000-file cap on
pulls/{n}/files and the same filter, connect and malformed-link gaps.
Tests: escapeQuickActions on what runs (including \r inside the name)
and what doesn't (paths, mid-line slashes, already-escaped lines). The
approval card warns of the backslash for a fenced /help. A queued
/clone comment reads and posts escaped. A "1000+" diff is refused, and
one simulated over a queued push is served. The filters match
mixed-case usernames, and the queued-commit author filter is a
case-sensitive substring. A failed first connect, whose completion
describes the account and rotates its grant, revokes the rotated pair;
the test swaps TestUser in for the account entrypoint the pool cannot
mint. A malformed connect link gets the invalid-link page.
* gatekeeper-gitlab: simplify pass -- shared SessionGitCache and capped reader, parallel reads
Kit:
- SessionGitCache moves into ./cursors, with withhold given once at construction; GitLab and
GitHub drop their copies (GitHub's seven per-call withhold closures collapse into one).
- readBytesCapped joins readTextCapped in ./response-body; git-transport's push report…
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.
Important
We are not generally accepting outside contributions. We may accept a small
change only when, in the maintainers' assessment, it is obviously correct and
trivially verifiable by reading the patch.
If you found a bug, please file an issue
first. For feature requests and other proposals, please
open a discussion.
What does this change?
Fixes #652.
Three assertions in
scripts/build-bundled-blueprints.test.tsspell paths with/, which fails on Windows: Node stores a symlink target as..\NOTES.md, and the importer reports ignored paths throughpath.relative(), which uses\. The assertions now compare against the platform form,normalize("../NOTES.md")andexample[\\/]blueprint\.json.Why is this obviously correct and trivially verifiable?
Test-only. On POSIX
normalize("../NOTES.md")is"../NOTES.md"and[\\/]still matches/, so the expectations there are unchanged. Run on Windows 11 and on Ubuntu (WSL), both on Node 24.18.0: the file passes 13/13 on both, against 10/13 on Windows before.Checklist
Checking every item does not guarantee acceptance. Maintainers determine whether
a pull request meets the contribution policy.