moving
This commit is contained in:
@@ -2,20 +2,46 @@ import { describe, expect, it, beforeEach, afterEach } from 'vitest';
|
||||
import Fastify from 'fastify';
|
||||
import { securityMiddleware } from '../../src/local/security-middleware.js';
|
||||
import { browserExtRoutes } from '../../src/local/routes/browser-ext.js';
|
||||
import {
|
||||
BROWSER_COMPANION_CREDENTIAL_VAULT_KEY,
|
||||
hashBrowserCompanionCredential,
|
||||
} from '../../src/local/browser-companion-pairing.js';
|
||||
|
||||
const TEST_TOKEN = 'browser-ext-session-token';
|
||||
const TEST_TOKEN = 'global-session-token';
|
||||
const TEST_BROWSER_TOKEN = 'browser-companion-scoped-token';
|
||||
const FABRICATED_LEGACY_TOKEN = 'fabricated-legacy-browser-token';
|
||||
const EXTENSION_ID = 'abcdefghijklmnopabcdefghijklmnop';
|
||||
const EXTENSION_ORIGIN = `chrome-extension://${EXTENSION_ID}`;
|
||||
const OTHER_EXTENSION_ORIGIN = 'chrome-extension://ponmlkjihgfedcbaponmlkjihgfedcba';
|
||||
|
||||
async function createBrowserExtServer() {
|
||||
const server = Fastify({ logger: false });
|
||||
const vaultEntries = new Map<string, { value: string; metadata?: Record<string, unknown> }>();
|
||||
server.decorate('vault', {
|
||||
get: (name: string) => vaultEntries.get(name) ?? null,
|
||||
set: (name: string, value: string, metadata?: Record<string, unknown>) => {
|
||||
vaultEntries.set(name, { value, metadata });
|
||||
},
|
||||
delete: (name: string) => vaultEntries.delete(name),
|
||||
});
|
||||
server.decorate('agentState', {
|
||||
wsSessionToken: TEST_TOKEN,
|
||||
browserCompanionCredentialHash: hashBrowserCompanionCredential(TEST_BROWSER_TOKEN),
|
||||
activeWorkspaceId: 'workspace-1',
|
||||
});
|
||||
await server.register(securityMiddleware, { sessionToken: TEST_TOKEN });
|
||||
await server.register(securityMiddleware, {
|
||||
sessionToken: TEST_TOKEN,
|
||||
authenticateBrowserCompanionToken: (token) => (
|
||||
server.agentState.browserCompanionCredentialHash === hashBrowserCompanionCredential(token)
|
||||
),
|
||||
});
|
||||
server.get('/api/memory/frames', async () => ({ ok: true }));
|
||||
server.post('/api/memory/frames', async (request) => ({ ok: true, body: request.body }));
|
||||
server.get('/api/private', async () => ({ ok: true }));
|
||||
server.post('/api/chat', async () => ({ ok: true }));
|
||||
server.post('/api/agents/test/run', async () => ({ ok: true }));
|
||||
server.post('/api/tools/launch', async () => ({ ok: true }));
|
||||
server.post('/api/vault/test/reveal', async () => ({ ok: true }));
|
||||
server.post('/v1/chat/completions', async () => ({ ok: true }));
|
||||
await server.register(browserExtRoutes);
|
||||
await server.ready();
|
||||
return server;
|
||||
@@ -25,10 +51,12 @@ describe('Browser Companion auth bootstrap', () => {
|
||||
const originalExtIds = process.env.WAGGLE_BROWSER_EXT_IDS;
|
||||
const originalTrustLocalhost = process.env.WAGGLE_TRUST_LOCALHOST;
|
||||
const originalDevAllow = process.env.WAGGLE_DEV_ALLOW_ANY_EXTENSION;
|
||||
const originalHost = process.env.WAGGLE_HOST;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.WAGGLE_TRUST_LOCALHOST = '0';
|
||||
process.env.WAGGLE_BROWSER_EXT_IDS = EXTENSION_ID;
|
||||
delete process.env.WAGGLE_HOST;
|
||||
delete process.env.WAGGLE_DEV_ALLOW_ANY_EXTENSION;
|
||||
});
|
||||
|
||||
@@ -39,28 +67,14 @@ describe('Browser Companion auth bootstrap', () => {
|
||||
else process.env.WAGGLE_TRUST_LOCALHOST = originalTrustLocalhost;
|
||||
if (originalDevAllow === undefined) delete process.env.WAGGLE_DEV_ALLOW_ANY_EXTENSION;
|
||||
else process.env.WAGGLE_DEV_ALLOW_ANY_EXTENSION = originalDevAllow;
|
||||
if (originalHost === undefined) delete process.env.WAGGLE_HOST;
|
||||
else process.env.WAGGLE_HOST = originalHost;
|
||||
});
|
||||
|
||||
it('returns the session token to an explicitly allowlisted extension origin without an existing bearer', async () => {
|
||||
it('returns upgrade guidance without a credential from the retired bootstrap endpoint', async () => {
|
||||
const server = await createBrowserExtServer();
|
||||
try {
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/browser-ext/session-token',
|
||||
headers: { origin: EXTENSION_ORIGIN },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ token: TEST_TOKEN });
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('returns the session token to an allowlisted MV3 service-worker request without an Origin header', async () => {
|
||||
const server = await createBrowserExtServer();
|
||||
try {
|
||||
const res = await server.inject({
|
||||
const publicRequest = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/browser-ext/session-token',
|
||||
headers: {
|
||||
@@ -68,60 +82,19 @@ describe('Browser Companion auth bootstrap', () => {
|
||||
'sec-fetch-site': 'none',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ token: TEST_TOKEN });
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects the token bootstrap for unallowlisted extension origins', async () => {
|
||||
const server = await createBrowserExtServer();
|
||||
try {
|
||||
const res = await server.inject({
|
||||
const authenticatedRequest = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/browser-ext/session-token',
|
||||
headers: { origin: OTHER_EXTENSION_ORIGIN },
|
||||
headers: { authorization: `Bearer ${TEST_TOKEN}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.json().code).toBe('EXTENSION_NOT_ALLOWLISTED');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects MV3 service-worker token bootstrap when the extension id header is missing', async () => {
|
||||
const server = await createBrowserExtServer();
|
||||
try {
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/browser-ext/session-token',
|
||||
headers: { 'sec-fetch-site': 'none' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.json().code).toBe('EXTENSION_NOT_ALLOWLISTED');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects MV3 service-worker token bootstrap with an unallowlisted extension id header', async () => {
|
||||
const server = await createBrowserExtServer();
|
||||
try {
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/browser-ext/session-token',
|
||||
headers: {
|
||||
'x-waggle-extension-id': 'ponmlkjihgfedcbaponmlkjihgfedcba',
|
||||
'sec-fetch-site': 'none',
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.json().code).toBe('EXTENSION_NOT_ALLOWLISTED');
|
||||
expect(publicRequest.statusCode).toBe(410);
|
||||
expect(publicRequest.headers['cache-control']).toBe('no-store');
|
||||
expect(publicRequest.json().code).toBe('BROWSER_COMPANION_UPDATE_REQUIRED');
|
||||
expect(publicRequest.json()).not.toHaveProperty('token');
|
||||
expect(authenticatedRequest.statusCode).toBe(410);
|
||||
expect(authenticatedRequest.json().code).toBe('BROWSER_COMPANION_UPDATE_REQUIRED');
|
||||
expect(authenticatedRequest.json()).not.toHaveProperty('token');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
@@ -149,7 +122,7 @@ describe('Browser Companion auth bootstrap', () => {
|
||||
const res = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/browser-ext/health',
|
||||
headers: { authorization: `Bearer ${TEST_TOKEN}` },
|
||||
headers: { authorization: `Bearer ${TEST_BROWSER_TOKEN}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
@@ -162,4 +135,237 @@ describe('Browser Companion auth bootstrap', () => {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('requires a desktop-minted one-time code instead of automatic extension-id bootstrap', async () => {
|
||||
const server = await createBrowserExtServer();
|
||||
try {
|
||||
const codeRes = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/browser-ext/pairing-code',
|
||||
headers: { authorization: `Bearer ${TEST_TOKEN}` },
|
||||
});
|
||||
|
||||
expect(codeRes.statusCode).toBe(200);
|
||||
expect(codeRes.headers['cache-control']).toBe('no-store');
|
||||
expect(codeRes.json().code).toMatch(/^[A-HJ-NP-Z2-9]{8}$/);
|
||||
|
||||
const pairRes = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/browser-ext/pair',
|
||||
headers: {
|
||||
'x-waggle-extension-id': EXTENSION_ID,
|
||||
'sec-fetch-site': 'none',
|
||||
},
|
||||
payload: { code: codeRes.json().code },
|
||||
});
|
||||
const pairedToken = pairRes.json().token as string;
|
||||
|
||||
expect(pairRes.statusCode).toBe(200);
|
||||
expect(pairRes.headers['cache-control']).toBe('no-store');
|
||||
expect(pairedToken).not.toBe(TEST_TOKEN);
|
||||
expect(pairedToken).not.toBe(TEST_BROWSER_TOKEN);
|
||||
const storedCredential = server.vault?.get(BROWSER_COMPANION_CREDENTIAL_VAULT_KEY);
|
||||
expect(storedCredential?.value).toBe(hashBrowserCompanionCredential(pairedToken));
|
||||
expect(storedCredential?.value).not.toContain(pairedToken);
|
||||
|
||||
const healthRes = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/browser-ext/health',
|
||||
headers: { authorization: `Bearer ${pairedToken}` },
|
||||
});
|
||||
expect(healthRes.statusCode).toBe(200);
|
||||
|
||||
const replayRes = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/browser-ext/pair',
|
||||
headers: {
|
||||
'x-waggle-extension-id': EXTENSION_ID,
|
||||
'sec-fetch-site': 'none',
|
||||
},
|
||||
payload: { code: codeRes.json().code },
|
||||
});
|
||||
expect(replayRes.statusCode).toBe(403);
|
||||
expect(replayRes.json().code).toBe('PAIRING_CODE_INVALID');
|
||||
|
||||
const statusRes = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/browser-ext/pairing',
|
||||
headers: { authorization: `Bearer ${TEST_TOKEN}` },
|
||||
});
|
||||
expect(statusRes.json()).toMatchObject({ paired: true, extensionId: EXTENSION_ID });
|
||||
|
||||
const revokeRes = await server.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/browser-ext/pairing',
|
||||
headers: { authorization: `Bearer ${TEST_TOKEN}` },
|
||||
});
|
||||
expect(revokeRes.statusCode).toBe(200);
|
||||
expect(server.vault?.get(BROWSER_COMPANION_CREDENTIAL_VAULT_KEY)).toBeNull();
|
||||
|
||||
const revokedHealth = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/browser-ext/health',
|
||||
headers: { authorization: `Bearer ${pairedToken}` },
|
||||
});
|
||||
expect(revokedHealth.statusCode).toBe(401);
|
||||
expect(revokedHealth.json().code).toBe('INVALID_TOKEN');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not let a distinct extension replay the public id without a pairing code', async () => {
|
||||
const server = await createBrowserExtServer();
|
||||
try {
|
||||
const res = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/browser-ext/pair',
|
||||
headers: {
|
||||
'x-waggle-extension-id': EXTENSION_ID,
|
||||
'sec-fetch-site': 'none',
|
||||
},
|
||||
payload: { code: 'BADCODE2' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.json().code).toBe('PAIRING_CODE_INVALID');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts the scoped token for personal imported-memory ingestion', async () => {
|
||||
const server = await createBrowserExtServer();
|
||||
try {
|
||||
const res = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/memory/frames',
|
||||
headers: { authorization: `Bearer ${TEST_BROWSER_TOKEN}` },
|
||||
payload: { content: 'Captured page note', source: 'import', importance: 'normal' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ ok: true });
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts low-importance full-page capture with the scoped token', async () => {
|
||||
const server = await createBrowserExtServer();
|
||||
try {
|
||||
const res = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/memory/frames',
|
||||
headers: { authorization: `Bearer ${TEST_BROWSER_TOKEN}` },
|
||||
payload: { content: 'Captured full page', source: 'import', importance: 'low' },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toMatchObject({ ok: true });
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not narrow the process bearer used by trusted sidecar clients', async () => {
|
||||
const server = await createBrowserExtServer();
|
||||
try {
|
||||
const privateRes = await server.inject({
|
||||
method: 'GET',
|
||||
url: '/api/private',
|
||||
headers: { authorization: `Bearer ${TEST_TOKEN}` },
|
||||
});
|
||||
const memoryRes = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/memory/frames',
|
||||
headers: { authorization: `Bearer ${TEST_TOKEN}` },
|
||||
payload: {
|
||||
content: 'Trusted workspace note',
|
||||
workspaceId: 'workspace-1',
|
||||
source: 'user_stated',
|
||||
importance: 'critical',
|
||||
},
|
||||
});
|
||||
|
||||
expect(privateRes.statusCode).toBe(200);
|
||||
expect(memoryRes.statusCode).toBe(200);
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['workspace target', { content: 'note', source: 'import', workspace: 'workspace-1' }],
|
||||
['empty workspace target', { content: 'note', source: 'import', workspace: '' }],
|
||||
['null workspace target', { content: 'note', source: 'import', workspace: null }],
|
||||
['workspaceId target', { content: 'note', source: 'import', workspaceId: 'workspace-1' }],
|
||||
['empty workspaceId target', { content: 'note', source: 'import', workspaceId: '' }],
|
||||
['null workspaceId target', { content: 'note', source: 'import', workspaceId: null }],
|
||||
['non-import source', { content: 'note', source: 'user_stated' }],
|
||||
['elevated importance', { content: 'note', source: 'import', importance: 'critical' }],
|
||||
['non-object body', ['note']],
|
||||
])('rejects scoped-token memory escalation through %s', async (_label, payload) => {
|
||||
const server = await createBrowserExtServer();
|
||||
try {
|
||||
const res = await server.inject({
|
||||
method: 'POST',
|
||||
url: '/api/memory/frames',
|
||||
headers: { authorization: `Bearer ${TEST_BROWSER_TOKEN}` },
|
||||
payload,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.json().code).toBe('BROWSER_COMPANION_SCOPE_VIOLATION');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
['GET', '/api/memory/frames'],
|
||||
['GET', '/api/private'],
|
||||
['HEAD', '/api/browser-ext/health'],
|
||||
['PUT', '/api/memory/frames'],
|
||||
['PATCH', '/api/memory/frames'],
|
||||
['DELETE', '/api/memory/frames'],
|
||||
['POST', '/api/memory/frames/'],
|
||||
['POST', '/api/chat'],
|
||||
['POST', '/api/agents/test/run'],
|
||||
['POST', '/api/tools/launch'],
|
||||
['POST', '/api/vault/test/reveal'],
|
||||
['POST', '/v1/chat/completions'],
|
||||
])('rejects the scoped token on %s %s outside its route scope', async (method, url) => {
|
||||
const server = await createBrowserExtServer();
|
||||
try {
|
||||
const res = await server.inject({
|
||||
method,
|
||||
url,
|
||||
headers: { authorization: `Bearer ${TEST_BROWSER_TOKEN}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
if (method !== 'HEAD') {
|
||||
expect(res.json().code).toBe('INVALID_TOKEN');
|
||||
}
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('rejects a fabricated legacy per-process credential', async () => {
|
||||
const restartedServer = await createBrowserExtServer();
|
||||
try {
|
||||
const res = await restartedServer.inject({
|
||||
method: 'GET',
|
||||
url: '/api/browser-ext/health',
|
||||
headers: { authorization: `Bearer ${FABRICATED_LEGACY_TOKEN}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
expect(res.json().code).toBe('INVALID_TOKEN');
|
||||
} finally {
|
||||
await restartedServer.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user