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
@@ -291,6 +291,135 @@ describe('pair-agent flow end-to-end (HTTP only, no ngrok)', () => {
|
||||
expect(body.hint).not.toContain('--admin');
|
||||
});
|
||||
|
||||
// ─── D2: reserved clientId is rejected with a named 400 ───────────────
|
||||
|
||||
test('POST /pair with clientId "root" returns 400 naming the reservation', async () => {
|
||||
const resp = await fetch(`${daemon.baseUrl}/pair`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
|
||||
body: JSON.stringify({ clientId: 'root' }),
|
||||
});
|
||||
expect(resp.status).toBe(400);
|
||||
const body = await resp.json() as any;
|
||||
// The reservation is named, NOT hidden behind the generic "Invalid request body".
|
||||
expect(body.error).toContain('root');
|
||||
expect(body.error).not.toBe('Invalid request body');
|
||||
});
|
||||
|
||||
test('POST /token with clientId "root" returns 400 naming the reservation', async () => {
|
||||
const resp = await fetch(`${daemon.baseUrl}/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
|
||||
body: JSON.stringify({ clientId: 'root' }),
|
||||
});
|
||||
expect(resp.status).toBe(400);
|
||||
const body = await resp.json() as any;
|
||||
expect(body.error).toContain('root');
|
||||
expect(body.error).not.toBe('Invalid request body');
|
||||
});
|
||||
|
||||
// ─── D1: a reducing re-pair supersedes the prior grant immediately ────
|
||||
|
||||
const pairAs = async (body: any) => (await (await fetch(`${daemon.baseUrl}/pair`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
|
||||
body: JSON.stringify(body),
|
||||
})).json()) as any;
|
||||
const connectKey = async (setup_key: string) => {
|
||||
const r = await fetch(`${daemon.baseUrl}/connect`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ setup_key }),
|
||||
});
|
||||
return { status: r.status, body: await r.json().catch(() => ({})) as any };
|
||||
};
|
||||
const statusWith = (token: string) => fetch(`${daemon.baseUrl}/command`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify({ command: 'status', args: [] }),
|
||||
});
|
||||
|
||||
test('reducing re-pair revokes the prior session immediately, without exchanging the new key', async () => {
|
||||
const { setup_key: k1 } = await pairAs({ clientId: 'reduce-me' }); // broad
|
||||
const { body: c1 } = await connectKey(k1);
|
||||
const s1 = c1.token as string;
|
||||
expect((await statusWith(s1)).status).not.toBe(401); // works
|
||||
// Narrow WITHOUT exchanging the new key — this is the whole bug.
|
||||
const rp = await pairAs({ clientId: 'reduce-me', scopes: ['read'] });
|
||||
expect(rp.superseded?.tokens_deleted).toBeGreaterThanOrEqual(1);
|
||||
expect((await statusWith(s1)).status).toBe(401); // old session revoked
|
||||
// The new narrow key still works and yields the reduced scope.
|
||||
const c2 = await connectKey(rp.setup_key);
|
||||
expect(c2.status).toBe(200);
|
||||
expect(c2.body.scopes).toEqual(['read']);
|
||||
expect((await statusWith(c2.body.token)).status).not.toBe(401);
|
||||
});
|
||||
|
||||
test('reducing re-pair BEFORE connect kills the stale broad setup key; only the narrow key works', async () => {
|
||||
const { setup_key: broad } = await pairAs({ clientId: 'shadow' }); // never connected
|
||||
const rp = await pairAs({ clientId: 'shadow', scopes: ['read'] }); // narrowing re-pair
|
||||
expect(rp.superseded).toBeUndefined(); // no live session existed
|
||||
expect((await connectKey(broad)).status).toBe(401); // stale broad key dead
|
||||
const c = await connectKey(rp.setup_key);
|
||||
expect(c.status).toBe(200);
|
||||
expect(c.body.scopes).toEqual(['read']); // narrow key survives
|
||||
});
|
||||
|
||||
test('broadening re-pair does NOT revoke the working session (no outage)', async () => {
|
||||
const first = await pairAs({ clientId: 'broaden', scopes: ['read'] });
|
||||
expect(first.superseded).toBeUndefined(); // first pair supersedes nothing
|
||||
const { body: c } = await connectKey(first.setup_key);
|
||||
const s = c.token as string;
|
||||
expect((await statusWith(s)).status).not.toBe(401);
|
||||
const rp = await pairAs({ clientId: 'broaden', scopes: ['read', 'write'] }); // broaden
|
||||
expect(rp.superseded).toBeUndefined(); // session not superseded
|
||||
expect((await statusWith(s)).status).not.toBe(401); // still working
|
||||
});
|
||||
|
||||
test('a reducing re-pair with an INVALID scope 400s and leaves the live session intact', async () => {
|
||||
// Regression: the supersede revoke must run AFTER validation. A scope typo
|
||||
// (--restrict red) on a narrowing re-pair must not destroy the session and
|
||||
// then fail to mint a replacement — the agent would be knocked offline.
|
||||
const { setup_key: k } = await pairAs({ clientId: 'validate-me' });
|
||||
const { body: c } = await connectKey(k);
|
||||
const s = c.token as string;
|
||||
expect((await statusWith(s)).status).not.toBe(401);
|
||||
const resp = await fetch(`${daemon.baseUrl}/pair`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
|
||||
body: JSON.stringify({ clientId: 'validate-me', scopes: ['red'] }),
|
||||
});
|
||||
expect(resp.status).toBe(400);
|
||||
expect((await resp.json() as any).error).toContain('red');
|
||||
// The working session survives the validation error (not revoked).
|
||||
expect((await statusWith(s)).status).not.toBe(401);
|
||||
});
|
||||
|
||||
// ─── D3: DELETE /token releases tabs unconditionally; 404 only when empty ─
|
||||
|
||||
test('DELETE /token returns tabs_released and 404 only when nothing to revoke or release', async () => {
|
||||
const pairResp = await fetch(`${daemon.baseUrl}/pair`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${daemon.token}` },
|
||||
body: JSON.stringify({ clientId: 'd3-agent' }),
|
||||
});
|
||||
const { setup_key } = await pairResp.json() as any;
|
||||
await fetch(`${daemon.baseUrl}/connect`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ setup_key }),
|
||||
});
|
||||
const del = await fetch(`${daemon.baseUrl}/token/d3-agent`, {
|
||||
method: 'DELETE', headers: { Authorization: `Bearer ${daemon.token}` },
|
||||
});
|
||||
expect(del.status).toBe(200);
|
||||
const body = await del.json() as any;
|
||||
expect(body.tokens_deleted).toBeGreaterThanOrEqual(1);
|
||||
// Headless-skip daemon owns no real tabs, but the field is always present.
|
||||
expect(body.tabs_released).toBe(0);
|
||||
// Nothing to revoke AND nothing to release → 404.
|
||||
const del2 = await fetch(`${daemon.baseUrl}/token/nonexistent-xyz`, {
|
||||
method: 'DELETE', headers: { Authorization: `Bearer ${daemon.token}` },
|
||||
});
|
||||
expect(del2.status).toBe(404);
|
||||
});
|
||||
|
||||
// ─── 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 () => {
|
||||
|
||||
@@ -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) ──────────
|
||||
|
||||
@@ -99,6 +99,35 @@ describe('Tab Isolation', () => {
|
||||
expect(() => bm.transferTab(999, 'agent-1')).toThrow('Tab 999 not found');
|
||||
});
|
||||
});
|
||||
|
||||
// D3: revocation must release tab ownership, or a same-name re-pair inherits
|
||||
// the revoked agent's authenticated tabs (own-only access keys on
|
||||
// owner === clientId). Prime the ownership map directly — newTab needs a
|
||||
// real browser (see the file header note on private-map injection).
|
||||
describe('releaseClientTabs (D3)', () => {
|
||||
it('deletes only the target client\'s ownership and returns the released ids', () => {
|
||||
const own = (bm as any).tabOwnership as Map<number, string>;
|
||||
own.set(1, 'codex'); own.set(2, 'codex'); own.set(3, 'other');
|
||||
const released = bm.releaseClientTabs('codex').sort((a, b) => a - b);
|
||||
expect(released).toEqual([1, 2]);
|
||||
expect(bm.getTabOwner(1)).toBeNull();
|
||||
expect(bm.getTabOwner(2)).toBeNull();
|
||||
expect(bm.getTabOwner(3)).toBe('other');
|
||||
});
|
||||
|
||||
it('after release, own-only access to the freed tab is denied even for the same name', () => {
|
||||
const own = (bm as any).tabOwnership as Map<number, string>;
|
||||
own.set(1, 'codex');
|
||||
expect(bm.checkTabAccess(1, 'codex', { ownOnly: true })).toBe(true); // owns it
|
||||
bm.releaseClientTabs('codex');
|
||||
// Ownership gone → a re-paired 'codex' can no longer read/write tab 1.
|
||||
expect(bm.checkTabAccess(1, 'codex', { ownOnly: true })).toBe(false);
|
||||
});
|
||||
|
||||
it('is a no-op on a client that owns nothing', () => {
|
||||
expect(bm.releaseClientTabs('nobody')).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Test the instruction block generator
|
||||
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
revokeToken, rotateRoot, listTokens, recordCommand,
|
||||
serializeRegistry, restoreRegistry, checkConnectRateLimit,
|
||||
SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN, SCOPE_CONTROL, SCOPE_META,
|
||||
DEFAULT_PAIR_SCOPES, InvalidScopeError,
|
||||
DEFAULT_PAIR_SCOPES, InvalidScopeError, ReservedClientIdError,
|
||||
revokeSetupKeys, getClientSession, grantReducesAccess,
|
||||
type TokenInfo, type ResolvedGrant,
|
||||
__resetRegistry,
|
||||
} from '../src/token-registry';
|
||||
|
||||
@@ -19,6 +21,92 @@ describe('token-registry', () => {
|
||||
initRegistry('root-token-for-tests');
|
||||
});
|
||||
|
||||
// D2: `root` is the sentinel checkScope/checkDomain/checkRate use for the
|
||||
// omnipotent caller; a scoped token carrying it bypasses all enforcement.
|
||||
describe('reserved clientId (D2)', () => {
|
||||
it('createToken rejects clientId "root" and lookalikes', () => {
|
||||
expect(() => createToken({ clientId: 'root' })).toThrow(ReservedClientIdError);
|
||||
expect(() => createToken({ clientId: 'ROOT' })).toThrow(ReservedClientIdError);
|
||||
expect(() => createToken({ clientId: ' root ' })).toThrow(ReservedClientIdError);
|
||||
});
|
||||
|
||||
it('createToken rejects empty / whitespace clientId', () => {
|
||||
expect(() => createToken({ clientId: '' })).toThrow(ReservedClientIdError);
|
||||
expect(() => createToken({ clientId: ' ' })).toThrow(ReservedClientIdError);
|
||||
});
|
||||
|
||||
it('createSetupKey rejects clientId "root" but allows an omitted one', () => {
|
||||
expect(() => createSetupKey({ clientId: 'root' })).toThrow(ReservedClientIdError);
|
||||
// Omitted clientId gets a safe generated default, not a throw.
|
||||
const key = createSetupKey({});
|
||||
expect(key.clientId.startsWith('remote-')).toBe(true);
|
||||
});
|
||||
|
||||
it('revokeSetupKeys drops only PENDING keys, keeping the spent key and the session', () => {
|
||||
const k1 = createSetupKey({ clientId: 'x' }); // pending
|
||||
exchangeSetupKey(k1.token); // k1 now spent + a session exists
|
||||
createSetupKey({ clientId: 'x' }); // pending k2
|
||||
expect(revokeSetupKeys('x')).toBe(1); // only the pending k2
|
||||
expect(getClientSession('x')).not.toBeNull(); // session kept
|
||||
// The spent key survives for idempotent re-exchange (#2646).
|
||||
expect(exchangeSetupKey(k1.token)).not.toBeNull();
|
||||
});
|
||||
|
||||
it('restoreRegistry skips a persisted "root" entry instead of injecting a bypass token', () => {
|
||||
restoreRegistry({ agents: {
|
||||
root: { token: 'gsk_sess_evil', type: 'session', scopes: ['read', 'write', 'admin', 'meta', 'control'], tabPolicy: 'shared', rateLimit: 0, expiresAt: null, createdAt: new Date().toISOString() } as any,
|
||||
good: { token: 'gsk_sess_good', type: 'session', scopes: ['read'], tabPolicy: 'own-only', rateLimit: 10, expiresAt: null, createdAt: new Date().toISOString() } as any,
|
||||
} });
|
||||
// The evil root entry is dropped; the valid one still restores.
|
||||
expect(validateToken('gsk_sess_evil')).toBeNull();
|
||||
const good = validateToken('gsk_sess_good');
|
||||
expect(good?.clientId).toBe('good');
|
||||
});
|
||||
});
|
||||
|
||||
// D1: drives the /pair supersede decision. Direction matters — dropping an
|
||||
// allowlisted domain is the reduction, not adding one; 0 = unlimited rate.
|
||||
describe('grantReducesAccess (D1)', () => {
|
||||
const prior = (o: Partial<TokenInfo> = {}): TokenInfo => ({
|
||||
token: 't', clientId: 'c', type: 'session',
|
||||
scopes: ['read', 'write', 'admin', 'meta'], tabPolicy: 'own-only',
|
||||
rateLimit: 10, expiresAt: null, createdAt: '', commandCount: 0, ...o,
|
||||
});
|
||||
const grant = (o: Partial<ResolvedGrant> = {}): ResolvedGrant => ({
|
||||
scopes: ['read', 'write', 'admin', 'meta'], rateLimit: 10, tabPolicy: 'own-only', ...o,
|
||||
});
|
||||
|
||||
it('scopes: drop → reduce; add/equal → not; dropping control → reduce', () => {
|
||||
expect(grantReducesAccess(prior({ scopes: ['read', 'write', 'admin', 'meta'] }), grant({ scopes: ['read'] }))).toBe(true);
|
||||
expect(grantReducesAccess(prior({ scopes: ['read'] }), grant({ scopes: ['read', 'write'] }))).toBe(false);
|
||||
expect(grantReducesAccess(prior({ scopes: ['read'] }), grant({ scopes: ['read'] }))).toBe(false);
|
||||
expect(grantReducesAccess(prior({ scopes: ['read', 'control'] }), grant({ scopes: ['read'] }))).toBe(true);
|
||||
});
|
||||
|
||||
it('domains: drop → reduce; add/equal → not; unrestricted→restricted → reduce; glob narrowing → reduce', () => {
|
||||
expect(grantReducesAccess(prior({ domains: ['a.com', 'b.com'] }), grant({ domains: ['a.com'] }))).toBe(true);
|
||||
expect(grantReducesAccess(prior({ domains: ['a.com'] }), grant({ domains: ['a.com', 'b.com'] }))).toBe(false);
|
||||
expect(grantReducesAccess(prior({ domains: ['a.com'] }), grant({ domains: ['a.com'] }))).toBe(false);
|
||||
expect(grantReducesAccess(prior({ domains: undefined }), grant({ domains: ['a.com'] }))).toBe(true);
|
||||
expect(grantReducesAccess(prior({ domains: ['a.com'] }), grant({ domains: undefined }))).toBe(false);
|
||||
expect(grantReducesAccess(prior({ domains: ['*.example.com'] }), grant({ domains: ['*.com'] }))).toBe(false); // widen
|
||||
expect(grantReducesAccess(prior({ domains: ['*.com'] }), grant({ domains: ['*.example.com'] }))).toBe(true); // narrow
|
||||
});
|
||||
|
||||
it('rate (0 = unlimited): unlimited→capped → reduce; lower cap → reduce; higher/equal → not', () => {
|
||||
expect(grantReducesAccess(prior({ rateLimit: 0 }), grant({ rateLimit: 10 }))).toBe(true);
|
||||
expect(grantReducesAccess(prior({ rateLimit: 10 }), grant({ rateLimit: 5 }))).toBe(true);
|
||||
expect(grantReducesAccess(prior({ rateLimit: 5 }), grant({ rateLimit: 10 }))).toBe(false);
|
||||
expect(grantReducesAccess(prior({ rateLimit: 10 }), grant({ rateLimit: 10 }))).toBe(false);
|
||||
expect(grantReducesAccess(prior({ rateLimit: 10 }), grant({ rateLimit: 0 }))).toBe(false); // → unlimited = broaden
|
||||
});
|
||||
|
||||
it('tabPolicy: shared → own-only → reduce; the reverse → not', () => {
|
||||
expect(grantReducesAccess(prior({ tabPolicy: 'shared' }), grant({ tabPolicy: 'own-only' }))).toBe(true);
|
||||
expect(grantReducesAccess(prior({ tabPolicy: 'own-only' }), grant({ tabPolicy: 'shared' }))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('root token', () => {
|
||||
it('identifies root token correctly', () => {
|
||||
expect(isRootToken('root-token-for-tests')).toBe(true);
|
||||
|
||||
@@ -155,6 +155,21 @@ describe('pair-agent scope-flag validation (pre-server)', () => {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
test('--client root → exit 1 reserved-name error, NO daemon spawned', async () => {
|
||||
// `root` is the sentinel that bypasses every scope/domain/rate/tab check;
|
||||
// the CLI must reject it pre-server (the daemon also 400s it as defense-in-depth).
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'browse-client-root-'));
|
||||
const stateFile = path.join(tmpDir, 'browse.json');
|
||||
try {
|
||||
const result = await runCli(['pair-agent', '--client', 'root'], baseEnv(stateFile));
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.stderr).toContain('reserved');
|
||||
expect(fs.existsSync(stateFile)).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('tunnel against no daemon (#2254 — never boot one)', () => {
|
||||
|
||||
Reference in New Issue
Block a user