Skip to content

Commit 902ab95

Browse files
authored
Merge pull request #3506 from trycompai/main
[comp] Production Deploy
2 parents 96210eb + 02746c3 commit 902ab95

159 files changed

Lines changed: 16499 additions & 1996 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/api/.env.example‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,30 @@ OPENAI_API_KEY=
4848
ANTHROPIC_API_KEY=
4949
GROQ_API_KEY=
5050

51+
# Browserbase — the cloud browser that powers Browser Automations.
52+
# REQUIRED for the browser-automation feature (connect, analyze, runs).
53+
BROWSERBASE_API_KEY=
54+
BROWSERBASE_PROJECT_ID=
55+
# Optional model overrides (defaults are sensible; no need to set these).
56+
# BROWSERBASE_CUA_MODEL: screenshot-based navigation agent. Defaults to
57+
# "openai/gpt-5.6-terra" (needs OPENAI_API_KEY; falls back to Claude if it's
58+
# missing). Set to "openai/gpt-5.6-sol" for max accuracy, or an "anthropic/…"
59+
# computer-use model to use Claude instead.
60+
# BROWSERBASE_STAGEHAND_MODEL: model behind page reading / pass-fail verdicts —
61+
# keep this a general model (Claude), NOT a computer-use model.
62+
BROWSERBASE_CUA_MODEL=
63+
BROWSERBASE_STAGEHAND_MODEL=
64+
65+
# 1Password service account token (starts with "ops_") for storing browser-
66+
# automation logins in a Comp-managed vault.
67+
# Enables the "Sign in for me" password path: Comp stores the login and signs in
68+
# on its own (including generating 2FA codes) for the first sign-in and for
69+
# scheduled runs.
70+
# Optional — when unset, that path is unavailable and users sign in manually in
71+
# the live browser each time (no unattended re-login).
72+
# Create one at 1Password → Developer → Service Accounts (needs vault-create access).
73+
OP_SERVICE_ACCOUNT_TOKEN=
74+
5175
# Inference.net Catalyst tracing (optional — no-op if CATALYST_OTLP_TOKEN is unset)
5276
CATALYST_OTLP_TOKEN=
5377
CATALYST_OTLP_ENDPOINT=https://telemetry.inference.net

‎apps/api/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
"version": "0.0.1",
55
"author": "",
66
"dependencies": {
7+
"@1password/sdk": "0.4.0",
78
"@ai-sdk/anthropic": "^3.0.75",
89
"@ai-sdk/groq": "^3.0.38",
910
"@ai-sdk/openai": "^3.0.62",

‎apps/api/src/audit/audit-log.constants.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ export const SENSITIVE_KEYS = new Set([
1717
'credentials',
1818
'privateKey',
1919
'private_key',
20+
// Authenticator (TOTP) material — a reusable MFA secret. Must never land in
21+
// the audit log in cleartext (browser-connection credential endpoints accept
22+
// `totpSeed`; the runtime carries `totpCode`).
23+
'totpSeed',
24+
'totpCode',
2025
]);
2126

2227
export const RESOURCE_TO_ENTITY_TYPE: Record<
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// buildChanges -> constants.ts imports enums from @db; provide minimal stubs so
2+
// the util can be exercised in isolation without a real Prisma client.
3+
jest.mock('@db', () => ({
4+
AuditLogEntityType: {},
5+
CommentEntityType: {},
6+
}));
7+
8+
import { buildChanges } from './audit-log.utils';
9+
10+
describe('audit log redaction (buildChanges)', () => {
11+
it('redacts the TOTP seed like other secrets, and keeps non-secret fields', () => {
12+
const changes = buildChanges(
13+
{
14+
username: 'user@example.com',
15+
password: 'hunter2',
16+
totpSeed: 'JBSWY3DPEHPK3PXP',
17+
},
18+
null,
19+
{},
20+
);
21+
22+
expect(changes).not.toBeNull();
23+
// The authenticator seed is a reusable MFA secret — must be redacted like
24+
// the password, never written to the audit log in cleartext.
25+
expect(changes?.totpSeed.current).toBe('[REDACTED]');
26+
expect(changes?.password.current).toBe('[REDACTED]');
27+
// A non-secret identifier is still recorded for a useful audit trail.
28+
expect(changes?.username.current).toBe('user@example.com');
29+
// Belt and braces: the raw seed must not appear anywhere in the payload.
30+
expect(JSON.stringify(changes)).not.toContain('JBSWY3DPEHPK3PXP');
31+
});
32+
33+
it('redacts a runtime totpCode as well', () => {
34+
const changes = buildChanges({ totpCode: '123456' }, null, {});
35+
expect(changes?.totpCode.current).toBe('[REDACTED]');
36+
});
37+
});

‎apps/api/src/browserbase/browser-auth-profile.service.spec.ts‎

Lines changed: 271 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { BadRequestException, ForbiddenException } from '@nestjs/common';
12
import { BrowserbaseSessionService } from './browserbase-session.service';
23
import { BrowserAuthProfileService } from './browser-auth-profile.service';
34

@@ -10,15 +11,21 @@ jest.mock('@db', () => ({
1011
findFirst: jest.fn(),
1112
create: jest.fn(),
1213
update: jest.fn(),
14+
updateMany: jest.fn(),
15+
delete: jest.fn(),
1316
deleteMany: jest.fn(),
1417
},
1518
browserbaseContext: {
1619
findUnique: jest.fn(),
20+
findFirst: jest.fn(),
1721
create: jest.fn(),
1822
update: jest.fn(),
1923
updateMany: jest.fn(),
2024
deleteMany: jest.fn(),
2125
},
26+
browserAutomation: {
27+
findMany: jest.fn(),
28+
},
2229
},
2330
}));
2431

