diff --git a/packages/cli/src/destroy-command.test.ts b/packages/cli/src/destroy-command.test.ts index 9dcda18..baf45ef 100644 --- a/packages/cli/src/destroy-command.test.ts +++ b/packages/cli/src/destroy-command.test.ts @@ -73,6 +73,7 @@ function trapFetch(handler: (call: FetchCall) => Response | Promise): } function withTokenEnv(token: string, workspace: string): () => void { + const restoreIsolate = isolateAuthFiles(); const prevToken = process.env.WORKFORCE_WORKSPACE_TOKEN; const prevWs = process.env.WORKFORCE_WORKSPACE_ID; const prevCloudA = process.env.WORKFORCE_DEPLOY_CLOUD_URL; @@ -86,8 +87,35 @@ function withTokenEnv(token: string, workspace: string): () => void { else process.env.WORKFORCE_WORKSPACE_TOKEN = prevToken; if (prevWs === undefined) delete process.env.WORKFORCE_WORKSPACE_ID; else process.env.WORKFORCE_WORKSPACE_ID = prevWs; - if (prevCloudA !== undefined) process.env.WORKFORCE_DEPLOY_CLOUD_URL = prevCloudA; - if (prevCloudB !== undefined) process.env.WORKFORCE_CLOUD_URL = prevCloudB; + if (prevCloudA === undefined) delete process.env.WORKFORCE_DEPLOY_CLOUD_URL; + else process.env.WORKFORCE_DEPLOY_CLOUD_URL = prevCloudA; + if (prevCloudB === undefined) delete process.env.WORKFORCE_CLOUD_URL; + else process.env.WORKFORCE_CLOUD_URL = prevCloudB; + restoreIsolate(); + }; +} + +/** + * Pin every filesystem-backed auth source to definitely-missing/disabled + * paths so the destroy CLI tests don't accidentally pick up the host + * developer's `~/.agentworkforce/active.json` or `~/.agent-relay/cloud-auth.json`. + * Tests that intentionally exercise the active.json fallback override + * `WORKFORCE_ACTIVE_WORKSPACE_FILE` after this runs. + */ +function isolateAuthFiles(): () => void { + const prevActive = process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE; + const prevLogin = process.env.WORKFORCE_LOGIN_FILE; + const prevDisable = process.env.WORKFORCE_DISABLE_SHARED_AUTH; + process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE = path.join(os.tmpdir(), 'wf-destroy-test-active-MISSING.json'); + process.env.WORKFORCE_LOGIN_FILE = path.join(os.tmpdir(), 'wf-destroy-test-login-MISSING.json'); + process.env.WORKFORCE_DISABLE_SHARED_AUTH = '1'; + return () => { + if (prevActive === undefined) delete process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE; + else process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE = prevActive; + if (prevLogin === undefined) delete process.env.WORKFORCE_LOGIN_FILE; + else process.env.WORKFORCE_LOGIN_FILE = prevLogin; + if (prevDisable === undefined) delete process.env.WORKFORCE_DISABLE_SHARED_AUTH; + else process.env.WORKFORCE_DISABLE_SHARED_AUTH = prevDisable; }; } @@ -227,7 +255,13 @@ test('runDestroy: 401 maps to exit 1 with a login hint', async () => { }); test('runDestroy: missing workspace exits 1', async () => { - // No WORKFORCE_WORKSPACE_ID, no --workspace. + // No WORKFORCE_WORKSPACE_ID, no --workspace, and no on-disk auth state + // — destroy should fail fast with an actionable error and never reach + // the network. We isolate the filesystem sources because the new code + // path also consults `~/.agentworkforce/active.json` and the shared + // cloud-auth file, which would otherwise leak from the host machine + // running the test. + const restoreIsolate = isolateAuthFiles(); const prevToken = process.env.WORKFORCE_WORKSPACE_TOKEN; const prevWs = process.env.WORKFORCE_WORKSPACE_ID; process.env.WORKFORCE_WORKSPACE_TOKEN = 'tok-1'; @@ -237,9 +271,12 @@ test('runDestroy: missing workspace exits 1', async () => { }); const trap = trapIO(); try { - await assert.rejects(runDestroy([AGENT_UUID]), /__exit_trap__:1/); + await assert.rejects(runDestroy([AGENT_UUID, '--no-prompt']), /__exit_trap__:1/); assert.deepEqual(trap.exits, [1]); - assert.match(trap.stderr, /no workspace resolved/); + // Accept either the orchestrator-level message ("no workspace resolved") + // or the auth-resolver message ("no workspace credentials resolved") + // — both are valid pre-network failures. + assert.match(trap.stderr, /no workspace (credentials )?resolved/); assert.equal(fetchTrap.calls.length, 0); } finally { trap.restore(); @@ -247,6 +284,7 @@ test('runDestroy: missing workspace exits 1', async () => { if (prevToken === undefined) delete process.env.WORKFORCE_WORKSPACE_TOKEN; else process.env.WORKFORCE_WORKSPACE_TOKEN = prevToken; if (prevWs !== undefined) process.env.WORKFORCE_WORKSPACE_ID = prevWs; + restoreIsolate(); } }); @@ -389,3 +427,117 @@ test('runDestroy: 5xx server error exits 1 and surfaces the status', async () => restoreEnv(); } }); + +test('runDestroy: HTML 404 body is replaced with a hint, not dumped verbatim', async () => { + // Regression guard for the apex-without-/cloud bug: when the CLI hits + // `agentrelay.com/api/...` instead of `agentrelay.com/cloud/api/...`, + // cloud's marketing site returns a full Next.js 404 page. The error + // formatter must summarize that, not dump it into stderr. + const restoreEnv = withTokenEnv('tok-1', WORKSPACE); + const htmlPage = '' + + '404' + + ''.repeat(20) + + ''; + const fetchTrap = trapFetch( + async () => new Response(htmlPage, { + status: 404, + headers: { 'content-type': 'text/html; charset=utf-8' } + }) + ); + const trap = trapIO(); + try { + // 404 on a DELETE is the documented "not found / already destroyed" + // path (exit 2). That branch produces a clean message that doesn't + // surface the body — so this guard is really about the + // !res.ok fallthrough. We use 500 here to exercise the generic + // formatter instead. + fetchTrap.restore(); + const fetchTrap2 = trapFetch( + async () => new Response(htmlPage, { + status: 500, + headers: { 'content-type': 'text/html; charset=utf-8' } + }) + ); + try { + await assert.rejects( + runDestroy([AGENT_UUID, '--cloud-url', CLOUD]), + /__exit_trap__:1/ + ); + assert.deepEqual(trap.exits, [1]); + assert.match(trap.stderr, /500/); + assert.match(trap.stderr, /HTML|wrong API root/); + // The raw '.repeat(50) + + ''; + const formatted = formatHttpErrorBody(html); + // Must not include the raw HTML. + assert.equal(formatted.includes(' { + assert.match(formatHttpErrorBody('...'), /HTML/); + assert.match(formatHttpErrorBody('...'), /HTML/); + assert.match(formatHttpErrorBody('x'), /HTML/); +}); + +test('formatHttpErrorBody: includes the offending URL when provided', () => { + const html = '404'; + const formatted = formatHttpErrorBody(html, { + url: 'https://agentrelay.com/api/v1/workspaces/abc/deployments' + }); + assert.match(formatted, /https:\/\/agentrelay\.com\/api\/v1\/workspaces\/abc\/deployments/); +}); + +test('formatHttpErrorBody: a stray

in plain text is NOT treated as HTML', () => { + // Bare angle brackets in JSON error messages shouldn't trigger the + // suppress path. The detector looks for the opening doctype / + trio. + const body = '{"error":"got <h1> tag in input"}'; + assert.equal(formatHttpErrorBody(body), body); +}); diff --git a/packages/deploy/src/error-format.ts b/packages/deploy/src/error-format.ts new file mode 100644 index 0000000..6deed3c --- /dev/null +++ b/packages/deploy/src/error-format.ts @@ -0,0 +1,34 @@ +/** + * Format an HTTP response body for inclusion in a CLI error message. + * + * Common failure mode this guards against: the CLI hits the wrong URL + * (missing `/cloud` basePath, marketing-site fallthrough, etc.) and the + * server returns a full Next.js 404 page. Dumping the HTML verbatim + * produces an unreadable wall of `<script>` tags. This helper detects + * HTML and replaces it with a one-line hint that points at the cause. + * + * Non-HTML bodies are truncated to a reasonable length so a stray + * stack trace doesn't drown the actual error message. + */ +export function formatHttpErrorBody( + body: string | undefined | null, + opts: { maxLength?: number; url?: string } = {} +): string { + const trimmed = (body ?? '').trim(); + if (!trimmed) return ''; + if (looksLikeHtml(trimmed)) { + const where = opts.url ? ` from ${opts.url}` : ''; + return `server returned HTML${where} — likely wrong API root (basePath missing, or fell through to a marketing/landing page). Body suppressed (${trimmed.length} bytes).`; + } + const max = opts.maxLength ?? 300; + if (trimmed.length <= max) return trimmed; + return `${trimmed.slice(0, max)}... (${trimmed.length - max} more bytes truncated)`; +} + +function looksLikeHtml(body: string): boolean { + const head = body.slice(0, 256).toLowerCase(); + if (head.startsWith('<!doctype html')) return true; + if (head.startsWith('<html')) return true; + if (/<head[\s>]/.test(head) && /<title[\s>]/.test(head)) return true; + return false; +} diff --git a/packages/deploy/src/index.ts b/packages/deploy/src/index.ts index d6236ab..5f68d48 100644 --- a/packages/deploy/src/index.ts +++ b/packages/deploy/src/index.ts @@ -44,7 +44,8 @@ export { type WorkspaceAuth, type WorkspaceAuthToken } from './login.js'; -export { canonicalizeCloudUrl } from './cloud-url.js'; +export { canonicalizeCloudUrl, resolveCloudUrl, type CloudUrlContext } from './cloud-url.js'; +export { formatHttpErrorBody } from './error-format.js'; export { createTerminalIO, createBufferedIO, type BufferedIO } from './io.js'; export { bundleStager } from './bundle.js'; export { devLauncher } from './modes/dev.js'; diff --git a/packages/deploy/src/login.ts b/packages/deploy/src/login.ts index 3a3a934..dd7f7e8 100644 --- a/packages/deploy/src/login.ts +++ b/packages/deploy/src/login.ts @@ -261,8 +261,16 @@ export async function resolveWorkspaceToken(args: { * Read the shared @agent-relay/cloud auth, refreshing if the accessToken * is expired and a refreshToken is available. Returns `null` on any * failure — callers fall through to the next resolution tier. + * + * Set `WORKFORCE_DISABLE_SHARED_AUTH=1` (or any truthy value) to skip + * the shared-auth read entirely. Primary use cases: + * - Hermetic tests that must not pick up the host machine's + * `~/.agent-relay/cloud-auth.json`. + * - Users who want the CLI to behave as if they had never run + * `agent-relay cloud login` (e.g. to force env-only operation in CI). */ async function readSharedAuthForBearer(): Promise<StoredAuth | null> { + if (isTruthyEnv(process.env.WORKFORCE_DISABLE_SHARED_AUTH)) return null; const auth = await readStoredAuth().catch(() => null); if (!auth || !auth.accessToken) return null; if (!isExpired(auth.accessTokenExpiresAt)) return auth; @@ -274,6 +282,12 @@ async function readSharedAuthForBearer(): Promise<StoredAuth | null> { } } +function isTruthyEnv(value: string | undefined): boolean { + if (!value) return false; + const v = value.trim().toLowerCase(); + return v === '1' || v === 'true' || v === 'yes' || v === 'on'; +} + export async function loadWorkspaceToken( workspace?: string, cloudUrl?: string @@ -346,6 +360,7 @@ async function readWorkspaceTokenFromCloudAuth( cloudUrl?: string ): Promise<StoredWorkspaceLogin | null> { if (usesWorkspaceLoginFileOverride()) return null; + if (isTruthyEnv(process.env.WORKFORCE_DISABLE_SHARED_AUTH)) return null; let auth = await readStoredAuth().catch(() => null); if (!auth) return null;