mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-25 06:11:02 +02:00
fix(browse): CLI always sends explicit pair scopes via shared DEFAULT_PAIR_SCOPES
The effective pairing default lived in two places: the CLI omitted scopes unless --restrict was passed, and the server filled in its own literal. handlePairAgent now always sends an explicit scopes list and both sides reference one exported constant, DEFAULT_PAIR_SCOPES, so the default cannot silently drift again (pinned by a server-auth source tripwire). Three input traps closed in the same surface: - Bare --restrict (or --restrict swallowing the next flag) parsed as "no restriction" and silently granted FULL access, the opposite of the user's intent. validatePairAgentFlags rejects it pre-server, before any consent gate, so an arg error never boots a daemon. - A scopes list could smuggle the control scope past the explicit flag: --restrict "read,control" minted a control-scoped session with no --control. /pair now 400s on control in a scopes list without the control flag, and the CLI points the user at --control. - Option typos validated only at exchange time: createSetupKey stored any scope string and any rateLimit, so /pair returned 200 with a poisoned setup key whose failure surfaced to the REMOTE agent at /connect as a misleading "Invalid request body". Shared validation now runs in both creators and throws typed InvalidScopeError; /pair and /token 400 with the message, naming the bad scope or negative rateLimit. Also `opts.rateLimit || 10` became `?? 10` so the documented "0 = unlimited" survives the /pair path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
f28bfd0158
commit
44e0b6f7be
@@ -194,6 +194,76 @@ describe('pair-agent flow end-to-end (HTTP only, no ngrok)', () => {
|
||||
expect(Array.isArray(scopes)).toBe(true);
|
||||
});
|
||||
|
||||
// ─── Pair scope contract: defaults, explicit lists, typo naming ───────
|
||||
|
||||
test('default /connect scopes are exactly read,write,admin,meta', async () => {
|
||||
const pairResp = await fetch(`${daemon.baseUrl}/pair`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
|
||||
body: JSON.stringify({ clientId: 'default-scopes' }),
|
||||
});
|
||||
const { setup_key, scopes: pairScopes } = await pairResp.json() as any;
|
||||
expect(pairScopes).toEqual(['read', 'write', 'admin', 'meta']);
|
||||
const connectResp = await fetch(`${daemon.baseUrl}/connect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ setup_key }),
|
||||
});
|
||||
const { scopes } = await connectResp.json() as any;
|
||||
expect(scopes).toEqual(['read', 'write', 'admin', 'meta']);
|
||||
});
|
||||
|
||||
test('explicit scopes are honored end-to-end (the --restrict wire contract)', async () => {
|
||||
const pairResp = await fetch(`${daemon.baseUrl}/pair`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
|
||||
body: JSON.stringify({ clientId: 'restricted-scopes', scopes: ['read'] }),
|
||||
});
|
||||
const { setup_key } = await pairResp.json() as any;
|
||||
const connectResp = await fetch(`${daemon.baseUrl}/connect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ setup_key }),
|
||||
});
|
||||
const { scopes } = await connectResp.json() as any;
|
||||
expect(scopes).toEqual(['read']);
|
||||
});
|
||||
|
||||
test('POST /pair with a scope typo fails fast, naming the scope', async () => {
|
||||
// Regression: pre-fix this returned 200 with a poisoned setup key whose
|
||||
// failure surfaced at /connect as a misleading "Invalid request body".
|
||||
const resp = await fetch(`${daemon.baseUrl}/pair`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
|
||||
body: JSON.stringify({ clientId: 'typo-agent', scopes: ['raed'] }),
|
||||
});
|
||||
expect(resp.status).toBe(400);
|
||||
const body = await resp.json() as any;
|
||||
expect(body.error).toContain('Invalid scope: raed');
|
||||
});
|
||||
|
||||
test('POST /token with a scope typo names the scope too', async () => {
|
||||
const resp = await fetch(`${daemon.baseUrl}/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
|
||||
body: JSON.stringify({ clientId: 'typo-token', scopes: ['wirte'] }),
|
||||
});
|
||||
expect(resp.status).toBe(400);
|
||||
const body = await resp.json() as any;
|
||||
expect(body.error).toContain('Invalid scope: wirte');
|
||||
});
|
||||
|
||||
test('control cannot ride in through a /pair scopes list without the control flag', async () => {
|
||||
const resp = await fetch(`${daemon.baseUrl}/pair`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
|
||||
body: JSON.stringify({ clientId: 'sneaky', scopes: ['read', 'control'] }),
|
||||
});
|
||||
expect(resp.status).toBe(400);
|
||||
const body = await resp.json() as any;
|
||||
expect(body.error).toContain('control');
|
||||
});
|
||||
|
||||
// ─── Revocation e2e: revoke-all + the /agents verification surface ────
|
||||
|
||||
test('DELETE /token revokes session AND setup keys; agent leaves /agents; token 401s; re-connect fails', async () => {
|
||||
|
||||
Reference in New Issue
Block a user