From 436dd427cf2a858520f3e459fcf80ba2bdfe6fb0 Mon Sep 17 00:00:00 2001 From: Raghu Betina Date: Fri, 31 Jul 2026 19:12:38 -0500 Subject: [PATCH] Authenticate CLI API requests The hosted service now scopes Projects by bearer credentials. Require one for every network command so pushes, status polls, compilation requests, and artifact downloads share the same user boundary without persisting the secret locally. Surface the validated server authentication problem as one stable recovery contract for agents. --- README.md | 15 ++++++ scripts/check-pack.js | 1 + scripts/smoke-package.js | 5 ++ src/api-authentication.js | 37 +++++++++++++++ src/cli.js | 92 +++++++++++++++++++++++++++++++++--- test/plan-compile.test.js | 99 ++++++++++++++++++++++++++++++++++++++- test/plan-push.test.js | 68 ++++++++++++++++++++++++++- test/plan-status.test.js | 59 +++++++++++++++++++++++ 8 files changed, 366 insertions(+), 10 deletions(-) create mode 100644 src/api-authentication.js diff --git a/README.md b/README.md index c6ee7ab..496fe4f 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,20 @@ There is intentionally no stable `latest` release yet. Pin an exact prerelease v repeatable installation matters. Remote Plan push, status, and compilation commands require a compatible First Draft service and are currently intended for coordinated trials. +## Authenticate API commands + +Create an API token in First Draft and provide it only through the environment when running a network command: + +```sh +export FIRSTDRAFT_API_TOKEN="your-token" +firstdraft plan push +``` + +`plan push`, `plan status`, and `plan compile` send the token as a Bearer credential on every API request. The CLI +does not save it in `.firstdraft`, print it, or require it for local commands such as `plan init` and +`plan subject-id`. Revoke the token in First Draft if it is exposed. A missing token, or First Draft's validated +`401` problem response with the `authentication_required` code, produces that stable CLI error. + ## Development ```sh @@ -139,6 +153,7 @@ exactly one JSON object to standard error. Agents should branch on its stable `e | `plan init`, `plan subject-id`, `plan push`, `plan status`, `plan compile` | `invalid_arguments` | 2 | The command syntax is invalid; nothing was written and no request was made. | | `plan init` | `local_initialization_failed` | 1 | Local initialization failed. The directory may be incomplete; existing files were not overwritten. | | `plan push`, `plan compile` | `invalid_configuration` | 2 | API configuration or the saved ETag is incompatible with the requested command; no request was made. | +| `plan push`, `plan status`, `plan compile` | `authentication_required` | 1 | `FIRSTDRAFT_API_TOKEN` is missing, or First Draft returned a validated `401` problem with the `authentication_required` code; create or replace the token. | | `plan push`, `plan status`, `plan compile` | `local_input_unreadable` | 1 | The required local Plan or private state could not be read; no request was made. | | `plan status`, `plan compile` | `project_not_pushed` | 1 | Local state is valid but has no pinned remote Project yet; run `plan push` first. | | `plan push`, `plan compile` | `request_outcome_unknown` | 1 | A sent mutation or its response could not be verified. Stop and reconcile instead of retrying it automatically. | diff --git a/scripts/check-pack.js b/scripts/check-pack.js index 0b32b5e..f3bd6f8 100644 --- a/scripts/check-pack.js +++ b/scripts/check-pack.js @@ -25,6 +25,7 @@ if (result.status !== 0) { "README.md", "bin/firstdraft.js", "package.json", + "src/api-authentication.js", "src/api-response.js", "src/cli.js", "src/commands/plan-compile.js", diff --git a/scripts/smoke-package.js b/scripts/smoke-package.js index 3472dde..b0450d1 100644 --- a/scripts/smoke-package.js +++ b/scripts/smoke-package.js @@ -15,6 +15,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; const npmCli = requiredEnvironmentVariable("npm_execpath"); +const apiToken = `fd_${"a".repeat(43)}`; /** @type {{name: string, version: string}} */ const packageMetadata = JSON.parse(readFileSync("package.json", "utf8")); @@ -223,6 +224,7 @@ function spawnPackedCli(arguments_, cwd = process.cwd()) { return spawnSync(process.execPath, [packedExecutable, ...arguments_], { cwd, encoding: "utf8", + env: { ...process.env, FIRSTDRAFT_API_TOKEN: apiToken }, }); } @@ -233,6 +235,7 @@ function spawnPackedCli(arguments_, cwd = process.cwd()) { async function spawnPackedCliAsync(arguments_, cwd) { const child = spawn(process.execPath, [packedExecutable, ...arguments_], { cwd, + env: { ...process.env, FIRSTDRAFT_API_TOKEN: apiToken }, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; @@ -342,6 +345,7 @@ async function exercisePackedCompilation(projectDirectory) { request.method === "POST" && request.url === `/v1/projects/${projectId}/compilations` ) { + assert.equal(request.headers.authorization, `Bearer ${apiToken}`); assert.equal(request.headers["if-match"], `"sha256:${headSha256}"`); assert.equal(requestBody.byteLength, 0); startRequestSeen = true; @@ -349,6 +353,7 @@ async function exercisePackedCompilation(projectDirectory) { return; } if (request.method === "GET" && request.url === artifactPath) { + assert.equal(request.headers.authorization, `Bearer ${apiToken}`); artifactRequestSeen = true; response.writeHead(200, { "Content-Type": "application/vnd.firstdraft.compilation-artifact+json", diff --git a/src/api-authentication.js b/src/api-authentication.js new file mode 100644 index 0000000..afc9e8b --- /dev/null +++ b/src/api-authentication.js @@ -0,0 +1,37 @@ +/** + * @param {typeof globalThis.fetch | undefined} fetchFunction + * @param {string | undefined} apiToken + * @returns {typeof globalThis.fetch | null} + */ +export function authenticatedFetch(fetchFunction, apiToken) { + if (apiToken === undefined || apiToken.trim().length === 0) return null; + + const request = fetchFunction ?? globalThis.fetch; + return (input, init) => + request(input, { + ...init, + headers: { + ...init?.headers, + Authorization: `Bearer ${apiToken}`, + }, + }); +} + +/** + * @param {number | undefined} status + * @param {unknown} response + * @returns {response is Record} + */ +export function isAuthenticationProblem(status, response) { + return ( + status === 401 && + isRecord(response) && + response.status === 401 && + response.code === "authentication_required" + ); +} + +/** @param {unknown} value @returns {value is Record} */ +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/src/cli.js b/src/cli.js index dcd15b0..7466a1d 100644 --- a/src/cli.js +++ b/src/cli.js @@ -1,5 +1,9 @@ import { parseArgs } from "node:util"; +import { + authenticatedFetch, + isAuthenticationProblem, +} from "./api-authentication.js"; import { CompilationArtifactInvalidError, CompilationArtifactResponseInvalidError, @@ -76,7 +80,8 @@ Options: -h, --help Show help Environment: - FIRSTDRAFT_API_URL Override the initial API origin + FIRSTDRAFT_API_TOKEN Authenticate API requests + FIRSTDRAFT_API_URL Override the initial API origin The first successful push saves its API origin in .firstdraft/state.json. Later pushes reject a different origin. @@ -91,6 +96,9 @@ Options: --wait Poll until the current analysis reaches a terminal status -h, --help Show help +Environment: + FIRSTDRAFT_API_TOKEN Authenticate API requests + The command uses only the API origin pinned by a successful plan push. Without --wait, it makes exactly one status request. `; @@ -104,6 +112,9 @@ Options: --output Materialize the generated application here -h, --help Show help +Environment: + FIRSTDRAFT_API_TOKEN Authenticate API requests + The command starts one compilation of the exact Plan ETag pinned by the last successful push, waits up to ten minutes, validates the complete artifact, and atomically renames it into an absent output path. @@ -154,6 +165,8 @@ const PLAN_PUSH_LOCAL_INPUT_UNREADABLE_DETAIL = const PLAN_PUSH_REQUEST_OUTCOME_UNKNOWN_DETAIL = "The Plan may have been accepted, but the response could not be verified. Stop and reconcile before pushing again; local state was not changed."; const PLAN_PUSH_SERVER_REJECTED_DETAIL = "First Draft rejected the Plan."; +const AUTHENTICATION_REQUIRED_DETAIL = + "First Draft authentication is required. Set FIRSTDRAFT_API_TOKEN to an active API token."; const PLAN_STATUS_INVALID_ARGUMENTS_DETAIL = "Invalid arguments. Run 'firstdraft plan status --help' for usage."; const PLAN_STATUS_LOCAL_INPUT_UNREADABLE_DETAIL = @@ -229,6 +242,7 @@ const PLAN_SUBJECT_ID_INVALID_ARGUMENTS_DETAIL = * @property {(delayMs: number) => Promise} [planCompileSleep] * @property {() => number} [planCompileNow] * @property {string} [apiUrl] + * @property {string} [apiToken] */ /** @@ -249,6 +263,7 @@ const PLAN_SUBJECT_ID_INVALID_ARGUMENTS_DETAIL = * @property {(delayMs: number) => Promise} [planCompileSleep] * @property {() => number} [planCompileNow] * @property {string} [apiUrl] + * @property {string} [apiToken] */ /** @@ -274,6 +289,7 @@ export async function run({ planCompileSleep, planCompileNow, apiUrl = process.env.FIRSTDRAFT_API_URL, + apiToken = process.env.FIRSTDRAFT_API_TOKEN, }) { if (argv[0] === "plan") { return runPlan({ @@ -294,6 +310,7 @@ export async function run({ planCompileSleep, planCompileNow, apiUrl, + apiToken, }); } @@ -362,6 +379,7 @@ async function runPlan({ planCompileSleep, planCompileNow, apiUrl, + apiToken, }) { if (argv[0] === "init") { return runPlanInit({ @@ -385,6 +403,7 @@ async function runPlan({ createTemporaryId, createRequestSignal, apiUrl, + apiToken, }); } @@ -399,6 +418,7 @@ async function runPlan({ createRequestSignal, planStatusSleep, planStatusNow, + apiToken, }); } @@ -413,6 +433,7 @@ async function runPlan({ createRequestSignal, planCompileSleep, planCompileNow, + apiToken, }); } @@ -484,7 +505,7 @@ function runPlanSubjectId({ argv, stdout, stderr, createSubjectId }) { } /** - * @param {Pick} options + * @param {Pick} options */ async function runPlanPush({ argv, @@ -496,6 +517,7 @@ async function runPlanPush({ createTemporaryId, createRequestSignal, apiUrl, + apiToken, }) { const parsed = parseArguments(() => parseArgs({ @@ -520,12 +542,18 @@ async function runPlanPush({ return 0; } + const authorizedFetch = authenticatedFetch(fetchFunction, apiToken); + if (authorizedFetch === null) { + writeAuthenticationRequired(stderr); + return 1; + } + let result; try { result = await pushPlan({ cwd, apiUrl, - fetchFunction, + fetchFunction: authorizedFetch, fileSystem: planPushFileSystem, createTemporaryId, createRequestSignal, @@ -583,6 +611,10 @@ async function runPlanPush({ } const response = safeRejectedResponse(result.responseKind, result.body); + if (isAuthenticationProblem(result.status, response)) { + writeAuthenticationRequired(stderr, result.status, response); + return 1; + } writeJson(stderr, { error: "server_rejected", detail: PLAN_PUSH_SERVER_REJECTED_DETAIL, @@ -603,7 +635,7 @@ async function runPlanPush({ } /** - * @param {Pick} options + * @param {Pick} options */ async function runPlanStatus({ argv, @@ -615,6 +647,7 @@ async function runPlanStatus({ createRequestSignal, planStatusSleep, planStatusNow, + apiToken, }) { const parsed = parseArguments(() => parseArgs({ @@ -642,12 +675,18 @@ async function runPlanStatus({ return 0; } + const authorizedFetch = authenticatedFetch(fetchFunction, apiToken); + if (authorizedFetch === null) { + writeAuthenticationRequired(stderr); + return 1; + } + let result; try { result = await readPlanStatus({ cwd, wait: parsed.values.wait, - fetchFunction, + fetchFunction: authorizedFetch, fileSystem: planPushFileSystem, createRequestSignal, sleep: planStatusSleep, @@ -720,6 +759,10 @@ async function runPlanStatus({ } const response = safeRejectedResponse(result.responseKind, result.body); + if (isAuthenticationProblem(result.status, response)) { + writeAuthenticationRequired(stderr, result.status, response); + return 1; + } writeJson(stderr, { error: "server_rejected", detail: PLAN_STATUS_SERVER_REJECTED_DETAIL, @@ -734,7 +777,7 @@ async function runPlanStatus({ } /** - * @param {Pick} options + * @param {Pick} options */ async function runPlanCompile({ argv, @@ -746,6 +789,7 @@ async function runPlanCompile({ createRequestSignal, planCompileSleep, planCompileNow, + apiToken, }) { const parsed = parseArguments(() => parseArgs({ @@ -785,18 +829,38 @@ async function runPlanCompile({ return 2; } + const authorizedFetch = authenticatedFetch(fetchFunction, apiToken); + if (authorizedFetch === null) { + writeAuthenticationRequired(stderr); + return 1; + } + let result; try { result = await compilePlan({ cwd, output: parsed.values.output, - fetchFunction, + fetchFunction: authorizedFetch, fileSystem: planPushFileSystem, createRequestSignal, sleep: planCompileSleep, now: planCompileNow, }); } catch (error) { + if ( + (error instanceof CompilationStartRejectedError || + error instanceof CompilationStatusUnavailableError || + error instanceof CompilationArtifactUnavailableError) && + isAuthenticationProblem(error.status, error.response) + ) { + writeAuthenticationRequired( + stderr, + error.status, + /** @type {Record} */ (error.response), + ); + return 1; + } + if (error instanceof PlanPushLocalError) { writeJson(stderr, { error: "local_input_unreadable", @@ -1101,6 +1165,20 @@ function writeJson(writer, value) { writer.write(`${JSON.stringify(value, null, 2)}\n`); } +/** + * @param {Writer} writer + * @param {number} [status] + * @param {Record} [response] + */ +function writeAuthenticationRequired(writer, status, response) { + writeJson(writer, { + error: "authentication_required", + detail: AUTHENTICATION_REQUIRED_DETAIL, + ...(status === undefined ? {} : { status }), + ...(response === undefined ? {} : { response }), + }); +} + /** * @param {"diagnostics" | "problem" | null} responseKind * @param {Record | null} body diff --git a/test/plan-compile.test.js b/test/plan-compile.test.js index 67cea26..23822f5 100644 --- a/test/plan-compile.test.js +++ b/test/plan-compile.test.js @@ -28,6 +28,7 @@ const ANALYSIS_ID = "01900000-0000-7000-8000-000000000705"; const SUBJECT_ID = "01900000-0000-7000-8000-000000000706"; const HEAD_SHA256 = "1".repeat(64); const ETAG = `"sha256:${HEAD_SHA256}"`; +const API_TOKEN = `fd_${"a".repeat(43)}`; const CREATED_AT = "2026-07-30T12:00:00.000Z"; const STARTED_AT = "2026-07-30T12:00:01.000Z"; const COMPLETED_AT = "2026-07-30T12:00:02.000Z"; @@ -45,6 +46,9 @@ Options: --output Materialize the generated application here -h, --help Show help +Environment: + FIRSTDRAFT_API_TOKEN Authenticate API requests + The command starts one compilation of the exact Plan ETag pinned by the last successful push, waits up to ten minutes, validates the complete artifact, and atomically renames it into an absent output path. @@ -139,6 +143,7 @@ test("plan compile uses one pinned POST, sequential polling, and one artifact GE assert(start); assert.equal(start.body.byteLength, 0); assert.equal(start.headers["if-match"], ETAG); + assert.equal(start.headers.authorization, `Bearer ${API_TOKEN}`); assert.equal( start.headers.accept, "application/json, application/problem+json", @@ -150,12 +155,14 @@ test("plan compile uses one pinned POST, sequential polling, and one artifact GE statusRequest.headers.accept, "application/json, application/problem+json", ); + assert.equal(statusRequest.headers.authorization, `Bearer ${API_TOKEN}`); } assert(artifactRequest); assert.equal( artifactRequest.headers.accept, `${ARTIFACT_MEDIA_TYPE}, application/problem+json`, ); + assert.equal(artifactRequest.headers.authorization, `Bearer ${API_TOKEN}`); assert.equal( readFileSync(path.join(output, "app/models/movie.rb"), "utf8"), "class Movie < ApplicationRecord\nend\n", @@ -310,6 +317,90 @@ test("a validated start rejection is safe and does not poll", async (context) => assert.equal(result.status, 1); }); +test("missing credentials and validated 401 responses use one stable authentication error", async (context) => { + const missingDirectory = remoteDirectory(context, "https://api.example.test"); + let requests = 0; + const missing = await invoke( + ["plan", "compile", "--output", "missing-auth-output"], + { + cwd: missingDirectory, + apiToken: "", + fetchFunction: async () => { + requests += 1; + throw new Error("request must not be sent"); + }, + }, + ); + + assert.deepEqual(JSON.parse(missing.stderr), { + error: "authentication_required", + detail: + "First Draft authentication is required. Set FIRSTDRAFT_API_TOKEN to an active API token.", + }); + assert.equal(missing.status, 1); + assert.equal(requests, 0); + + const stages = [ + [ + problemResponse( + 401, + "authentication_required", + "Provide a valid API token.", + ), + ], + [ + jsonResponse(compilationBody("queued"), 202, { + Location: STATUS_PATH, + }), + problemResponse( + 401, + "authentication_required", + "Provide a valid API token.", + ), + ], + [ + jsonResponse( + compilationBody("succeeded", { artifact: artifactFixture() }), + 202, + { Location: STATUS_PATH }, + ), + problemResponse( + 401, + "authentication_required", + "Provide a valid API token.", + ), + ], + ]; + + for (const [index, responses] of stages.entries()) { + const cwd = remoteDirectory(context, `https://api-${index}.example.test`); + const result = await invoke( + ["plan", "compile", "--output", `auth-output-${index}`], + { + cwd, + fetchFunction: sequenceFetch(responses), + planCompileSleep: async () => undefined, + }, + ); + + assert.deepEqual(JSON.parse(result.stderr), { + error: "authentication_required", + detail: + "First Draft authentication is required. Set FIRSTDRAFT_API_TOKEN to an active API token.", + status: 401, + response: { + type: "about:blank", + title: "Unauthorized", + status: 401, + code: "authentication_required", + detail: "Provide a valid API token.", + }, + }); + assert.equal(result.status, 1); + assert.equal(result.stderr.includes(API_TOKEN), false); + } +}); + test("an unvalidated non-success start response remains ambiguous", async (context) => { const cwd = remoteDirectory(context, "https://api.example.test"); /** @type {unknown[]} */ @@ -935,7 +1026,12 @@ function problemResponse(status, code, detail) { return new Response( JSON.stringify({ type: "about:blank", - title: status === 409 ? "Conflict" : "Service Unavailable", + title: + status === 401 + ? "Unauthorized" + : status === 409 + ? "Conflict" + : "Service Unavailable", status, code, detail, @@ -1025,6 +1121,7 @@ async function invoke(argv, options = {}) { argv, stdout: { write: (text) => (stdout += text) }, stderr: { write: (text) => (stderr += text) }, + apiToken: API_TOKEN, ...options, }); return { status, stdout, stderr }; diff --git a/test/plan-push.test.js b/test/plan-push.test.js index 857a551..1ee993c 100644 --- a/test/plan-push.test.js +++ b/test/plan-push.test.js @@ -30,6 +30,7 @@ import { const PROJECT_ID = "01900000-0000-7000-8000-000000000301"; const API_URL = "https://api.example.test"; +const API_TOKEN = `fd_${"a".repeat(43)}`; const FIRST_ETAG = '"opaque:first-validator"'; const SECOND_ETAG = '"opaque:second-validator"'; const PLAN_PUSH_HELP = `First Draft CLI @@ -41,7 +42,8 @@ Options: -h, --help Show help Environment: - FIRSTDRAFT_API_URL Override the initial API origin + FIRSTDRAFT_API_TOKEN Authenticate API requests + FIRSTDRAFT_API_URL Override the initial API origin The first successful push saves its API origin in .firstdraft/state.json. Later pushes reject a different origin. @@ -118,6 +120,7 @@ test("the initial push sends exact bytes and saves its origin and ETag", async ( ); assert.equal(headers.get("if-none-match"), "*"); assert.equal(headers.has("if-match"), false); + assert.equal(headers.get("authorization"), `Bearer ${API_TOKEN}`); assert(Buffer.isBuffer(call.init?.body)); assert.deepEqual(call.init.body, source); @@ -444,6 +447,61 @@ test("server rejection envelopes expose only validated response fields", async ( assert.doesNotMatch(problemResult.stderr, /canary-secret/); }); +test("missing credentials and a validated 401 use one stable authentication error", async (context) => { + const cwd = await initializedDirectory(context); + let requests = 0; + for (const apiToken of ["", " \t\n"]) { + const missing = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + apiToken, + fetchFunction: async () => { + requests += 1; + throw new Error("request must not be sent"); + }, + }); + + assert.deepEqual(JSON.parse(missing.stderr), { + error: "authentication_required", + detail: + "First Draft authentication is required. Set FIRSTDRAFT_API_TOKEN to an active API token.", + }); + assert.equal(missing.status, 1); + } + assert.equal(requests, 0); + + const problem = { + type: "about:blank", + title: "Unauthorized", + status: 401, + code: "authentication_required", + detail: "Provide a valid API token.", + canary: "canary-secret-response-field", + }; + const rejected = await invoke(["plan", "push"], { + cwd, + apiUrl: API_URL, + fetchFunction: recordingFetch(problemResponse(problem, 401), []), + }); + + assert.deepEqual(JSON.parse(rejected.stderr), { + error: "authentication_required", + detail: + "First Draft authentication is required. Set FIRSTDRAFT_API_TOKEN to an active API token.", + status: 401, + response: { + type: "about:blank", + title: "Unauthorized", + status: 401, + code: "authentication_required", + detail: "Provide a valid API token.", + }, + }); + assert.equal(rejected.status, 1); + assert.doesNotMatch(rejected.stderr, /canary-secret/); + assert.equal(rejected.stderr.includes(API_TOKEN), false); +}); + test("a stale update preserves the prior ETag and exact local state", async (context) => { const cwd = await initializedDirectory(context); await successfulInitialPush(cwd); @@ -1085,7 +1143,11 @@ test("the packaged executable completes a real local HTTP push", async (context) ); const child = spawn(process.execPath, [executable, "plan", "push"], { cwd, - env: { ...process.env, FIRSTDRAFT_API_URL: apiUrl }, + env: { + ...process.env, + FIRSTDRAFT_API_TOKEN: API_TOKEN, + FIRSTDRAFT_API_URL: apiUrl, + }, stdio: ["ignore", "pipe", "pipe"], }); child.stdout.setEncoding("utf8"); @@ -1105,6 +1167,7 @@ test("the packaged executable completes a real local HTTP push", async (context) requestHeaders?.["content-type"], "application/vnd.firstdraft.foundation-plan+json", ); + assert.equal(requestHeaders?.authorization, `Bearer ${API_TOKEN}`); assert.equal(readState(cwd).api_url, apiUrl); assert.equal(readState(cwd).foundation_plan_etag, FIRST_ETAG); }); @@ -1120,6 +1183,7 @@ async function invoke(argv, overrides = {}) { argv, stdout: { write: (text) => (stdout += text) }, stderr: { write: (text) => (stderr += text) }, + apiToken: API_TOKEN, ...overrides, }); diff --git a/test/plan-status.test.js b/test/plan-status.test.js index 47690c5..c01f222 100644 --- a/test/plan-status.test.js +++ b/test/plan-status.test.js @@ -24,6 +24,7 @@ const ANALYSIS_ID = "01900000-0000-7000-8000-000000000401"; const OTHER_ANALYSIS_ID = "01900000-0000-7000-8000-000000000402"; const SUBJECT_ID = "01900000-0000-7000-8000-000000000501"; const API_URL = "https://api.example.test"; +const API_TOKEN = `fd_${"a".repeat(43)}`; const ETAG = '"opaque:plan-validator"'; const STARTED_AT = "2026-07-30T12:00:00.123Z"; const COMPLETED_AT = "2026-07-30T12:00:01.456Z"; @@ -36,6 +37,9 @@ Options: --wait Poll until the current analysis reaches a terminal status -h, --help Show help +Environment: + FIRSTDRAFT_API_TOKEN Authenticate API requests + The command uses only the API origin pinned by a successful plan push. Without --wait, it makes exactly one status request. `; @@ -134,6 +138,7 @@ test("plan status makes one bounded GET to only the pinned origin", async (conte new Headers(call.init?.headers), new Headers({ Accept: "application/json, application/problem+json", + Authorization: `Bearer ${API_TOKEN}`, }), ); assert.equal(call.init?.body, undefined); @@ -604,6 +609,57 @@ test("validated problem responses are whitelisted for agent recovery", async (co assert.doesNotMatch(result.stderr, /canary-secret/); }); +test("missing credentials and a validated 401 use one stable authentication error", async (context) => { + const cwd = remoteDirectory(context); + let requests = 0; + const missing = await invoke(["plan", "status"], { + cwd, + apiToken: "", + fetchFunction: async () => { + requests += 1; + throw new Error("request must not be sent"); + }, + }); + + assert.deepEqual(JSON.parse(missing.stderr), { + error: "authentication_required", + detail: + "First Draft authentication is required. Set FIRSTDRAFT_API_TOKEN to an active API token.", + }); + assert.equal(missing.status, 1); + assert.equal(requests, 0); + + const problem = { + type: "about:blank", + title: "Unauthorized", + status: 401, + code: "authentication_required", + detail: "Provide a valid API token.", + canary: "canary-secret-response-field", + }; + const rejected = await invoke(["plan", "status"], { + cwd, + fetchFunction: recordingFetch([problemResponse(problem, 401)], []), + }); + + assert.deepEqual(JSON.parse(rejected.stderr), { + error: "authentication_required", + detail: + "First Draft authentication is required. Set FIRSTDRAFT_API_TOKEN to an active API token.", + status: 401, + response: { + type: "about:blank", + title: "Unauthorized", + status: 401, + code: "authentication_required", + detail: "Provide a valid API token.", + }, + }); + assert.equal(rejected.status, 1); + assert.doesNotMatch(rejected.stderr, /canary-secret/); + assert.equal(rejected.stderr.includes(API_TOKEN), false); +}); + test("invalid HTTP responses are non-retryable and never expose their body", async (context) => { for (const response of [ new Response("canary-secret-json", { @@ -930,6 +986,7 @@ test("the packaged executable polls a real local analysis endpoint", async (cont cwd, env: { ...process.env, + FIRSTDRAFT_API_TOKEN: API_TOKEN, FIRSTDRAFT_API_URL: "https://canary-secret.example", }, stdio: ["ignore", "pipe", "pipe"], @@ -951,6 +1008,7 @@ test("the packaged executable polls a real local analysis endpoint", async (cont requestHeaders?.accept, "application/json, application/problem+json", ); + assert.equal(requestHeaders?.authorization, `Bearer ${API_TOKEN}`); assert.doesNotMatch(stdout, /canary-secret/); }); @@ -965,6 +1023,7 @@ async function invoke(argv, overrides = {}) { argv, stdout: { write: (text) => (stdout += text) }, stderr: { write: (text) => (stderr += text) }, + apiToken: API_TOKEN, ...overrides, });