diff --git a/scripts/packed-core-release-acceptance.mjs b/scripts/packed-core-release-acceptance.mjs index 63ab1d6..39c4587 100644 --- a/scripts/packed-core-release-acceptance.mjs +++ b/scripts/packed-core-release-acceptance.mjs @@ -192,6 +192,15 @@ function json(response, status, body) { response.end(payload); } +function htmlNotFound(response) { + const body = "404: This page could not be found."; + response.writeHead(404, { + "content-type": "text/html; charset=utf-8", + "content-length": Buffer.byteLength(body) + }); + response.end(body); +} + function readBody(request) { return new Promise((resolveBody, reject) => { const chunks = []; @@ -666,7 +675,7 @@ cases: return; } await readBody(request); - json(response, 404, { error: { code: "not_found" } }); + htmlNotFound(response); }, async (mock) => { const env = { ...baseEnv, @@ -755,7 +764,7 @@ cases: return; } await readBody(request); - json(response, 404, { error: { code: "not_found" } }); + htmlNotFound(response); }, async (mock) => { const env = { ...baseEnv, @@ -794,7 +803,7 @@ cases: return; } await readBody(request); - json(response, 404, { error: { code: "not_found" } }); + htmlNotFound(response); }, async (mock) => { const env = { ...baseEnv, diff --git a/scripts/packed-real-data-privacy.mjs b/scripts/packed-real-data-privacy.mjs index 414d93f..5118596 100644 --- a/scripts/packed-real-data-privacy.mjs +++ b/scripts/packed-real-data-privacy.mjs @@ -387,8 +387,12 @@ async function main() { response.end(payload); return; } - response.writeHead(404, { "content-type": "application/json" }); - response.end(JSON.stringify({ error: "not found" })); + const missing = "404: This page could not be found."; + response.writeHead(404, { + "content-type": "text/html; charset=utf-8", + "content-length": Buffer.byteLength(missing) + }); + response.end(missing); }); await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); const origin = `http://127.0.0.1:${server.address().port}`; diff --git a/scripts/packed-report-fixture.mjs b/scripts/packed-report-fixture.mjs index b5766cf..f2ecf65 100644 --- a/scripts/packed-report-fixture.mjs +++ b/scripts/packed-report-fixture.mjs @@ -80,6 +80,15 @@ function send(response, status, value) { response.end(body); } +function sendHtmlNotFound(response) { + const body = "404: This page could not be found."; + response.writeHead(404, { + "content-type": "text/html; charset=utf-8", + "content-length": Buffer.byteLength(body) + }); + response.end(body); +} + function cloneMutate(fixtures, name, origin, mutate) { const fixture = fixtureNamed(fixtures, name, origin); const body = JSON.parse(JSON.stringify(fixture.body)); @@ -213,7 +222,7 @@ function startFixtureServer(fixtures, producer) { send(response, fixture.status, fixture.body); return; } - send(response, 404, { error: { code: "NOT_FOUND", message: "missing packed report fixture route" } }); + sendHtmlNotFound(response); }); return { httpServer, requests, state }; } diff --git a/scripts/packed-saved-suite-release.mjs b/scripts/packed-saved-suite-release.mjs index 697c374..1c5841e 100644 --- a/scripts/packed-saved-suite-release.mjs +++ b/scripts/packed-saved-suite-release.mjs @@ -297,6 +297,15 @@ function send(response, status, value) { response.end(body); } +function sendHtmlNotFound(response) { + const body = "404: This page could not be found."; + response.writeHead(404, { + "content-type": "text/html; charset=utf-8", + "content-length": Buffer.byteLength(body) + }); + response.end(body); +} + function rewriteOrigin(value, from, to) { if (typeof value === "string") return value.split(from).join(to); if (Array.isArray(value)) return value.map((item) => rewriteOrigin(item, from, to)); @@ -491,7 +500,7 @@ function startServer(billing, reports, producer) { ); return; } - send(response, 404, { error: { code: "NOT_FOUND", message: `${request.method} ${url.pathname}` } }); + sendHtmlNotFound(response); }); }); return { httpServer, requests, counts }; diff --git a/scripts/smoke-pack.mjs b/scripts/smoke-pack.mjs index 24076c3..1ed5344 100644 --- a/scripts/smoke-pack.mjs +++ b/scripts/smoke-pack.mjs @@ -475,8 +475,12 @@ async function assertPackedSelectionCompile(packedBin, consumerDirectory) { response.end(payload); return; } - response.writeHead(404, { "content-type": "application/json" }); - response.end(JSON.stringify({ error: { code: "NOT_FOUND", message: "missing" } })); + const missing = "404: This page could not be found."; + response.writeHead(404, { + "content-type": "text/html; charset=utf-8", + "content-length": Buffer.byteLength(missing) + }); + response.end(missing); }); }); diff --git a/src/cloud/client.ts b/src/cloud/client.ts index 2a745d7..35e4294 100644 --- a/src/cloud/client.ts +++ b/src/cloud/client.ts @@ -719,10 +719,7 @@ export class CloudClient { if (!looksLikeExecutionScopeResponse(value)) return undefined; return parseExecutionScopeResponse(value); } catch (error) { - if ( - error instanceof AwError && - (error.details?.["http_status"] === 404 || error.code === "CLOUD_REQUEST_FAILED") - ) { + if (error instanceof AwError && error.details?.["http_status"] === 404) { return undefined; } throw error; @@ -871,15 +868,20 @@ export class CloudClient { externalSignal?.removeEventListener("abort", onAbort); } let value: unknown; + let nonJsonErrorBody = false; try { value = text.length === 0 ? undefined : JSON.parse(text); } catch (error) { - throw new AwError({ - code: "INVALID_CLOUD_RESPONSE", - category: "protocol", - message: "AugmentWorks returned an invalid JSON response.", - cause: error - }); + if (response.ok || errorFamily === "billing") { + throw new AwError({ + code: "INVALID_CLOUD_RESPONSE", + category: "protocol", + message: "AugmentWorks returned an invalid JSON response.", + cause: error + }); + } + value = undefined; + nonJsonErrorBody = true; } if (value !== undefined) assertJsonLimits(value, "AugmentWorks response"); if (response.status === 401 && allowAuthRefresh && this.#accessTokenProvider !== undefined) { @@ -915,7 +917,7 @@ export class CloudClient { profileRecoveryUrl(this.apiUrl) ); } - throw cloudHttpError( + const httpError = cloudHttpError( response.status, value, method, @@ -923,6 +925,8 @@ export class CloudClient { profileRecoveryUrl(this.apiUrl), response.headers.get("retry-after") ); + if (!nonJsonErrorBody) throw httpError; + throw nameNonJsonRoute(httpError, method, path); } if (value === undefined) { throw new AwError({ @@ -1099,6 +1103,20 @@ function responseTooLarge(): AwError { }); } +function nameNonJsonRoute(error: AwError, method: "GET" | "POST", path: string): AwError { + if (error.message.includes(method) && error.message.includes(path)) return error; + return new AwError({ + code: error.code, + category: error.category, + message: `${error.message} ${method} ${path}`, + retryable: error.retryable, + ...(error.operation === undefined ? {} : { operation: error.operation }), + ...(error.commandId === undefined ? {} : { commandId: error.commandId }), + ...(error.details === undefined ? {} : { details: error.details }), + cause: error + }); +} + function cloudHttpError( status: number, value: unknown, diff --git a/src/real-data/hosted.ts b/src/real-data/hosted.ts index 8d9016b..99d1f42 100644 --- a/src/real-data/hosted.ts +++ b/src/real-data/hosted.ts @@ -25,29 +25,30 @@ import { assertBindingMatches } from "./scope-store.js"; -export async function requireHostedAuthorizedScope( - cloud: CloudClient, - ref: SuiteExecutionScopeRef, +export async function assertHostedScopeUsable( + response: ExecutionScopeResponse, options: { + readonly cloud: CloudClient; readonly workspaceId: string; readonly resolved?: ResolvedConfig; readonly signal?: AbortSignal; readonly now?: number; + readonly revision?: number; + readonly scopeHash?: string; } -): Promise { - const capabilities = await cloud.getRealDataCapabilities(options.signal); +): Promise { + const capabilities = await options.cloud.getRealDataCapabilities(options.signal); assertHostedRealDataRelease(capabilities); - const response = await cloud.getExecutionScope(ref.scopeId, options.signal); if (response.scope.workspaceId !== options.workspaceId) { throw realDataError( "TARGET_SCOPE_MISMATCH", "The admitted execution scope belongs to a different workspace." ); } - if (ref.revision !== undefined && response.scope.revision !== ref.revision) { + if (options.revision !== undefined && response.scope.revision !== options.revision) { throw realDataError("EXECUTION_SCOPE_STALE", "The admitted scope revision does not match the suite binding."); } - if (ref.scopeHash !== undefined && response.scope.scopeHash !== ref.scopeHash) { + if (options.scopeHash !== undefined && response.scope.scopeHash !== options.scopeHash) { throw realDataError("EXECUTION_SCOPE_STALE", "The admitted scope hash does not match the suite binding."); } if (response.availability === "revoked") { @@ -76,6 +77,28 @@ export async function requireHostedAuthorizedScope( if (options.resolved !== undefined) { assertBoundaryMatchesConfig(response.targetBoundary, options.resolved); } +} + +export async function requireHostedAuthorizedScope( + cloud: CloudClient, + ref: SuiteExecutionScopeRef, + options: { + readonly workspaceId: string; + readonly resolved?: ResolvedConfig; + readonly signal?: AbortSignal; + readonly now?: number; + } +): Promise { + const response = await cloud.getExecutionScope(ref.scopeId, options.signal); + await assertHostedScopeUsable(response, { + cloud, + workspaceId: options.workspaceId, + ...(options.resolved === undefined ? {} : { resolved: options.resolved }), + ...(options.signal === undefined ? {} : { signal: options.signal }), + ...(options.now === undefined ? {} : { now: options.now }), + ...(ref.revision === undefined ? {} : { revision: ref.revision }), + ...(ref.scopeHash === undefined ? {} : { scopeHash: ref.scopeHash }) + }); return response; } @@ -241,11 +264,18 @@ export async function resolveDispatchPolicyForBinding(options: { return dispatchPolicyFromHostedScope(response); } + if (!packetRequiresHostedScope(options.binding.packet)) { + return undefined; + } + const fromRun = await options.cloud.getRunExecutionScope(options.binding.run_id, options.signal); if (fromRun !== undefined) { - if (options.resolved !== undefined) { - assertBoundaryMatchesConfig(fromRun.targetBoundary, options.resolved); - } + await assertHostedScopeUsable(fromRun, { + cloud: options.cloud, + workspaceId: options.workspaceId, + ...(options.resolved === undefined ? {} : { resolved: options.resolved }), + ...(options.signal === undefined ? {} : { signal: options.signal }) + }); await persistHostedScopeForRun({ runId: options.binding.run_id, configSha256: options.binding.config_sha256, @@ -256,11 +286,8 @@ export async function resolveDispatchPolicyForBinding(options: { return dispatchPolicyFromHostedScope(fromRun); } - if (packetRequiresHostedScope(options.binding.packet)) { - throw realDataError( - "INVALID_EXECUTION_SCOPE", - "This authorized run has no persisted or server-admitted execution scope. Re-admit from the original suite, assessment, or manifest; do not reconstruct authority from YAML." - ); - } - return undefined; + throw realDataError( + "INVALID_EXECUTION_SCOPE", + "This authorized run has no persisted or server-admitted execution scope. Re-admit from the original suite, assessment, or manifest; do not reconstruct authority from YAML." + ); } diff --git a/src/real-data/index.ts b/src/real-data/index.ts index f97bf33..1f3be22 100644 --- a/src/real-data/index.ts +++ b/src/real-data/index.ts @@ -105,6 +105,7 @@ export { localBinding } from "./scope-store.js"; export { + assertHostedScopeUsable, requireHostedAuthorizedScope, dispatchPolicyFromHostedScope, persistHostedScopeForRun, diff --git a/test/catalog/cli.test.ts b/test/catalog/cli.test.ts index d5b79ed..5c8a722 100644 --- a/test/catalog/cli.test.ts +++ b/test/catalog/cli.test.ts @@ -66,7 +66,12 @@ describe("catalog CLI", () => { send(response, 200, current?.response, current?.headers); return; } - send(response, 404, { error: { code: "not_found", message: "missing" } }); + const missing = "404: This page could not be found."; + response.writeHead(404, { + "content-type": "text/html; charset=utf-8", + "content-length": Buffer.byteLength(missing) + }); + response.end(missing); }); const state = await mkdtemp(join(tmpdir(), "aw-catalog-")); temporaryDirectories.push(state); diff --git a/test/cloud/request-errors.test.ts b/test/cloud/request-errors.test.ts new file mode 100644 index 0000000..19be00d --- /dev/null +++ b/test/cloud/request-errors.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it, vi } from "vitest"; + +import { CloudClient } from "../../src/cloud/client.js"; + +const HTML_NOT_FOUND = "404: This page could not be found."; + +function htmlResponse(status: number): Response { + return new Response(HTML_NOT_FOUND, { + status, + headers: { "content-type": "text/html; charset=utf-8" } + }); +} + +function client(fetchMock: typeof fetch): CloudClient { + return new CloudClient({ + apiUrl: "http://127.0.0.1:8787", + accessToken: "project-token", + fetch: fetchMock + }); +} + +describe("cloud HTML error pages", () => { + it("keeps http_status on an HTML 404 instead of INVALID_CLOUD_RESPONSE", async () => { + const fetchMock = vi.fn(async () => htmlResponse(404)); + const error = await client(fetchMock).getRunStatus("run-html").then( + () => { + throw new Error("expected HTML 404 to fail"); + }, + (caught: unknown) => caught + ); + expect(error).toMatchObject({ + code: "CLOUD_REQUEST_FAILED", + details: { + http_status: 404, + http_method: "GET", + http_path: "/v1/relay/runs/run-html" + } + }); + expect(error).not.toMatchObject({ code: "INVALID_CLOUD_RESPONSE" }); + expect((error as { message: string }).message).toContain("GET /v1/relay/runs/run-html"); + }); + + it("still rejects an HTML 200 as INVALID_CLOUD_RESPONSE", async () => { + const fetchMock = vi.fn(async () => htmlResponse(200)); + await expect(client(fetchMock).getRunStatus("run-html")).rejects.toMatchObject({ + code: "INVALID_CLOUD_RESPONSE", + message: "AugmentWorks returned an invalid JSON response." + }); + }); + + it("keeps a billing HTML error body on the existing invalid JSON path", async () => { + const fetchMock = vi.fn(async () => htmlResponse(502)); + await expect(client(fetchMock).getBillingUsage()).rejects.toMatchObject({ + code: "INVALID_CLOUD_RESPONSE", + message: "AugmentWorks returned an invalid JSON response." + }); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/investigation/admission.test.ts b/test/investigation/admission.test.ts index 47539fd..5d66ead 100644 --- a/test/investigation/admission.test.ts +++ b/test/investigation/admission.test.ts @@ -4,6 +4,7 @@ import { resolve } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { runEstimate, runTest } from "../../src/commands/test.js"; +import type { RelayRunner } from "../../src/relay/runner.js"; import { runReleasePolicyCommand } from "../../src/baseline/execute.js"; import { inspectInvestigation, fetchInvestigation } from "../../src/investigation/execute.js"; import { canonicalize, sha256 } from "../../src/util/canonical.js"; @@ -606,4 +607,87 @@ describe("investigation reproduction admission", () => { expect(paths.some((path) => path.startsWith("POST /v1/runs/"))).toBe(true); expect(paths.some((path) => path.includes("/v1/relay/runs"))).toBe(false); }); + + it("completes an investigation rerun against an HTML 404 fallback", async () => { + const cwd = await tempDir("aw-inv-html-"); + temporaryDirectories.push(cwd); + await copyInvestigation("response-only.json", cwd, "investigation.json"); + const paths: string[] = []; + const fetchMock = vi.fn(async (input, init) => { + const url = new URL(String(input)); + paths.push(`${init?.method ?? "GET"} ${url.pathname}`); + const body = init?.body === undefined ? undefined : (JSON.parse(String(init.body)) as Record); + if (url.pathname === `/v1/suites/${FAQ_SUITE_ID}/revisions/${FAQ_REVISION}`) { + return Response.json({ + suiteId: FAQ_SUITE_ID, + revisionId: FAQ_REVISION, + contentHash: FAQ_HASH, + canonicalDocument: faqPinnedDocument() + }); + } + if (url.pathname === "/v1/billing/capabilities") { + return Response.json(billingFixtures.fixtures["eligible_trial"]?.response); + } + if (url.pathname === "/v1/billing/quote") { + return Response.json(billingFixtures.fixtures["quote_success_with_balance"]?.response); + } + if (url.pathname === "/v1/relay/runs") { + const request = body ?? {}; + return Response.json({ + protocol_version: "aw-relay/0.3", + create_request_id: request["create_request_id"], + create_request_sha256: sha256(canonicalize(request)), + create_disposition: "created", + run_id: "run-repro-faq", + session_id: "session-1", + packet: { key: "aw-customer-suite", version: "1.0.0", sha256: "a".repeat(64) }, + config_sha256: request["config_sha256"], + fencing_epoch: 1, + status: "running", + dashboard_url: "http://127.0.0.1:8787/portal/runs/run-repro-faq", + run_expires_at: "2099-09-06T00:00:00.000Z", + credit_state: "reserved" + }); + } + const missing = "404: This page could not be found."; + return new Response(missing, { + status: 404, + headers: { "content-type": "text/html; charset=utf-8" } + }); + }); + const result = await runTest( + { + cwd, + investigation: "investigation.json", + maxCredits: "30", + yes: true, + stateDirectory: cwd, + handleSignals: false, + env: { AUGMENTWORKS_API_URL: "http://127.0.0.1:8787" } + }, + { + doctor: doctorFor(), + isInteractive: () => false, + accessToken: async () => "token", + identity: async () => identity(), + cloud: hostedCloud(fetchMock), + runner: () => + ({ + run: async () => ({ + protocol_version: "aw-relay/0.1", + run_id: "run-repro-faq", + status: "completed", + credit_state: "reserved", + outcome: "passed" + }), + requestCancellation: async () => { + throw new Error("investigation rerun must not cancel"); + } + }) as unknown as RelayRunner + } + ); + expect(result.binding.run_id).toBe("run-repro-faq"); + expect(result.run.status).toBe("completed"); + expect(paths.some((path) => path.includes("execution-scope"))).toBe(false); + }); }); diff --git a/test/real-data/contract.test.ts b/test/real-data/contract.test.ts index d77f290..32eecb9 100644 --- a/test/real-data/contract.test.ts +++ b/test/real-data/contract.test.ts @@ -3,15 +3,19 @@ import { mkdtemp, readFile, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CloudClient } from "../../src/cloud/client.js"; +import { resolveConfig } from "../../src/config/resolve.js"; +import type { AugmentWorksConfig } from "../../src/config/types.js"; import { AW_REAL_DATA_CONTRACT } from "../../src/real-data/generated/contract.js"; import { parseCapabilities, parseExecutionScope, parseExecutionScopeResponse, parseLocalExecutionScope, - looksLikeHostedExecutionScope + looksLikeHostedExecutionScope, + type ExecutionScopeResponse } from "../../src/real-data/documents.js"; import { assertHostedRealDataRelease, DISABLED_REAL_DATA_CAPABILITIES } from "../../src/real-data/capabilities.js"; import { applyRedactionProfile, failClosedPrivacyService, minimizeForUpload, registerPrivacyService } from "../../src/real-data/privacy.js"; @@ -29,6 +33,7 @@ import type { RelayCommand } from "../../src/cloud/protocol.js"; import { AwError } from "../../src/errors.js"; import { ADVERTISED_DISABLED_CAPABILITIES, + ENABLED_CAPABILITIES, FUTURE, hostedScopeResponse, localAuthorizedPacketManifest, @@ -36,6 +41,7 @@ import { canaryRedactionPolicy, sealedBoundary, sealedLocalScope, + UUID_A, UUID_C } from "./helpers.js"; @@ -363,3 +369,255 @@ describe("local authorized packets and private targets", () => { ).rejects.toMatchObject({ code: "INVALID_EXECUTION_SCOPE" }); }); }); + +const HTML_NOT_FOUND = "404: This page could not be found."; + +function resolvedTarget(baseUrl: string, path = "/chat") { + const config: AugmentWorksConfig = { + version: 1, + target: { + name: "support", + connector: "http", + base_url: baseUrl, + operations: { + send: { + method: "POST", + path, + request: { message: "$input.message" }, + response: { content: "$.answer" } + } + } + } + }; + const inspection = resolveConfig(config, "/tmp/augmentworks.yaml", "/tmp", {}); + if (inspection.resolvedConfig === undefined) { + throw new Error(`test config did not resolve: ${JSON.stringify(inspection.diagnostics)}`); + } + return inspection.resolvedConfig; +} + +function runBinding(packet: { readonly key: string; readonly version: string }, runId = "run-scope") { + return { + protocol_version: "aw-relay/0.1" as const, + create_request_id: "crq_aaaaaaaaaaaaaaaaaaaaaaaa", + create_request_sha256: "c".repeat(64), + create_disposition: "created" as const, + run_id: runId, + session_id: "session-1", + packet: { ...packet, sha256: "d".repeat(64) }, + config_sha256: "a".repeat(64), + fencing_epoch: 1, + status: "connected" as const, + dashboard_url: `https://example.test/runs/${runId}`, + run_expires_at: FUTURE, + credit_state: "reserved" as const + }; +} + +describe("hosted run scope lookup", () => { + it("does not request a scope for a synthetic or v2 binding", async () => { + const calls: string[] = []; + const cloud = { + async getRealDataCapabilities() { + calls.push("capabilities"); + return ENABLED_CAPABILITIES; + }, + async getRunExecutionScope() { + calls.push("scope"); + return hostedScopeResponse(); + } + }; + for (const packet of [ + { key: "support-refunds", version: "0.1.0" }, + { key: "aw-customer-suite", version: "2.0.0" } + ]) { + await expect( + resolveDispatchPolicyForBinding({ + cloud: cloud as never, + binding: runBinding(packet), + workspaceId: UUID_C + }) + ).resolves.toBeUndefined(); + } + expect(calls).toEqual([]); + }); + + it("uses a looked-up scope that passes the shared checks", async () => { + const directory = await mkdtemp(join(tmpdir(), "aw-scope-lookup-")); + temporaryDirectories.push(directory); + const response = hostedScopeResponse(); + const calls: string[] = []; + const cloud = { + async getRealDataCapabilities() { + calls.push("capabilities"); + return ENABLED_CAPABILITIES; + }, + async getRunExecutionScope() { + calls.push("scope"); + return response; + } + }; + const policy = await resolveDispatchPolicyForBinding({ + cloud: cloud as never, + binding: runBinding({ key: "aw-customer-suite", version: "3.0.0" }, "run-looked-up"), + workspaceId: UUID_C, + resolved: resolvedTarget("https://support.example.com"), + stateDirectory: directory + }); + expect(calls).toEqual(["scope", "capabilities"]); + expect(policy).toMatchObject({ + kind: "authorized", + origin: "https://support.example.com" + }); + }); + + it.each([ + { + name: "release capability", + code: "EXECUTION_RELEASE_UNAVAILABLE", + capabilities: ADVERTISED_DISABLED_CAPABILITIES, + response: () => hostedScopeResponse() + }, + { + name: "workspace", + code: "TARGET_SCOPE_MISMATCH", + workspaceId: UUID_A, + response: () => hostedScopeResponse() + }, + { + name: "revoked availability", + code: "TARGET_AUTHORITY_REVOKED", + response: () => ({ ...hostedScopeResponse(), availability: "revoked" as const }) + }, + { + name: "expired availability", + code: "TARGET_AUTHORITY_EXPIRED", + response: () => ({ ...hostedScopeResponse(), availability: "expired" as const }) + }, + { + name: "release-unavailable availability", + code: "EXECUTION_RELEASE_UNAVAILABLE", + response: () => ({ ...hostedScopeResponse(), availability: "release_unavailable" as const }) + }, + { + name: "authority verification", + code: "TARGET_AUTHORITY_REQUIRED", + response: (): ExecutionScopeResponse => { + const response = hostedScopeResponse(); + return { + ...response, + authority: { ...response.authority, verificationStatus: "pending" } + }; + } + }, + { + name: "scope expiry", + code: "TARGET_AUTHORITY_EXPIRED", + response: (): ExecutionScopeResponse => { + const response = hostedScopeResponse(); + return { ...response, scope: { ...response.scope, expiresAt: "2000-01-01T00:00:00Z" } }; + } + }, + { + name: "authority expiry", + code: "TARGET_AUTHORITY_EXPIRED", + response: (): ExecutionScopeResponse => { + const response = hostedScopeResponse(); + return { + ...response, + authority: { ...response.authority, expiresAt: "2000-01-01T00:00:00Z" } + }; + } + }, + { + name: "target boundary", + code: "TARGET_SCOPE_MISMATCH", + resolved: true, + response: () => hostedScopeResponse() + } + ])("rejects a looked-up scope that fails $name", async (entry) => { + const cloud = { + async getRealDataCapabilities() { + return entry.capabilities ?? ENABLED_CAPABILITIES; + }, + async getRunExecutionScope() { + return entry.response(); + } + }; + await expect( + resolveDispatchPolicyForBinding({ + cloud: cloud as never, + binding: runBinding({ key: "aw-customer-suite", version: "3.0.0" }), + workspaceId: entry.workspaceId ?? UUID_C, + ...(entry.resolved === true ? { resolved: resolvedTarget("https://other.example.com") } : {}) + }) + ).rejects.toMatchObject({ code: entry.code }); + }); + + it("turns an HTML 404 from the lookup into INVALID_EXECUTION_SCOPE", async () => { + const fetchMock = vi.fn(async () => + new Response(HTML_NOT_FOUND, { + status: 404, + headers: { "content-type": "text/html; charset=utf-8" } + }) + ); + const cloud = new CloudClient({ + apiUrl: "http://127.0.0.1:8787", + accessToken: "token", + fetch: fetchMock + }); + await expect( + resolveDispatchPolicyForBinding({ + cloud, + binding: runBinding({ key: "aw-customer-suite", version: "3.0.0" }, "run-html-404"), + workspaceId: UUID_C + }) + ).rejects.toMatchObject({ code: "INVALID_EXECUTION_SCOPE" }); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(String(fetchMock.mock.calls[0]?.[0])).toContain("/v1/relay/runs/run-html-404/execution-scope"); + }); + + it("propagates a 503 from the lookup", async () => { + const fetchMock = vi.fn(async () => + Response.json({ error: "unavailable" }, { status: 503 }) + ); + const cloud = new CloudClient({ + apiUrl: "http://127.0.0.1:8787", + accessToken: "token", + fetch: fetchMock + }); + await expect( + resolveDispatchPolicyForBinding({ + cloud, + binding: runBinding({ key: "aw-customer-suite", version: "3.0.0" }, "run-503"), + workspaceId: UUID_C + }) + ).rejects.toMatchObject({ + code: "CLOUD_REQUEST_FAILED", + details: { http_status: 503 } + }); + }); + + it("still rejects a stale pinned revision before availability", async () => { + const response = hostedScopeResponse(); + const cloud = { + async getRealDataCapabilities() { + return ENABLED_CAPABILITIES; + }, + async getExecutionScope() { + return { ...response, availability: "revoked" as const }; + } + }; + await expect( + requireHostedAuthorizedScope( + cloud as never, + { + schemaVersion: "aw-execution-scope/1", + scopeId: response.scope.scopeId, + revision: response.scope.revision + 1 + }, + { workspaceId: UUID_C } + ) + ).rejects.toMatchObject({ code: "EXECUTION_SCOPE_STALE" }); + }); +}); diff --git a/test/recovery/recovery.test.ts b/test/recovery/recovery.test.ts index c34c54c..d3cd41c 100644 --- a/test/recovery/recovery.test.ts +++ b/test/recovery/recovery.test.ts @@ -998,6 +998,87 @@ describe("CLI recovery (B04)", () => { expect(targetRuns).toBe(1); expect(report.outcome).toBe("resumed"); }); + + it("completes recover --resume for a synthetic run against an HTML 404 fallback", async () => { + const stateDirectory = await stateDir(); + const resolved = resolvedConfig(); + const boundary = targetBoundarySha256(resolved); + const store = new RunIntentStore({ + apiOrigin: new URL("http://127.0.0.1:8787/"), + tenant: { workspace_id: "workspace-1", connector_id: "connector-1" }, + stateDirectory, + createRequestId: () => `crq_${"k".repeat(32)}` + }); + await store.open(); + const loaded = await store.loadOrCreate({ + protocol_version: RELAY_PROTOCOL_VERSION, + packet: { key: PACKET.key, version: PACKET.version }, + config_sha256: resolved.configDigest, + target: { + name: resolved.config.target.name, + boundary_sha256: boundary, + capabilities: { + prepare: false, + observation: false, + cleanup: false, + tool_events: false, + observation_keys: [] + } + } + }); + await store.bind( + bindingFor(loaded.intent.request as unknown as Record, { + config_sha256: loaded.intent.request.config_sha256, + status: "running", + credit_state: "consumed" + }) as never + ); + await store.close(); + + const paths: string[] = []; + const fetchMock = vi.fn(async (input) => { + const url = new URL(String(input)); + paths.push(url.pathname); + if (url.pathname === "/v1/relay/runs") { + throw new Error("resume must not create"); + } + if (url.pathname === "/v1/relay/runs/run-1") { + return Response.json(statusFor({ status: "running", outcome: null, credit_state: "consumed" })); + } + const body = "404: This page could not be found."; + return new Response(body, { + status: 404, + headers: { "content-type": "text/html; charset=utf-8" } + }); + }); + const report = await runRecover( + { resume: true, stateDirectory, env: {}, handleSignals: false }, + { + doctor: doctorFor(), + apiOrigin: () => new URL("http://127.0.0.1:8787/"), + accessToken: async () => "token", + identity: async () => identity(), + cloud: ({ accessToken, accessTokenProvider }) => + new CloudClient({ + apiUrl: "http://127.0.0.1:8787/", + accessToken, + accessTokenProvider, + fetch: fetchMock + }), + runner: () => + ({ + run: async () => statusFor(), + requestCancellation: async () => { + throw new Error("resume must not cancel"); + } + }) as unknown as RelayRunner, + stdout: { write: () => true }, + stderr: { write: () => true } + } + ); + expect(report.outcome).toBe("resumed"); + expect(paths.some((path) => path.includes("execution-scope"))).toBe(false); + }); }); describe("generic create errors are not treated as no-create", () => { diff --git a/test/selection/execution.test.ts b/test/selection/execution.test.ts index c60f9e3..abaeb9c 100644 --- a/test/selection/execution.test.ts +++ b/test/selection/execution.test.ts @@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; import { CloudClient } from "../../src/cloud/client.js"; +import type { RelayRunner } from "../../src/relay/runner.js"; import { createTestCommand, installSelectionExecutionInterruptHandler, @@ -44,6 +45,7 @@ import { SelectionExecutionSchema, type SuiteSelectionManifest } from "../../src/selection/schema.js"; +import { CANONICAL_HASH, SUITE_ID, SUITE_REVISION, savedSuiteManifest } from "./saved-suite-fixtures.js"; const projectRoot = resolve(fileURLToPath(new URL("../..", import.meta.url))); const fixtures = JSON.parse( @@ -1472,4 +1474,80 @@ describe("selection execution tenant pinning", () => { expect(second.execution.shards.every((shard) => shard.runId === null)).toBe(true); await second.lock.release(); }); + + it("completes a saved v2 suite run against an HTML 404 fallback", async () => { + const cwd = await stateDir(); + const stateDirectory = await stateDir(); + const manifest = savedSuiteManifest(); + await writeFile(join(cwd, "suite-selection.manifest.json"), `${JSON.stringify(manifest)}\n`, "utf8"); + const apiOrigin = new URL("http://127.0.0.1:8787/"); + const paths: string[] = []; + const fetchMock = vi.fn(async (input, init) => { + const url = new URL(String(input)); + paths.push(`${init?.method ?? "GET"} ${url.pathname}`); + if (url.pathname === "/v1/billing/capabilities") { + return Response.json(billingFixtures.fixtures["eligible_trial"]?.response); + } + if (url.pathname === "/v1/billing/quote") { + return Response.json(billingFixtures.fixtures["quote_success_with_balance"]?.response); + } + if (url.pathname === "/v1/relay/runs" && (init?.method ?? "GET") === "POST") { + const request = JSON.parse(String(init?.body)) as Record; + return Response.json({ + ...createRunJson(request, "run-saved-v2", apiOrigin), + status: "running" + }); + } + if (url.pathname === `/v1/suites/${SUITE_ID}/revisions/${SUITE_REVISION}`) { + return Response.json({ + suiteId: SUITE_ID, + revisionId: SUITE_REVISION, + contentHash: CANONICAL_HASH, + canonicalDocument: { schemaVersion: "aw-customer-suite/2", syntheticOnly: false } + }); + } + const missing = "404: This page could not be found."; + return new Response(missing, { + status: 404, + headers: { "content-type": "text/html; charset=utf-8" } + }); + }); + const result = await runTest( + { + manifest: "suite-selection.manifest.json", + allShards: true, + maxCredits: "100", + yes: true, + cwd, + stateDirectory, + handleSignals: false, + env: { CI: "1", AUGMENTWORKS_API_URL: apiOrigin.origin } + }, + { + doctor: doctorFor(), + apiOrigin: () => apiOrigin, + accessToken: async () => "token", + identity: async () => hostedIdentity(), + cloud: (options) => + new CloudClient({ + apiUrl: options.apiOrigin, + accessToken: options.accessToken, + accessTokenProvider: options.accessTokenProvider, + fetch: fetchMock + }), + runner: () => + ({ + run: async () => runStatusJson("run-saved-v2", apiOrigin), + requestCancellation: async () => { + throw new Error("saved suite run must not cancel"); + } + }) as unknown as RelayRunner, + stdout: { write: () => true }, + stderr: { write: () => true } + } + ); + expect(result.binding.run_id).toBe("run-saved-v2"); + expect(result.run.status).toBe("completed"); + expect(paths.some((path) => path.includes("execution-scope"))).toBe(false); + }); });