@@ -37,6 +44,57 @@ describe('BrowserAuthProfileService', () => {
3744
service = new BrowserAuthProfileService(sessions);
3845
});
3946

47+
describe('tenant guards (cross-org IDOR)', () => {
48+
it('assertContextOwnedByOrg passes when a profile in the org owns the context', async () => {
49+
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue({ id: 'bap_1' });
50+
(db.browserbaseContext.findFirst as jest.Mock).mockResolvedValue(null);
51+
await expect(
52+
service.assertContextOwnedByOrg({ organizationId: 'org_1', contextId: 'ctx_1' }),
53+
).resolves.toBeUndefined();
54+
});
55+
56+
it('assertContextOwnedByOrg passes when the org-level context row owns it', async () => {
57+
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue(null);
58+
(db.browserbaseContext.findFirst as jest.Mock).mockResolvedValue({ id: 'bbc_1' });
59+
await expect(
60+
service.assertContextOwnedByOrg({ organizationId: 'org_1', contextId: 'ctx_1' }),
61+
).resolves.toBeUndefined();
62+
});
63+
64+
it('assertContextOwnedByOrg rejects a context that belongs to another org', async () => {
65+
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue(null);
66+
(db.browserbaseContext.findFirst as jest.Mock).mockResolvedValue(null);
67+
await expect(
68+
service.assertContextOwnedByOrg({ organizationId: 'org_1', contextId: 'ctx_other' }),
69+
).rejects.toBeInstanceOf(ForbiddenException);
70+
});
71+
72+
it('assertSessionOwnedByOrg returns the contextId when the org owns the session', async () => {
73+
jest.spyOn(sessions, 'getSessionContextId').mockResolvedValue('ctx_1');
74+
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue({ id: 'bap_1' });
75+
(db.browserbaseContext.findFirst as jest.Mock).mockResolvedValue(null);
76+
await expect(
77+
service.assertSessionOwnedByOrg({ organizationId: 'org_1', sessionId: 'sess_1' }),
78+
).resolves.toBe('ctx_1');
79+
});
80+
81+
it('assertSessionOwnedByOrg rejects a session whose context is another org', async () => {
82+
jest.spyOn(sessions, 'getSessionContextId').mockResolvedValue('ctx_other');
83+
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue(null);
84+
(db.browserbaseContext.findFirst as jest.Mock).mockResolvedValue(null);
85+
await expect(
86+
service.assertSessionOwnedByOrg({ organizationId: 'org_1', sessionId: 'sess_x' }),
87+
).rejects.toBeInstanceOf(ForbiddenException);
88+
});
89+
90+
it('assertSessionOwnedByOrg rejects when the session context cannot be resolved', async () => {
91+
jest.spyOn(sessions, 'getSessionContextId').mockResolvedValue(undefined);
92+
await expect(
93+
service.assertSessionOwnedByOrg({ organizationId: 'org_1', sessionId: 'gone' }),
94+
).rejects.toBeInstanceOf(ForbiddenException);
95+
});
96+
});
97+
4098
it('normalizes hostname and login identity when creating a profile', async () => {
4199
(db.browserAuthProfile.findUnique as jest.Mock).mockResolvedValue(null);
42100
(db.browserbaseContext.findUnique as jest.Mock).mockResolvedValue(null);
@@ -188,4 +246,217 @@ describe('BrowserAuthProfileService', () => {
188246
where: { organizationId: 'org_1', contextId: '__PENDING__' },
189247
});
190248
});
249+
250+
describe('listProfiles', () => {
251+
it('attaches an automation count per connection by hostname', async () => {
252+
(db.browserAuthProfile.findMany as jest.Mock).mockResolvedValue([
253+
{ id: 'bap_gh', organizationId: 'org_1', hostname: 'github.com' },
254+
{ id: 'bap_aws', organizationId: 'org_1', hostname: 'aws.amazon.com' },
255+
{ id: 'bap_dd', organizationId: 'org_1', hostname: 'datadoghq.com' },
256+
]);
257+
(db.browserAutomation.findMany as jest.Mock).mockResolvedValue([
258+
{ targetUrl: 'https://github.com/acme/repo' },
259+
{ targetUrl: 'https://github.com/acme/other' },
260+
{ targetUrl: 'https://aws.amazon.com/console' },
261+
{ targetUrl: 'not-a-url' }, // malformed -> skipped, not fatal
262+
]);
263+
264+
const result = await service.listProfiles('org_1');
265+
266+
expect(db.browserAutomation.findMany).toHaveBeenCalledWith({
267+
where: { task: { organizationId: 'org_1' } },
268+
select: { targetUrl: true },
269+
});
270+
expect(result.find((p) => p.id === 'bap_gh')?.automationCount).toBe(2);
271+
expect(result.find((p) => p.id === 'bap_aws')?.automationCount).toBe(1);
272+
expect(result.find((p) => p.id === 'bap_dd')?.automationCount).toBe(0);
273+
});
274+
});
275+
276+
describe('updateProfile / deleteProfile', () => {
277+
const existing = {
278+
id: 'bap_1',
279+
organizationId: 'org_1',
280+
hostname: 'app.example.com',
281+
displayName: 'Example',
282+
status: 'verified',
283+
};
284+
285+
beforeEach(() => {
286+
(db.browserAuthProfile.update as jest.Mock).mockImplementation(
287+
({ data }) => ({ ...existing, ...data }),
288+
);
289+
(db.browserAuthProfile.delete as jest.Mock).mockResolvedValue(existing);
290+
});
291+
292+
it('updates only the display name (trimmed)', async () => {
293+
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue(existing);
294+
await service.updateProfile({
295+
organizationId: 'org_1',
296+
profileId: 'bap_1',
297+
displayName: ' New name ',
298+
});
299+
expect(db.browserAuthProfile.update).toHaveBeenCalledWith({
300+
where: { id: 'bap_1' },
301+
data: { displayName: 'New name' },
302+
});
303+
});
304+
305+
it('keeps the connection signed in when the URL stays on the same host', async () => {
306+
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue(existing);
307+
await service.updateProfile({
308+
organizationId: 'org_1',
309+
profileId: 'bap_1',
310+
url: 'https://app.example.com/account/login',
311+
});
312+
const data = (db.browserAuthProfile.update as jest.Mock).mock.calls[0][0]
313+
.data;
314+
expect(data.lastAuthCheckUrl).toBe(
315+
'https://app.example.com/account/login',
316+
);
317+
expect(data.status).toBeUndefined();
318+
});
319+
320+
it('marks needs_reauth when the URL moves to a different host', async () => {
321+
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue(existing);
322+
await service.updateProfile({
323+
organizationId: 'org_1',
324+
profileId: 'bap_1',
325+
url: 'https://other.com/login',
326+
});
327+
const data = (db.browserAuthProfile.update as jest.Mock).mock.calls[0][0]
328+
.data;
329+
expect(data.status).toBe('needs_reauth');
330+
});
331+
332+
it('deletes the profile', async () => {
333+
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue(existing);
334+
const result = await service.deleteProfile({
335+
organizationId: 'org_1',
336+
profileId: 'bap_1',
337+
});
338+
expect(db.browserAuthProfile.delete).toHaveBeenCalledWith({
339+
where: { id: 'bap_1' },
340+
});
341+
expect(result.success).toBe(true);
342+
});
343+
344+
it('throws when updating a missing profile', async () => {
345+
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue(null);
346+
await expect(
347+
service.updateProfile({
348+
organizationId: 'org_1',
349+
profileId: 'nope',
350+
displayName: 'x',
351+
}),
352+
).rejects.toThrow('not found');
353+
});
354+
});
355+
356+
describe('recordSignInAttempt', () => {
357+
it('persists the outcome, detail, and a timestamp, scoped to the org', async () => {
358+
(db.browserAuthProfile.updateMany as jest.Mock).mockResolvedValue({
359+
count: 1,
360+
});
361+
362+
await service.recordSignInAttempt({
363+
organizationId: 'org_1',
364+
profileId: 'bap_1',
365+
outcome: 'needs_2fa',
366+
detail: 'The account requires a two-factor code to sign in.',
367+
});
368+
369+
expect(db.browserAuthProfile.updateMany).toHaveBeenCalledWith({
370+
where: { id: 'bap_1', organizationId: 'org_1' },
371+
data: expect.objectContaining({
372+
lastSignInOutcome: 'needs_2fa',
373+
lastSignInDetail: 'The account requires a two-factor code to sign in.',
374+
lastSignInAt: expect.any(Date),
375+
}),
376+
});
377+
});
378+
379+
it('stores null detail when none is provided', async () => {
380+
(db.browserAuthProfile.updateMany as jest.Mock).mockResolvedValue({
381+
count: 1,
382+
});
383+
384+
await service.recordSignInAttempt({
385+
organizationId: 'org_1',
386+
profileId: 'bap_1',
387+
outcome: 'logged_in',
388+
});
389+
390+
expect(db.browserAuthProfile.updateMany).toHaveBeenCalledWith(
391+
expect.objectContaining({
392+
data: expect.objectContaining({
393+
lastSignInOutcome: 'logged_in',
394+
lastSignInDetail: null,
395+
}),
396+
}),
397+
);
398+
});
399+
400+
it('never throws — a logging write failure cannot break sign-in', async () => {
401+
(db.browserAuthProfile.updateMany as jest.Mock).mockRejectedValue(
402+
new Error('db down'),
403+
);
404+
405+
await expect(
406+
service.recordSignInAttempt({
407+
organizationId: 'org_1',
408+
profileId: 'bap_1',
409+
outcome: 'error',
410+
detail: 'boom',
411+
}),
412+
).resolves.toBeUndefined();
413+
});
414+
});
415+
416+
describe('assertUrlMatchesProfileHostname (credential-safety guard)', () => {
417+
it('passes when the URL host matches the connection hostname', () => {
418+
expect(() =>
419+
service.assertUrlMatchesProfileHostname({
420+
url: 'https://app.example.com/login',
421+
profileHostname: 'app.example.com',
422+
}),
423+
).not.toThrow();
424+
});
425+
426+
it('rejects a URL on a different host', () => {
427+
expect(() =>
428+
service.assertUrlMatchesProfileHostname({
429+
url: 'https://evil.example.net/login',
430+
profileHostname: 'app.example.com',
431+
}),
432+
).toThrow(BadRequestException);
433+
});
434+
435+
it('rejects an unparseable URL', () => {
436+
expect(() =>
437+
service.assertUrlMatchesProfileHostname({
438+
url: 'not a url',
439+
profileHostname: 'app.example.com',
440+
}),
441+
).toThrow(BadRequestException);
442+
});
443+
});
444+
445+
describe('resolveProfileForTarget (explicit profileId host binding)', () => {
446+
it('rejects an explicit profile whose host does not match the target URL', async () => {
447+
(db.browserAuthProfile.findFirst as jest.Mock).mockResolvedValue({
448+
id: 'bap_1',
449+
organizationId: 'org_1',
450+
hostname: 'app.example.com',
451+
});
452+
453+
await expect(
454+
service.resolveProfileForTarget({
455+
organizationId: 'org_1',
456+
targetUrl: 'https://evil.example.net/app',
457+
profileId: 'bap_1',
458+
}),
459+
).rejects.toBeInstanceOf(BadRequestException);
460+
});
461+
});
191462
});

0 commit comments

Comments
 (0)