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
162 changes: 157 additions & 5 deletions packages/cli/src/destroy-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ function trapFetch(handler: (call: FetchCall) => Response | Promise<Response>):
}

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;
Expand All @@ -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;
};
}

Expand Down Expand Up @@ -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';
Expand All @@ -237,16 +271,20 @@ 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();
fetchTrap.restore();
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();
}
});

Expand Down Expand Up @@ -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 = '<!DOCTYPE html><html lang="en"><head>'
+ '<title>404</title>'
+ '<script src="/_next/static/chunks/main.js"></script>'.repeat(20)
+ '</head><body></body></html>';
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 <script> tags must not appear in stderr.
assert.equal(trap.stderr.includes('<script'), false);
assert.equal(trap.stderr.includes('<!DOCTYPE'), false);
} finally {
fetchTrap2.restore();
}
} finally {
trap.restore();
restoreEnv();
}
});

test('runDestroy: reads active.json cloudUrl when no flag and no env is set', async () => {
// The destroy command must consult `~/.agentworkforce/active.json` for
// the cloud URL just like the deploy orchestrator does. Without this,
// a user who ran `agentworkforce login` (which writes active.json with
// the canonical cloud URL) would still hit the legacy default.
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;
const prevCloudB = process.env.WORKFORCE_CLOUD_URL;
process.env.WORKFORCE_WORKSPACE_TOKEN = 'tok-active';
process.env.WORKFORCE_WORKSPACE_ID = WORKSPACE;
delete process.env.WORKFORCE_DEPLOY_CLOUD_URL;
delete process.env.WORKFORCE_CLOUD_URL;

const tmp = await mkdtemp(path.join(os.tmpdir(), 'aw-destroy-active-'));
const activeFile = path.join(tmp, 'active.json');
await writeFile(
activeFile,
JSON.stringify({
workspace: WORKSPACE,
workspaceId: WORKSPACE,
cloudUrl: 'https://active.example.test/cloud',
setAt: new Date().toISOString()
}),
'utf8'
);
process.env.WORKFORCE_ACTIVE_WORKSPACE_FILE = activeFile;

const fetchTrap = trapFetch(
async () =>
new Response(
JSON.stringify({
agentId: AGENT_UUID,
status: 'destroyed',
destroyedAt: '2026-05-13T00:00:00.000Z',
cancelledScheduleIds: []
}),
{ status: 200, headers: { 'content-type': 'application/json' } }
)
);
const trap = trapIO();
try {
// No `--cloud-url` flag. The command must derive the URL from active.json.
await assert.rejects(runDestroy([AGENT_UUID]), /__exit_trap__:0/);
assert.equal(fetchTrap.calls.length, 1);
assert.equal(
fetchTrap.calls[0].url,
`https://active.example.test/cloud/api/v1/workspaces/${WORKSPACE}/deployments/${AGENT_UUID}`
);
} finally {
trap.restore();
fetchTrap.restore();
if (prevToken === undefined) delete process.env.WORKFORCE_WORKSPACE_TOKEN;
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;
restoreIsolate();
await rm(tmp, { recursive: true, force: true });
}
});
56 changes: 30 additions & 26 deletions packages/cli/src/destroy-command.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { readFile, stat } from 'node:fs/promises';
import path from 'node:path';
import { createTerminalIO, resolveWorkspaceToken } from '@agentworkforce/deploy';
import {
createTerminalIO,
formatHttpErrorBody,
readActiveWorkspace,
resolveCloudUrl,
resolveWorkspaceToken
} from '@agentworkforce/deploy';

const DEFAULT_CLOUD_URL = 'https://agentrelay.com';
const USER_AGENT = 'agentworkforce-cli/destroy';
// UUID v1-v5, what the cloud agents.id column emits.
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;
Expand All @@ -12,7 +17,7 @@ export interface DestroyOptions {
target: string;
/** Workforce workspace id. Falls back to WORKFORCE_WORKSPACE_ID. */
workspace?: string;
/** Override cloud base URL. Falls back to env, then DEFAULT_CLOUD_URL. */
/** Override cloud base URL. Falls back to env, then active.json, then the canonical default. */
cloudUrl?: string;
/** Fail instead of opening the browser to log in. */
noPrompt?: boolean;
Expand Down Expand Up @@ -77,28 +82,32 @@ export async function runDestroy(args: readonly string[]): Promise<void> {
}

async function executeDestroy(opts: DestroyOptions): Promise<void> {
const workspace = (opts.workspace ?? process.env.WORKFORCE_WORKSPACE_ID ?? '').trim();
if (!workspace) {
throw new DestroyExit(
1,
'\nagentworkforce destroy failed: no workspace resolved: pass --workspace or set WORKFORCE_WORKSPACE_ID\n'
);
}

const cloudUrl = normalizeCloudUrl(
opts.cloudUrl ??
process.env.WORKFORCE_DEPLOY_CLOUD_URL ??
process.env.WORKFORCE_CLOUD_URL ??
DEFAULT_CLOUD_URL
);
const active = await readActiveWorkspace().catch(() => null);
const cloudUrl = resolveCloudUrl({
...(opts.cloudUrl ? { flag: opts.cloudUrl } : {}),
active
});

const io = createTerminalIO();
const { token } = await resolveWorkspaceToken({
workspace,
const auth = await resolveWorkspaceToken({
...(opts.workspace ? { workspace: opts.workspace } : {}),
cloudUrl,
io,
...(opts.noPrompt ? { noPrompt: true } : {})
});
const workspace = (
auth.workspace
?? opts.workspace
?? process.env.WORKFORCE_WORKSPACE_ID
?? ''
).trim();
if (!workspace) {
throw new DestroyExit(
1,
'\nagentworkforce destroy failed: no workspace resolved: pass --workspace, set WORKFORCE_WORKSPACE_ID, or run `agentworkforce login`\n'
);
}
const token = auth.token;

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

async function responseExcerpt(res: Response): Promise<string> {
try {
const text = (await res.text()).trim();
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
const text = await res.text();
return formatHttpErrorBody(text, { url: res.url, maxLength: 200 });
} catch {
return '';
}
}

function normalizeCloudUrl(url: string): string {
const trimmed = url.trim();
return trimmed ? trimmed.replace(/\/+$/, '') : DEFAULT_CLOUD_URL;
}

export const DESTROY_USAGE = `usage: agentworkforce destroy <persona-or-agent-id> [flags]

Tear down a deployed agent: cancel all relaycron schedules and mark the
Expand Down
29 changes: 13 additions & 16 deletions packages/cli/src/list-command.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import {
createTerminalIO,
formatHttpErrorBody,
readActiveWorkspace,
resolveCloudUrl,
resolveWorkspaceToken
} from '@agentworkforce/deploy';

const DEFAULT_CLOUD_URL = 'https://agentrelay.com';

type DeploymentListOptions = {
workspace?: string;
status?: string;
Expand Down Expand Up @@ -38,17 +39,16 @@ export async function runDeploymentList(args: readonly string[]): Promise<void>
try {
const opts = parseDeploymentListArgs(args);
const io = createTerminalIO();
const cloudUrl = normalizeCloudUrl(
opts.cloudUrl
?? process.env.WORKFORCE_DEPLOY_CLOUD_URL
?? process.env.WORKFORCE_CLOUD_URL
?? DEFAULT_CLOUD_URL
);
const active = await readActiveWorkspace().catch(() => null);
const cloudUrl = resolveCloudUrl({
...(opts.cloudUrl ? { flag: opts.cloudUrl } : {}),
active
});
const auth = await resolveWorkspaceToken({
workspace: opts.workspace,
...(opts.workspace ? { workspace: opts.workspace } : {}),
cloudUrl,
io,
noPrompt: opts.noPrompt
...(opts.noPrompt ? { noPrompt: true } : {})
});
const workspace = auth.workspace?.trim() || opts.workspace?.trim();
if (!workspace) {
Expand All @@ -70,7 +70,9 @@ export async function runDeploymentList(args: readonly string[]): Promise<void>
throw new Error('unauthorized. Run `agentworkforce login` and retry.');
}
if (!res.ok) {
throw new Error(`list failed: ${res.status} ${await res.text().catch(() => '')}`.trim());
const body = await res.text().catch(() => '');
const hint = formatHttpErrorBody(body, { url: url.toString() });
throw new Error(`list failed: ${res.status}${hint ? ` ${hint}` : ''}`);
}
const agents = parseAgents((await res.json()) as ListResponse);
if (opts.json) {
Expand Down Expand Up @@ -224,11 +226,6 @@ function readNullableString(record: Record<string, unknown>, key: string): strin
return typeof value === 'string' && value.trim() ? value.trim() : null;
}

function normalizeCloudUrl(url: string): string {
const trimmed = url.trim();
return trimmed ? trimmed.replace(/\/+$/, '') : DEFAULT_CLOUD_URL;
}

function expectValue(flag: string, value: string | undefined): string {
if (typeof value !== 'string' || !value.trim() || value.startsWith('-')) {
throw new Error(`${flag}: missing value`);
Expand Down
Loading
Loading