Skip to content
Merged
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
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1032,6 +1032,10 @@ npx firecrawl-cli@alexandria alexandria terms accept benzinga \

This posts to `/exchange/provider-terms/accept`. No automatic acceptance or retry
occurs. A `409 terms_changed` requires reviewing the new agreement before retrying.
The terms catalog may remain access-gated even when the acceptance endpoint is
available. A failed catalog lookup does not imply acceptance is unavailable.
Agents must present the returned terms and provider links, ask the user for explicit
approval, and wait before accepting. If review is refused or a provider link is
unavailable, show the error and direct an organization admin to
https://www.firecrawl.dev/app/settings?tab=data-sources. Never infer consent from a
failed lookup or automatically retry an acceptance. The API remains authoritative
for organization access and acceptance authority.
After confirmed success, rerun the original provider command; its normal credits apply.
2 changes: 2 additions & 0 deletions src/__tests__/alexandria-beta.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -472,6 +472,8 @@ it('relays terms refusals and keeps the request ID on failure', async () => {
expect(result.code).toBe(1);
const body = JSON.parse(result.stdout);
expect(body).toMatchObject(response);
expect(body.guidance).toContain('wait for explicit approval');
expect(body.guidance).toContain('/app/settings?tab=data-sources');
expect(result.stderr).toContain(body.requestId);
expect(requests).toHaveLength(1);
});
Expand Down
112 changes: 112 additions & 0 deletions src/__tests__/commands/terms.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { afterEach, expect, it, vi } from 'vitest';
import { requestTerms } from '../../commands/terms';

vi.mock('../../utils/config', () => ({
getApiKey: () => 'fc-test',
getConfig: () => ({ apiUrl: 'https://api.example.test' }),
}));
afterEach(() => vi.unstubAllGlobals());
const digest = 'a'.repeat(64);
const options = { termsVersion: 'B-1', digest, confirm: true };

it('presents the selected agreement and requests human approval without accepting', async () => {
const provider = {
provider: 'particle',
terms: { version: 'B-1', digest, document: 'Review these terms.' },
};
const fetch = vi
.fn()
.mockResolvedValue(Response.json({ providers: [provider] }));
vi.stubGlobal('fetch', fetch);
const result = await requestTerms('particle', {});
expect(result).toMatchObject({ success: true, ...provider });
expect(result.instructions).toContain('explicit approval');
expect(fetch).toHaveBeenCalledOnce();
expect(fetch.mock.calls[0][1]).toMatchObject({
method: 'GET',
redirect: 'error',
});
});

it('preserves refusal details and gives actionable guidance without retrying', async () => {
const fetch = vi
.fn()
.mockResolvedValue(
Response.json(
{
code: 'forbidden',
error: 'This endpoint is not enabled for this team.',
},
{ status: 403 }
)
);
vi.stubGlobal('fetch', fetch);
const result = await requestTerms('particle', {});
expect(result).toMatchObject({
success: false,
status: 403,
code: 'forbidden',
error: 'This endpoint is not enabled for this team.',
guidance: {
url: 'https://www.firecrawl.dev/app/settings?tab=data-sources',
},
});
expect(fetch).toHaveBeenCalledOnce();
});

it('refuses acceptance without confirmation or the exact version and digest before networking', async () => {
const fetch = vi.fn();
vi.stubGlobal('fetch', fetch);
for (const invalid of [
{ ...options, confirm: false },
{ ...options, termsVersion: '' },
{ ...options, digest: 'invalid' },
]) {
await expect(requestTerms('particle', invalid, true)).rejects.toThrow(
'Review the terms'
);
}
expect(fetch).not.toHaveBeenCalled();
});

it('sends exactly the explicitly confirmed agreement once', async () => {
const fetch = vi
.fn()
.mockResolvedValue(
Response.json({ success: true, receiptId: 'receipt-test' })
);
vi.stubGlobal('fetch', fetch);
expect(await requestTerms('particle', options, true)).toEqual({
success: true,
receiptId: 'receipt-test',
});
expect(fetch).toHaveBeenCalledOnce();
const [url, init] = fetch.mock.calls[0];
expect(url).toBe('https://api.example.test/exchange/provider-terms/accept');
expect(JSON.parse(init.body)).toEqual({
provider: 'particle',
version: 'B-1',
digest,
confirmed: true,
});
});

