mirror of https://github.com/garrytan/gstack.git
feat: tab enforcement + POST /pair endpoint + activity attribution
Server-side tab ownership check blocks scoped agents from writing to unowned tabs. Special-case newtab records ownership for scoped tokens. POST /pair endpoint creates setup keys for the pairing ceremony. Activity events now include clientId for attribution. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8fa3d7b06d
commit
eb6f57239b
|
|
@ -31,6 +31,7 @@ export interface ActivityEntry {
|
||||||
result?: string;
|
result?: string;
|
||||||
tabs?: number;
|
tabs?: number;
|
||||||
mode?: string;
|
mode?: string;
|
||||||
|
clientId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Buffer & Subscribers ───────────────────────────────────────
|
// ─── Buffer & Subscribers ───────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -870,6 +870,33 @@ async function handleCommand(body: any, tokenInfo?: TokenInfo | null): Promise<R
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Tab ownership check (for scoped tokens) ──────────────
|
||||||
|
if (tokenInfo && tokenInfo.clientId !== 'root' && WRITE_COMMANDS.has(command)) {
|
||||||
|
const targetTab = tabId ?? browserManager.getActiveTabId();
|
||||||
|
if (!browserManager.checkTabAccess(targetTab, tokenInfo.clientId, true)) {
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
error: 'Tab not owned by your agent. Use newtab to create your own tab.',
|
||||||
|
hint: `Tab ${targetTab} is owned by ${browserManager.getTabOwner(targetTab) || 'root'}. Your agent: ${tokenInfo.clientId}.`,
|
||||||
|
}), {
|
||||||
|
status: 403,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── newtab with ownership for scoped tokens ──────────────
|
||||||
|
if (command === 'newtab' && tokenInfo && tokenInfo.clientId !== 'root') {
|
||||||
|
const newId = await browserManager.newTab(args[0] || undefined, tokenInfo.clientId);
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
tabId: newId,
|
||||||
|
owner: tokenInfo.clientId,
|
||||||
|
hint: 'Include "tabId": ' + newId + ' in subsequent commands to target this tab.',
|
||||||
|
}), {
|
||||||
|
status: 200,
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Block mutation commands while watching (read-only observation mode)
|
// Block mutation commands while watching (read-only observation mode)
|
||||||
if (browserManager.isWatching() && WRITE_COMMANDS.has(command)) {
|
if (browserManager.isWatching() && WRITE_COMMANDS.has(command)) {
|
||||||
return new Response(JSON.stringify({
|
return new Response(JSON.stringify({
|
||||||
|
|
@ -889,6 +916,7 @@ async function handleCommand(body: any, tokenInfo?: TokenInfo | null): Promise<R
|
||||||
url: browserManager.getCurrentUrl(),
|
url: browserManager.getCurrentUrl(),
|
||||||
tabs: browserManager.getTabCount(),
|
tabs: browserManager.getTabCount(),
|
||||||
mode: browserManager.getConnectionMode(),
|
mode: browserManager.getConnectionMode(),
|
||||||
|
clientId: tokenInfo?.clientId,
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -946,6 +974,7 @@ async function handleCommand(body: any, tokenInfo?: TokenInfo | null): Promise<R
|
||||||
result: result,
|
result: result,
|
||||||
tabs: browserManager.getTabCount(),
|
tabs: browserManager.getTabCount(),
|
||||||
mode: browserManager.getConnectionMode(),
|
mode: browserManager.getConnectionMode(),
|
||||||
|
clientId: tokenInfo?.clientId,
|
||||||
});
|
});
|
||||||
|
|
||||||
browserManager.resetFailures();
|
browserManager.resetFailures();
|
||||||
|
|
@ -978,6 +1007,7 @@ async function handleCommand(body: any, tokenInfo?: TokenInfo | null): Promise<R
|
||||||
error: err.message,
|
error: err.message,
|
||||||
tabs: browserManager.getTabCount(),
|
tabs: browserManager.getTabCount(),
|
||||||
mode: browserManager.getConnectionMode(),
|
mode: browserManager.getConnectionMode(),
|
||||||
|
clientId: tokenInfo?.clientId,
|
||||||
});
|
});
|
||||||
|
|
||||||
browserManager.incrementFailures();
|
browserManager.incrementFailures();
|
||||||
|
|
@ -1297,6 +1327,38 @@ async function start() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── /pair — create setup key for pair-agent ceremony (root-only) ───
|
||||||
|
if (url.pathname === '/pair' && req.method === 'POST') {
|
||||||
|
if (!isRootRequest(req)) {
|
||||||
|
return new Response(JSON.stringify({ error: 'Root token required' }), {
|
||||||
|
status: 403, headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const pairBody = await req.json() as any;
|
||||||
|
const scopes = pairBody.admin
|
||||||
|
? ['read', 'write', 'admin', 'meta'] as const
|
||||||
|
: (pairBody.scopes || ['read', 'write']) as const;
|
||||||
|
const setupKey = createSetupKey({
|
||||||
|
clientId: pairBody.clientId,
|
||||||
|
scopes: [...scopes],
|
||||||
|
domains: pairBody.domains,
|
||||||
|
rateLimit: pairBody.rateLimit,
|
||||||
|
});
|
||||||
|
return new Response(JSON.stringify({
|
||||||
|
setup_key: setupKey.token,
|
||||||
|
expires_at: setupKey.expiresAt,
|
||||||
|
scopes: setupKey.scopes,
|
||||||
|
tunnel_url: tunnelActive ? tunnelUrl : null,
|
||||||
|
server_url: `http://127.0.0.1:${server?.port || 0}`,
|
||||||
|
}), { status: 200, headers: { 'Content-Type': 'application/json' } });
|
||||||
|
} catch {
|
||||||
|
return new Response(JSON.stringify({ error: 'Invalid request body' }), {
|
||||||
|
status: 400, headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Refs endpoint — auth required, does NOT reset idle timer
|
// Refs endpoint — auth required, does NOT reset idle timer
|
||||||
if (url.pathname === '/refs') {
|
if (url.pathname === '/refs') {
|
||||||
if (!validateAuth(req)) {
|
if (!validateAuth(req)) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue