Skip to content

Commit a88cc80

Browse files
khaliqgantRicky Schema Cascadeclaude
authored
fix(cli): orchestrator + list/destroy read active.json; sanitize HTML errors (#118)
* fix(cli): orchestrator + list/destroy read active.json + cloud-auth.json The deploy orchestrator was still using the legacy `envWorkspaceAuth()` default, which only consults `WORKFORCE_WORKSPACE_TOKEN` env + a long-dead keychain. A user who freshly ran `agentworkforce login` (which writes the shared @agent-relay/cloud accessToken + an active.json pointer) would hit `no workspace resolved` because that flow is invisible to the env-only resolver. PR #113 fixed the cloud launcher and list/destroy commands but missed this orchestrator entry point. The list and destroy CLI commands also defaulted to `https://agentrelay.com` (missing the `/cloud` basePath), so every API call landed on the marketing site's Next.js 404 page — and the full HTML response body was dumped verbatim into the CLI error message. Comprehensive fix: * Add `resolveCloudUrl()` as the single source of truth for cloud URL resolution (flag → env → active.json → canonical default). All three CLI commands and the orchestrator now route through it. The canonical default is now applied via `canonicalizeCloudUrl`, which also remaps the bare apex `agentrelay.com` → `agentrelay.com/cloud` to prevent the marketing-site fallthrough from ever happening again. * Add `formatHttpErrorBody()` — detects HTML response bodies and replaces them with a one-line hint, truncates long non-HTML bodies. list and destroy both use it. * Swap the orchestrator's auth default from `envWorkspaceAuth()` to `resolveWorkspaceToken()`, which respects the same env vars (Tier 1 for CI) but additionally falls through to the shared cloud-auth + active.json pointer. * Add `WORKFORCE_DISABLE_SHARED_AUTH` opt-out for hermetic tests and power users who want strictly env-only operation. Tests: 17 new (cloud-url, error-format, deploy, destroy) covering URL resolution precedence, apex canonicalization, HTML body sanitization, the deploy env-Tier 1 path, deploy noPrompt error message, destroy active.json fallback, and the test isolation hook. Smoke verified against the local build in proactive-agents: `agentworkforce deploy ... --no-prompt` now resolves the workspace from active.json, finds both notion and github already connected, and stages the bundle (failing later on harness creds, which is M3 scope). `agentworkforce deployments list` returns clean results instead of a 404 HTML wall. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(cli): address pr review comments --------- Co-authored-by: Ricky Schema Cascade <ricky@agent-relay.com> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9a859fd commit a88cc80

11 files changed

Lines changed: 592 additions & 61 deletions

packages/cli/src/destroy-command.test.ts

Lines changed: 157 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ function trapFetch(handler: (call: FetchCall) => Response | Promise<Response>):
7373
}
7474

