mirror of
https://github.com/garrytan/gstack.git
synced 2026-08-29 09:20:39 +02:00
v1.68.3.0 fix(pairing): re-pair to narrow revokes the old grant on the spot (#2665)
* fix(pairing): reject reserved clientId 'root' at all token writers 'root' is the sentinel checkScope/checkDomain/checkRate and the server command gate use for the omnipotent caller, so a scoped token carrying it bypasses every enforcement path. Add ReservedClientIdError + a shared assertValidClientId; createToken/createSetupKey throw, restoreRegistry skips-and-logs (a corrupt state file must not brick boot). /pair and /token surface it as a named 400, and the CLI fast-fails --client root. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pairing): release tab ownership on revoke tabOwnership cleared only on tab close, so after DELETE /token a same-name re-pair inherited the revoked agent's authenticated tabs (own-only access keys on owner === clientId). Add BrowserManager.releaseClientTabs and run it unconditionally in DELETE /token (ownership outlives the token, so an expired-token client can still own tabs); 404 only when both nothing was revoked and nothing released. Response now carries tabs_released. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * v1.68.3.0 fix(pairing): re-pair to narrow revokes the old grant on the spot POST /pair minted a new setup key but never touched the agent's live session, so re-pairing --client X --restrict read while X was connected (or whose 5-min key expired unexchanged) left the original full-access session, eval included, alive up to 24h. A reducing re-pair (fewer scopes, tighter domains, lower rate, stricter tab policy) now revokes the live session and releases its tabs before minting the new key (grantReducesAccess + revokeClientFully; superseded in the response). Non-reducing re-pairs keep the session and only drop stale PENDING setup keys, so a broaden/refresh never strands a working agent and a narrowing re-pair issued before the agent connects can't leave the old broad key exchangeable. Revoke happens before mint (revokeToken deletes all of a client's tokens). CLI prints a version-skew-safe supersede notice and warns when a re-pair-shaped call omits --client. Docs + CHANGELOG + VERSION. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(pairing): harden re-pair per adversarial review Adversarial review of the diff found four issues, now fixed: - Validate the requested grant BEFORE the supersede revoke: a reducing re-pair with a bad scope/rate no longer destroys the live session and then fails to mint a replacement (assertValidTokenOptions runs up front). - A re-pair with no live session releases tabs orphaned by an expired incarnation, closing the tab-inheritance gap /pair had (DELETE /token already released unconditionally). - Test the DELETE /token revoked=0/tabs>0 path and the /pair orphaned-tab release at the handler level (HTTP e2e can't, headless owns no tabs). - Test the CLI --client root fast-fail; fix its null-guard (parseFlag returns null when --client is absent). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Garry Tan <garry@ycombinator.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
Garry Tan
parent
51932eceef
commit
85fd9db554
@@ -370,6 +370,41 @@ describe('buildFetchHandler factory contract', () => {
|
||||
initRegistry('first-token-pad-to-16-chars');
|
||||
expect(() => initRegistry('second-token-pad-to-16-chars')).toThrow(/already initialized/i);
|
||||
});
|
||||
|
||||
// D3 handler gating: tab ownership outlives token expiry, so DELETE /token
|
||||
// must release tabs and 200 even when no token remains (revoked=0, tabs>0).
|
||||
// Drives the handler into that exact state — HTTP e2e can't (headless-skip
|
||||
// owns no real tabs), so the revoke-gated-release regression would pass there.
|
||||
test('13. DELETE /token releases orphaned tabs and 200s when no token remains', async () => {
|
||||
const cfg = makeMinimalConfig();
|
||||
(cfg.browserManager as any).tabOwnership.set(7, 'ghost'); // owned, no token
|
||||
const handle = buildFetchHandler(cfg);
|
||||
const req = new Request('http://127.0.0.1/token/ghost', {
|
||||
method: 'DELETE', headers: { Authorization: `Bearer ${cfg.authToken}` },
|
||||
});
|
||||
const resp = await handle.fetchLocal(req, null);
|
||||
expect(resp.status).toBe(200);
|
||||
const body = await resp.json() as { tokens_deleted: number; tabs_released: number };
|
||||
expect(body.tokens_deleted).toBe(0);
|
||||
expect(body.tabs_released).toBe(1);
|
||||
expect(cfg.browserManager.getTabOwner(7)).toBeNull();
|
||||
});
|
||||
|
||||
// D1 F2: a re-pair with no LIVE session must release tabs orphaned by an
|
||||
// expired incarnation, or the new session inherits its authenticated pages.
|
||||
test('14. /pair releases tabs orphaned by an expired session (no inheritance)', async () => {
|
||||
const cfg = makeMinimalConfig();
|
||||
(cfg.browserManager as any).tabOwnership.set(9, 'ghost'); // orphaned, no live session
|
||||
const handle = buildFetchHandler(cfg);
|
||||
const req = new Request('http://127.0.0.1/pair', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${cfg.authToken}`, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ clientId: 'ghost', scopes: ['read'] }),
|
||||
});
|
||||
const resp = await handle.fetchLocal(req, null);
|
||||
expect(resp.status).toBe(200);
|
||||
expect(cfg.browserManager.getTabOwner(9)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Idle timer + onDisconnect dual-instance fix (v1.42.3.0) ──────────
|
||||
|
||||
Reference in New Issue
Block a user