it('keeps stale agreement failures and never claims acceptance from an ambiguous response', async () => {
const fetch = vi
.fn()
.mockResolvedValueOnce(
Response.json({ code: 'terms_changed', version: 'B-2' }, { status: 409 })
)
.mockResolvedValueOnce(Response.json({ receiptId: 'ambiguous' }));
vi.stubGlobal('fetch', fetch);
expect(await requestTerms('particle', options, true)).toMatchObject({
success: false,
status: 409,
code: 'terms_changed',
version: 'B-2',
});
await expect(requestTerms('particle', options, true)).rejects.toThrow(
'did not confirm success'
);
expect(fetch).toHaveBeenCalledTimes(2);
});
6 changes: 3 additions & 3 deletions src/commands/alexandria.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,9 +120,9 @@ export async function handleAlexandria(
envelope.data?.alexandria?.some((item: any) => item.error);
if (failed) process.exitCode = 1;
if (envelope.code === 'THIRD_PARTY_DATA_TERMS_REQUIRED') {
console.error(
'Review the provider terms with firecrawl alexandria terms show <provider>. After review, accept with firecrawl alexandria terms accept <provider> --terms-version <version> --digest <sha256> --confirm.'
);
envelope.guidance =
'Review the provider terms with firecrawl alexandria terms show <provider>. Present the terms to the user and wait for explicit approval. Only then accept with firecrawl alexandria terms accept <provider> --terms-version <version> --digest <sha256> --confirm. If the API-provided link is unavailable, use https://www.firecrawl.dev/app/settings?tab=data-sources. Do not automatically retry or accept.';
console.error(envelope.guidance);
}
writeOutput(
JSON.stringify(envelope, null, options.pretty ? 2 : undefined),
Expand Down
20 changes: 18 additions & 2 deletions src/commands/terms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,18 @@ export async function requestTerms(
`Terms endpoint returned non-JSON (HTTP ${response.status}).`
);
if (!response.ok || body.success === false)
return { ...body, success: false, status: response.status };
return {
...body,
success: false,
status: response.status,
...(response.status === 403 && {
guidance: {

@cubic-dev-ai cubic-dev-ai Bot Sep 17, 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: When a 403 is returned, handle emits this guidance only through stdout, so the promised refusal guidance is missing from stderr. Also write the refusal message to stderr while retaining the JSON payload on stdout.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/commands/terms.ts, line 74:

<comment>When a 403 is returned, `handle` emits this guidance only through stdout, so the promised refusal guidance is missing from stderr. Also write the refusal message to stderr while retaining the JSON payload on stdout.</comment>

<file context>
@@ -66,7 +66,18 @@ export async function requestTerms(
+      success: false,
+      status: response.status,
+      ...(response.status === 403 && {
+        guidance: {
+          message:
+            'Terms access was refused. Show this error to the user and ask an organization admin to review access in Settings. Do not retry or accept automatically.',
</file context>
Fix with cubic

message:
'Terms access was refused. Show this error to the user and ask an organization admin to review access in Settings. Do not retry or accept automatically.',
url: 'https://www.firecrawl.dev/app/settings?tab=data-sources',
},
}),
};
if (accept) {
if (body.success !== true)
throw new Error(
Expand All @@ -79,7 +90,12 @@ export async function requestTerms(
const item = body.providers.find((entry: any) => entry.provider === provider);
if (!item)
throw new Error('Provider not found in the accessible terms catalog.');
return { success: true, ...item };
return {
...item,
success: true,
instructions:
'Present the returned terms and any provider document links to the user. Ask for explicit approval before accepting this exact version and digest for their organization. Reading terms does not accept them; stop if approval is absent.',
};
}

async function handle(
Expand Down
Loading