7575
function withTokenEnv(token: string, workspace: string): () => void {
76+
const restoreIsolate = isolateAuthFiles();
7677
const prevToken = process.env.WORKFORCE_WORKSPACE_TOKEN;
7778
const prevWs = process.env.WORKFORCE_WORKSPACE_ID;
7879
const prevCloudA = process.env.WORKFORCE_DEPLOY_CLOUD_URL;
@@ -86,8 +87,35 @@ function withTokenEnv(token: string, workspace: string): () => void {
8687
else process.env.WORKFORCE_WORKSPACE_TOKEN = prevToken;
8788
if (prevWs === undefined) delete process.env.WORKFORCE_WORKSPACE_ID;
8889
else process.env.WORKFORCE_WORKSPACE_ID = prevWs;
89-
if (prevCloudA !== undefined) process.env.WORKFORCE_DEPLOY_CLOUD_URL = prevCloudA;
90-
if (prevCloudB !== undefined) process.env.WORKFORCE_CLOUD_URL = prevCloudB;
90+
if (prevCloudA === undefined) delete process.env.WORKFORCE_DEPLOY_CLOUD_URL;
91+
else process.env.WORKFORCE_DEPLOY_CLOUD_URL = prevCloudA;
92+
if (prevCloudB === undefined) delete process.env.WORKFORCE_CLOUD_URL;
93+
else process.env.WORKFORCE_CLOUD_URL = prevCloudB;
94+
restoreIsolate();
95+
};
96+
}
97+
98+
/**
99+
* Pin every filesystem-backed auth source to definitely-missing/disabled
100+
* paths so the destroy CLI tests don't accidentally pick up the host
101+
* developer's `~/.agentworkforce/active.json` or `~/.agent-relay/cloud-auth.json`.
102+
* Tests that intentionally exercise the active.json fallback override
103+
* `WORKFORCE_ACTIVE_WORKSPACE_FILE` after this runs.
104+
*/
105+
function isolateAuthFiles(): () => void {
106+
const prevActive = process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE;
107+
const prevLogin = process.env.WORKFORCE_LOGIN_FILE;
108+
const prevDisable = process.env.WORKFORCE_DISABLE_SHARED_AUTH;
109+
process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE = path.join(os.tmpdir(), 'wf-destroy-test-active-MISSING.json');
110+
process.env.WORKFORCE_LOGIN_FILE = path.join(os.tmpdir(), 'wf-destroy-test-login-MISSING.json');
111+
process.env.WORKFORCE_DISABLE_SHARED_AUTH = '1';
112+
return () => {
113+
if (prevActive === undefined) delete process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE;
114+
else process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE = prevActive;
115+
if (prevLogin === undefined) delete process.env.WORKFORCE_LOGIN_FILE;
116+
else process.env.WORKFORCE_LOGIN_FILE = prevLogin;
117+
if (prevDisable === undefined) delete process.env.WORKFORCE_DISABLE_SHARED_AUTH;
118+
else process.env.WORKFORCE_DISABLE_SHARED_AUTH = prevDisable;
91119
};
92120
}
93121

@@ -227,7 +255,13 @@ test('runDestroy: 401 maps to exit 1 with a login hint', async () => {
227255
});
228256

229257
test('runDestroy: missing workspace exits 1', async () => {
230-
// No WORKFORCE_WORKSPACE_ID, no --workspace.
258+
// No WORKFORCE_WORKSPACE_ID, no --workspace, and no on-disk auth state
259+
// — destroy should fail fast with an actionable error and never reach
260+
// the network. We isolate the filesystem sources because the new code
261+
// path also consults `~/.agentworkforce/active.json` and the shared
262+
// cloud-auth file, which would otherwise leak from the host machine
263+
// running the test.
264+
const restoreIsolate = isolateAuthFiles();
231265
const prevToken = process.env.WORKFORCE_WORKSPACE_TOKEN;
232266
const prevWs = process.env.WORKFORCE_WORKSPACE_ID;
233267
process.env.WORKFORCE_WORKSPACE_TOKEN = 'tok-1';
@@ -237,16 +271,20 @@ test('runDestroy: missing workspace exits 1', async () => {
237271
});
238272
const trap = trapIO();
239273
try {
240-
await assert.rejects(runDestroy([AGENT_UUID]), /__exit_trap__:1/);
274+
await assert.rejects(runDestroy([AGENT_UUID, '--no-prompt']), /__exit_trap__:1/);
241275
assert.deepEqual(trap.exits, [1]);
242-
assert.match(trap.stderr, /no workspace resolved/);
276+
// Accept either the orchestrator-level message ("no workspace resolved")
277+
// or the auth-resolver message ("no workspace credentials resolved")
278+
// — both are valid pre-network failures.
279+
assert.match(trap.stderr, /no workspace (credentials )?resolved/);
243280
assert.equal(fetchTrap.calls.length, 0);
244281
} finally {
245282
trap.restore();
246283
fetchTrap.restore();
247284
if (prevToken === undefined) delete process.env.WORKFORCE_WORKSPACE_TOKEN;
248285
else process.env.WORKFORCE_WORKSPACE_TOKEN = prevToken;
249286
if (prevWs !== undefined) process.env.WORKFORCE_WORKSPACE_ID = prevWs;
287+
restoreIsolate();
250288
}
251289
});
252290

