Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ OPENAI_API_KEY=
ANTHROPIC_API_KEY=
GROQ_API_KEY=

# 1Password service account token for browser-automation credential storage.
# Optional: when unset, browser automations fall back to human re-authentication.
OP_SERVICE_ACCOUNT_TOKEN=

# Inference.net Catalyst tracing (optional — no-op if CATALYST_OTLP_TOKEN is unset)
CATALYST_OTLP_TOKEN=
CATALYST_OTLP_ENDPOINT=https://telemetry.inference.net
Expand Down
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"version": "0.0.1",
"author": "",
"dependencies": {
"@1password/sdk": "0.4.0",
"@ai-sdk/anthropic": "^3.0.75",
"@ai-sdk/groq": "^3.0.38",
"@ai-sdk/openai": "^3.0.62",
Expand Down
18 changes: 18 additions & 0 deletions apps/api/src/browserbase/browser-auth-profile.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,24 @@ export class BrowserAuthProfileService {
return { profile: updated, auth };
}

async markVerified(input: { organizationId: string; profileId: string }) {

@cubic-dev-ai cubic-dev-ai Bot Jul 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new successful-run profile transition is currently untested, so regressions in status, lastVerifiedAt, or blocked-reason clearing can pass the API suite unnoticed. Adding service tests for an unverified/reauth profile, an already-verified profile, and a missing profile would cover this behavior.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/browserbase/browser-auth-profile.service.ts, line 209:

<comment>The new successful-run profile transition is currently untested, so regressions in status, `lastVerifiedAt`, or blocked-reason clearing can pass the API suite unnoticed. Adding service tests for an unverified/reauth profile, an already-verified profile, and a missing profile would cover this behavior.</comment>

<file context>
@@ -206,6 +206,24 @@ export class BrowserAuthProfileService {
     return { profile: updated, auth };
   }
 
+  async markVerified(input: { organizationId: string; profileId: string }) {
+    const profile = await this.getProfile(input);
+    if (!profile) {
</file context>
Fix with cubic

const profile = await this.getProfile(input);
if (!profile) {
throw new NotFoundException('Browser auth profile not found');
}
// Avoid a needless write when the profile is already verified.
if (profile.status === 'verified') return profile;

@cubic-dev-ai cubic-dev-ai Bot Jul 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Successful runs for an already-verified profile leave lastVerifiedAt stale because this return skips the metadata update; applyProfileResult invokes markVerified after every successful run. Removing the short-circuit (or guarding only when all verification metadata is already current) keeps profile verification state accurate.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/browserbase/browser-auth-profile.service.ts, line 215:

<comment>Successful runs for an already-verified profile leave `lastVerifiedAt` stale because this return skips the metadata update; `applyProfileResult` invokes `markVerified` after every successful run. Removing the short-circuit (or guarding only when all verification metadata is already current) keeps profile verification state accurate.</comment>

<file context>
@@ -206,6 +206,24 @@ export class BrowserAuthProfileService {
+      throw new NotFoundException('Browser auth profile not found');
+    }
+    // Avoid a needless write when the profile is already verified.
+    if (profile.status === 'verified') return profile;
+
+    return db.browserAuthProfile.update({
</file context>
Suggested change
if (profile.status === 'verified') return profile;
// Refresh verification metadata after every successful run.
Fix with cubic


return db.browserAuthProfile.update({
where: { id: profile.id },
data: {
status: 'verified',
lastVerifiedAt: new Date(),
blockedReason: null,
},
});
}

async markNeedsReauth(input: {
organizationId: string;
profileId: string;
Expand Down
25 changes: 25 additions & 0 deletions apps/api/src/browserbase/browser-auth-profiles.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
ResolveAuthProfileDto,
ResolveAuthProfileResponseDto,
SessionResponseDto,
StoreAuthProfileCredentialsDto,
VerifyAuthProfileResponseDto,
VerifyAuthProfileSessionDto,
} from './dto/browserbase.dto';
Expand Down Expand Up @@ -112,6 +113,30 @@ export class BrowserAuthProfilesController {
})) as VerifyAuthProfileResponseDto;
}

@Post('profiles/:profileId/credentials')
@RequirePermission('integration', 'update')
@ApiOperation({
summary: 'Store browser auth profile credentials',
description:
'Store the login (username, password, optional authenticator setup key) for a browser auth profile in the organization vault so scheduled and manual runs can sign in automatically when a session expires.',
})
@ApiParam({ name: 'profileId', description: 'Browser auth profile ID' })
@ApiBody({ type: StoreAuthProfileCredentialsDto })
@ApiResponse({ status: 200, type: BrowserAuthProfileResponseDto })

@cubic-dev-ai cubic-dev-ai Bot Jul 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Clients receive 201 from this route while the generated OpenAPI contract declares 200, so integrations that follow the documented response can mis-handle successful credential storage. The contract would be consistent if this documented 201 or the method explicitly returned 200.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/browserbase/browser-auth-profiles.controller.ts, line 125:

<comment>Clients receive 201 from this route while the generated OpenAPI contract declares 200, so integrations that follow the documented response can mis-handle successful credential storage. The contract would be consistent if this documented 201 or the method explicitly returned 200.</comment>

<file context>
@@ -112,6 +113,30 @@ export class BrowserAuthProfilesController {
+  })
+  @ApiParam({ name: 'profileId', description: 'Browser auth profile ID' })
+  @ApiBody({ type: StoreAuthProfileCredentialsDto })
+  @ApiResponse({ status: 200, type: BrowserAuthProfileResponseDto })
+  async storeProfileCredentials(
+    @OrganizationId() organizationId: string,
</file context>
Suggested change
@ApiResponse({ status: 200, type: BrowserAuthProfileResponseDto })
@ApiResponse({ status: 201, type: BrowserAuthProfileResponseDto })
Fix with cubic

async storeProfileCredentials(

@cubic-dev-ai cubic-dev-ai Bot Jul 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new credential endpoint has no controller coverage for RBAC protection, organization/profile forwarding, DTO mapping, or service errors, leaving the security-sensitive API boundary unverified. A focused controller spec would make these behaviors regression-tested.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/browserbase/browser-auth-profiles.controller.ts, line 126:

<comment>The new credential endpoint has no controller coverage for RBAC protection, organization/profile forwarding, DTO mapping, or service errors, leaving the security-sensitive API boundary unverified. A focused controller spec would make these behaviors regression-tested.</comment>

<file context>
@@ -112,6 +113,30 @@ export class BrowserAuthProfilesController {
+  @ApiParam({ name: 'profileId', description: 'Browser auth profile ID' })
+  @ApiBody({ type: StoreAuthProfileCredentialsDto })
+  @ApiResponse({ status: 200, type: BrowserAuthProfileResponseDto })
+  async storeProfileCredentials(
+    @OrganizationId() organizationId: string,
+    @Param('profileId') profileId: string,
</file context>
Fix with cubic

@OrganizationId() organizationId: string,
@Param('profileId') profileId: string,
@Body() dto: StoreAuthProfileCredentialsDto,
): Promise<BrowserAuthProfileResponseDto> {
return (await this.browserbaseService.storeAuthProfileCredentials({
organizationId,
profileId,
username: dto.username,
password: dto.password,
totpSeed: dto.totpSeed,
})) as BrowserAuthProfileResponseDto;
}

@Post('profiles/:profileId/needs-reauth')
@RequirePermission('integration', 'update')
@ApiOperation({
Expand Down
17 changes: 16 additions & 1 deletion apps/api/src/browserbase/browser-automation-execution.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,12 @@ export class BrowserAutomationExecutionService {
profileId: profile.id,
});

if (profile.status !== 'verified') {
// A profile with stored credentials can recover an expired session on its
// own, so let a needs_reauth profile attempt the run instead of blocking it.
// Profiles that are blocked or never verified still require a human.
const canAutoRelogin =
profile.status === 'needs_reauth' && Boolean(profile.vaultProvider);

@cubic-dev-ai cubic-dev-ai Bot Jul 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Profiles with only vaultProvider metadata are allowed into the auto-relogin path even though no credential item may be resolvable. Gate this on the required external reference as well so unusable profiles remain in the human re-auth flow.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/browserbase/browser-automation-execution.service.ts, line 144:

<comment>Profiles with only `vaultProvider` metadata are allowed into the auto-relogin path even though no credential item may be resolvable. Gate this on the required external reference as well so unusable profiles remain in the human re-auth flow.</comment>

<file context>
@@ -137,7 +137,12 @@ export class BrowserAutomationExecutionService {
+    // own, so let a needs_reauth profile attempt the run instead of blocking it.
+    // Profiles that are blocked or never verified still require a human.
+    const canAutoRelogin =
+      profile.status === 'needs_reauth' && Boolean(profile.vaultProvider);
+    if (profile.status !== 'verified' && !canAutoRelogin) {
       const result = this.profileBlockedResult(profile.status);
</file context>
Suggested change
profile.status === 'needs_reauth' && Boolean(profile.vaultProvider);
profile.status === 'needs_reauth' &&
Boolean(profile.vaultProvider && profile.vaultExternalItemRef);
Fix with cubic

if (profile.status !== 'verified' && !canAutoRelogin) {
const result = this.profileBlockedResult(profile.status);
await this.runs.finishRun({
runId: run.id,
Expand Down Expand Up @@ -188,6 +193,16 @@ export class BrowserAutomationExecutionService {
profileId: string;
result: BrowserEvidenceRunResult;
}) {
// A successful run proves the session is valid again — clear any prior
// needs_reauth/blocked state (e.g. after a credential-backed auto sign-in).
if (input.result.success) {
await this.profiles.markVerified({

@cubic-dev-ai cubic-dev-ai Bot Jul 15, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: A successful live-session execution can now mark the run's profile verified even when the supplied Browserbase session is not that profile's session. The profile/session ownership check should occur before execution, otherwise an unrelated session can clear needs_reauth or blocked state.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/api/src/browserbase/browser-automation-execution.service.ts, line 199:

<comment>A successful live-session execution can now mark the run's profile verified even when the supplied Browserbase session is not that profile's session. The profile/session ownership check should occur before execution, otherwise an unrelated session can clear `needs_reauth` or `blocked` state.</comment>

<file context>
@@ -188,6 +193,16 @@ export class BrowserAutomationExecutionService {
+    // A successful run proves the session is valid again — clear any prior
+    // needs_reauth/blocked state (e.g. after a credential-backed auto sign-in).
+    if (input.result.success) {
+      await this.profiles.markVerified({
+        organizationId: input.organizationId,
+        profileId: input.profileId,
</file context>
Fix with cubic

organizationId: input.organizationId,
profileId: input.profileId,
});
return;
}

if (input.result.failureCode === 'needs_reauth') {
await this.profiles.markNeedsReauth({
organizationId: input.organizationId,
Expand Down
186 changes: 186 additions & 0 deletions apps/api/src/browserbase/browser-credential-login.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
import {
performCredentialLogin,
reloginWithStoredCredentials,
} from './browser-credential-login';
import type { BrowserCredentialVaultAdapter } from './credential-vault';
import type { BrowserbaseSessionService } from './browserbase-session.service';
import type { BrowserEvidenceSessionInput } from './browser-evidence-runner.service';

type Stagehand = import('@browserbasehq/stagehand').Stagehand;

const makeStagehand = () => ({ act: jest.fn().mockResolvedValue(undefined) });
const makePage = () => ({ goto: jest.fn().mockResolvedValue(undefined) });
const makeSessions = (page: ReturnType<typeof makePage>) => ({
ensureActivePage: jest.fn().mockResolvedValue(page),
});

const baseInput: BrowserEvidenceSessionInput = {
organizationId: 'org_1',
automationId: 'bau_1',
runId: 'bar_1',
targetUrl: 'https://vendor.example.com/app',
instruction: 'capture evidence',
profile: {
id: 'bap_1',
hostname: 'vendor.example.com',
contextId: 'ctx_1',
vaultProvider: '1password',
vaultExternalItemRef: 'op://v/i',
vaultConnectionId: 'v',
},
sessionId: 'sess_1',
};

describe('performCredentialLogin', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());

it('passes secrets through act variables and never in the prompt text', async () => {
const stagehand = makeStagehand();
const password = 'sup3r-s3cret-passphrase';

const promise = performCredentialLogin({
stagehand: stagehand as unknown as Stagehand,
credentials: { username: 'alice', password, totpCode: '424242' },
log: jest.fn(),
});
await jest.runAllTimersAsync();
await promise;

expect(stagehand.act).toHaveBeenNthCalledWith(
1,
expect.stringContaining('%username%'),
{ variables: { username: 'alice', password } },
);
expect(stagehand.act).toHaveBeenNthCalledWith(
2,
expect.stringContaining('%code%'),
{ variables: { code: '424242' } },
);

// The actual secret values must not appear in the instruction sent to the LLM.
const credentialInstruction = stagehand.act.mock.calls[0][0] as string;
expect(credentialInstruction).not.toContain(password);
expect(credentialInstruction).not.toContain('alice');
});
});

describe('reloginWithStoredCredentials', () => {
beforeEach(() => jest.useFakeTimers());
afterEach(() => jest.useRealTimers());

const runRelogin = async (args: {
stagehand: ReturnType<typeof makeStagehand>;
sessions: ReturnType<typeof makeSessions>;
vault: BrowserCredentialVaultAdapter;
verifyLoggedIn: jest.Mock;
}) => {
const promise = reloginWithStoredCredentials({
stagehand: args.stagehand as unknown as Stagehand,
sessions: args.sessions as unknown as BrowserbaseSessionService,
vault: args.vault,
input: baseInput,
verifyLoggedIn: args.verifyLoggedIn,
log: jest.fn(),
});
await jest.runAllTimersAsync();
return promise;
};

it('does not attempt a login when no credentials are stored', async () => {
const stagehand = makeStagehand();
const page = makePage();
const vault: BrowserCredentialVaultAdapter = {
resolveCredentialReference: jest.fn().mockResolvedValue(null),
};

const result = await runRelogin({
stagehand,
sessions: makeSessions(page),
vault,
verifyLoggedIn: jest.fn().mockResolvedValue(false),
});

expect(result.isLoggedIn).toBe(false);
expect(result.reason).toMatch(/no stored credentials/i);
expect(stagehand.act).not.toHaveBeenCalled();
});

it('signs in and returns to the target URL when verification passes', async () => {
const stagehand = makeStagehand();
const page = makePage();
const vault: BrowserCredentialVaultAdapter = {
resolveCredentialReference: jest
.fn()
.mockResolvedValue({ username: 'alice', password: 'pw' }),
};

const result = await runRelogin({
stagehand,
sessions: makeSessions(page),
vault,
verifyLoggedIn: jest.fn().mockResolvedValue(true),
});

expect(result.isLoggedIn).toBe(true);
expect(stagehand.act).toHaveBeenCalledTimes(1);
expect(page.goto).toHaveBeenCalledWith(
baseInput.targetUrl,
expect.objectContaining({ waitUntil: 'domcontentloaded' }),
);
});

it('retries once with a freshly resolved TOTP code', async () => {
const stagehand = makeStagehand();
const page = makePage();
const resolveCredentialReference = jest
.fn()
.mockResolvedValueOnce({
username: 'alice',
password: 'pw',
totpCode: '111111',
})
.mockResolvedValueOnce({
username: 'alice',
password: 'pw',
totpCode: '222222',
});
const vault: BrowserCredentialVaultAdapter = { resolveCredentialReference };
const verifyLoggedIn = jest
.fn()
.mockResolvedValueOnce(false)
.mockResolvedValueOnce(true);

const result = await runRelogin({
stagehand,
sessions: makeSessions(page),
vault,
verifyLoggedIn,
});

expect(result.isLoggedIn).toBe(true);
expect(resolveCredentialReference).toHaveBeenCalledTimes(2);
// Two login attempts: each enters credentials + a TOTP code (2 acts each).
expect(stagehand.act).toHaveBeenCalledTimes(4);
});

it('gives up (user action required) when sign-in never authenticates', async () => {
const stagehand = makeStagehand();
const page = makePage();
const vault: BrowserCredentialVaultAdapter = {
resolveCredentialReference: jest
.fn()
.mockResolvedValue({ username: 'alice', password: 'pw' }),
};

const result = await runRelogin({
stagehand,
sessions: makeSessions(page),
vault,
verifyLoggedIn: jest.fn().mockResolvedValue(false),
});

expect(result.isLoggedIn).toBe(false);
expect(result.reason).toMatch(/user action/i);
});
});
Loading
Loading