@@ -389,3 +427,117 @@ test('runDestroy: 5xx server error exits 1 and surfaces the status', async () =>
389427
restoreEnv();
390428
}
391429
});
430+
431+
test('runDestroy: HTML 404 body is replaced with a hint, not dumped verbatim', async () => {
432+
// Regression guard for the apex-without-/cloud bug: when the CLI hits
433+
// `agentrelay.com/api/...` instead of `agentrelay.com/cloud/api/...`,
434+
// cloud's marketing site returns a full Next.js 404 page. The error
435+
// formatter must summarize that, not dump it into stderr.
436+
const restoreEnv = withTokenEnv('tok-1', WORKSPACE);
437+
const htmlPage = '<!DOCTYPE html><html lang="en"><head>'
438+
+ '<title>404</title>'
439+
+ '<script src="/_next/static/chunks/main.js"></script>'.repeat(20)
440+
+ '</head><body></body></html>';
441+
const fetchTrap = trapFetch(
442+
async () => new Response(htmlPage, {
443+
status: 404,
444+
headers: { 'content-type': 'text/html; charset=utf-8' }
445+
})
446+
);
447+
const trap = trapIO();
448+
try {
449+
// 404 on a DELETE is the documented "not found / already destroyed"
450+
// path (exit 2). That branch produces a clean message that doesn't
451+
// surface the body — so this guard is really about the
452+
// !res.ok fallthrough. We use 500 here to exercise the generic
453+
// formatter instead.
454+
fetchTrap.restore();
455+
const fetchTrap2 = trapFetch(
456+
async () => new Response(htmlPage, {
457+
status: 500,
458+
headers: { 'content-type': 'text/html; charset=utf-8' }
459+
})
460+
);
461+
try {
462+
await assert.rejects(
463+
runDestroy([AGENT_UUID, '--cloud-url', CLOUD]),
464+
/__exit_trap__:1/
465+
);
466+
assert.deepEqual(trap.exits, [1]);
467+
assert.match(trap.stderr, /500/);
468+
assert.match(trap.stderr, /HTML|wrong API root/);
469+
// The raw <script> tags must not appear in stderr.
470+
assert.equal(trap.stderr.includes('<script'), false);
471+
assert.equal(trap.stderr.includes('<!DOCTYPE'), false);
472+
} finally {
473+
fetchTrap2.restore();
474+
}
475+
} finally {
476+
trap.restore();
477+
restoreEnv();
478+
}
479+
});
480+
481+
test('runDestroy: reads active.json cloudUrl when no flag and no env is set', async () => {
482+
// The destroy command must consult `~/.agentworkforce/active.json` for
483+
// the cloud URL just like the deploy orchestrator does. Without this,
484+
// a user who ran `agentworkforce login` (which writes active.json with
485+
// the canonical cloud URL) would still hit the legacy default.
486+
const restoreIsolate = isolateAuthFiles();
487+
const prevToken = process.env.WORKFORCE_WORKSPACE_TOKEN;
488+
const prevWs = process.env.WORKFORCE_WORKSPACE_ID;
489+
const prevCloudA = process.env.WORKFORCE_DEPLOY_CLOUD_URL;
490+
const prevCloudB = process.env.WORKFORCE_CLOUD_URL;
491+
process.env.WORKFORCE_WORKSPACE_TOKEN = 'tok-active';
492+
process.env.WORKFORCE_WORKSPACE_ID = WORKSPACE;
493+
delete process.env.WORKFORCE_DEPLOY_CLOUD_URL;
494+
delete process.env.WORKFORCE_CLOUD_URL;
495+
496+
const tmp = await mkdtemp(path.join(os.tmpdir(), 'aw-destroy-active-'));
497+
const activeFile = path.join(tmp, 'active.json');
498+
await writeFile(
499+
activeFile,
500+
JSON.stringify({
501+
workspace: WORKSPACE,
502+
workspaceId: WORKSPACE,
503+
cloudUrl: 'https://active.example.test/cloud',
504+
setAt: new Date().toISOString()
505+
}),
506+
'utf8'
507+
);
508+
process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE = activeFile;
509+
510+
const fetchTrap = trapFetch(
511+
async () =>
512+
new Response(
513+
JSON.stringify({
514+
agentId: AGENT_UUID,
515+
status: 'destroyed',
516+
destroyedAt: '2026-05-13T00:00:00.000Z',
517+
cancelledScheduleIds: []
518+
}),
519+
{ status: 200, headers: { 'content-type': 'application/json' } }
520+
)
521+
);
522+
const trap = trapIO();
523+
try {
524+
// No `--cloud-url` flag. The command must derive the URL from active.json.
525+
await assert.rejects(runDestroy([AGENT_UUID]), /__exit_trap__:0/);
526+
assert.equal(fetchTrap.calls.length, 1);
527+
assert.equal(
528+
fetchTrap.calls[0].url,
529+
`https://active.example.test/cloud/api/v1/workspaces/${WORKSPACE}/deployments/${AGENT_UUID}`
530+
);
531+
} finally {
532+
trap.restore();
533+
fetchTrap.restore();
534+
if (prevToken === undefined) delete process.env.WORKFORCE_WORKSPACE_TOKEN;
535+
else process.env.WORKFORCE_WORKSPACE_TOKEN = prevToken;
536+
if (prevWs === undefined) delete process.env.WORKFORCE_WORKSPACE_ID;
537+
else process.env.WORKFORCE_WORKSPACE_ID = prevWs;
538+
if (prevCloudA !== undefined) process.env.WORKFORCE_DEPLOY_CLOUD_URL = prevCloudA;
539+
if (prevCloudB !== undefined) process.env.WORKFORCE_CLOUD_URL = prevCloudB;
540+
restoreIsolate();
541+
await rm(tmp, { recursive: true, force: true });
542+
}
543+
});

packages/cli/src/destroy-command.ts

Lines changed: 30 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
import { readFile, stat } from 'node:fs/promises';
22
import path from 'node:path';
3-
import { createTerminalIO, resolveWorkspaceToken } from '@agentworkforce/deploy';
3+
import {
4+
createTerminalIO,
5+
formatHttpErrorBody,
6+
readActiveWorkspace,
7+
resolveCloudUrl,
8+
resolveWorkspaceToken
9+
} from '@agentworkforce/deploy';
410

5-
const DEFAULT_CLOUD_URL = 'https://agentrelay.com';
611
const USER_AGENT = 'agentworkforce-cli/destroy';
712
// UUID v1-v5, what the cloud agents.id column emits.
813
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@@ -12,7 +17,7 @@ export interface DestroyOptions {
1217
target: string;
1318
/** Workforce workspace id. Falls back to WORKFORCE_WORKSPACE_ID. */
1419
workspace?: string;
15-
/** Override cloud base URL. Falls back to env, then DEFAULT_CLOUD_URL. */
20+
/** Override cloud base URL. Falls back to env, then active.json, then the canonical default. */
1621
cloudUrl?: string;
1722
/** Fail instead of opening the browser to log in. */
1823
noPrompt?: boolean;
@@ -77,28 +82,32 @@ export async function runDestroy(args: readonly string[]): Promise<void> {
7782
}
7883

7984
async function executeDestroy(opts: DestroyOptions): Promise<void> {
80-
const workspace = (opts.workspace ?? process.env.WORKFORCE_WORKSPACE_ID ?? '').trim();
81-
if (!workspace) {
82-
throw new DestroyExit(
83-
1,
84-
'\nagentworkforce destroy failed: no workspace resolved: pass --workspace or set WORKFORCE_WORKSPACE_ID\n'
85-
);
86-
}
87-
88-
const cloudUrl = normalizeCloudUrl(
89-
opts.cloudUrl ??
90-
process.env.WORKFORCE_DEPLOY_CLOUD_URL ??
91-
process.env.WORKFORCE_CLOUD_URL ??
92-
DEFAULT_CLOUD_URL
93-
);
85+
const active = await readActiveWorkspace().catch(() => null);
86+
const cloudUrl = resolveCloudUrl({
87+
...(opts.cloudUrl ? { flag: opts.cloudUrl } : {}),
88+
active
89+
});
9490

9591
const io = createTerminalIO();
96-
const { token } = await resolveWorkspaceToken({
97-
workspace,
92+
const auth = await resolveWorkspaceToken({
93+
...(opts.workspace ? { workspace: opts.workspace } : {}),
9894
cloudUrl,
9995
io,
10096
...(opts.noPrompt ? { noPrompt: true } : {})
10197
});
98+
const workspace = (
99+
auth.workspace
100+
?? opts.workspace
101+
?? process.env.WORKFORCE_WORKSPACE_ID
102+
?? ''
103+
).trim();
104+
if (!workspace) {
105+
throw new DestroyExit(
106+
1,
107+
'\nagentworkforce destroy failed: no workspace resolved: pass --workspace, set WORKFORCE_WORKSPACE_ID, or run `agentworkforce login`\n'
108+
);
109+
}
110+
const token = auth.token;
102111

103112
const agentId = await resolveAgentId({
104113
target: opts.target,
@@ -260,18 +269,13 @@ async function pathExists(target: string): Promise<boolean> {
260269

261270
async function responseExcerpt(res: Response): Promise<string> {
262271
try {
263-
const text = (await res.text()).trim();
264-
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
272+
const text = await res.text();
273+
return formatHttpErrorBody(text, { url: res.url, maxLength: 200 });
265274
} catch {
266275
return '';
267276
}
268277
}
269278

270-
function normalizeCloudUrl(url: string): string {
271-
const trimmed = url.trim();
272-
return trimmed ? trimmed.replace(/\/+$/, '') : DEFAULT_CLOUD_URL;
273-
}
274-
275279
export const DESTROY_USAGE = `usage: agentworkforce destroy <persona-or-agent-id> [flags]
276280
277281
Tear down a deployed agent: cancel all relaycron schedules and mark the

packages/cli/src/list-command.ts

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
import {
22
createTerminalIO,
3+
formatHttpErrorBody,
4+
readActiveWorkspace,
5+
resolveCloudUrl,
36
resolveWorkspaceToken
47
} from '@agentworkforce/deploy';
58

6-
const DEFAULT_CLOUD_URL = 'https://agentrelay.com';
7-
89
type DeploymentListOptions = {
910
workspace?: string;
1011
status?: string;
@@ -38,17 +39,16 @@ export async function runDeploymentList(args: readonly string[]): Promise<void>
3839
try {
3940
const opts = parseDeploymentListArgs(args);
4041
const io = createTerminalIO();
41-
const cloudUrl = normalizeCloudUrl(
42-
opts.cloudUrl
43-
?? process.env.WORKFORCE_DEPLOY_CLOUD_URL
44-
?? process.env.WORKFORCE_CLOUD_URL
45-
?? DEFAULT_CLOUD_URL
46-
);
42+
const active = await readActiveWorkspace().catch(() => null);
43+
const cloudUrl = resolveCloudUrl({
44+
...(opts.cloudUrl ? { flag: opts.cloudUrl } : {}),
45+
active
46+
});
4747
const auth = await resolveWorkspaceToken({
48-
workspace: opts.workspace,
48+
...(opts.workspace ? { workspace: opts.workspace } : {}),
4949
cloudUrl,
5050
io,
51-
noPrompt: opts.noPrompt
51+
...(opts.noPrompt ? { noPrompt: true } : {})
5252
});
5353
const workspace = auth.workspace?.trim() || opts.workspace?.trim();
5454
if (!workspace) {
@@ -70,7 +70,9 @@ export async function runDeploymentList(args: readonly string[]): Promise<void>
7070
throw new Error('unauthorized. Run `agentworkforce login` and retry.');
7171
}
7272
if (!res.ok) {
73-
throw new Error(`list failed: ${res.status} ${await res.text().catch(() => '')}`.trim());
73+
const body = await res.text().catch(() => '');
74+
const hint = formatHttpErrorBody(body, { url: url.toString() });
75+
throw new Error(`list failed: ${res.status}${hint ? ` ${hint}` : ''}`);
7476
}
7577
const agents = parseAgents((await res.json()) as ListResponse);
7678
if (opts.json) {
@@ -224,11 +226,6 @@ function readNullableString(record: Record<string, unknown>, key: string): strin
224226
return typeof value === 'string' && value.trim() ? value.trim() : null;
225227
}
226228

227-
function normalizeCloudUrl(url: string): string {
228-
const trimmed = url.trim();
229-
return trimmed ? trimmed.replace(/\/+$/, '') : DEFAULT_CLOUD_URL;
230-
}
231-
232229
function expectValue(flag: string, value: string | undefined): string {
233230
if (typeof value !== 'string' || !value.trim() || value.startsWith('-')) {
234231
throw new Error(`${flag}: missing value`);

0 commit comments

Comments
 (0)