426e9eeabd
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Has been cancelled
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Has been cancelled
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Has been cancelled
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Has been cancelled
675 lines
20 KiB
TypeScript
675 lines
20 KiB
TypeScript
/**
|
|
* `/accounts` + `/backend` tests: token parsing, the authorized-read /
|
|
* elevated-write gate split, account matching (ambiguous and empty matches
|
|
* refuse with candidates), the use-disables-siblings PATCH sequence, strategy
|
|
* and refresh flows, and the defaultBackend-only POST — all through
|
|
* `resolveCommand` with a stubbed loopback fetch.
|
|
*/
|
|
|
|
import type { IAgentRuntime, Memory } from "@elizaos/core";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { resolveCommand } from "../src/actions";
|
|
import { parseAccountsArgs } from "../src/actions/accounts";
|
|
import { parseBackendArgs } from "../src/actions/backend";
|
|
import { gateConnectorCommandByName } from "../src/connector-bridge";
|
|
import { initForRuntime } from "../src/registry";
|
|
|
|
function makeRuntime(): IAgentRuntime {
|
|
const cache = new Map<string, unknown>();
|
|
return {
|
|
agentId: "agent-accounts-backend",
|
|
character: { name: "Eliza", settings: {} },
|
|
actions: [],
|
|
getSetting: () => null,
|
|
getCache: async (key: string) => cache.get(key),
|
|
setCache: async (key: string, value: unknown) => {
|
|
cache.set(key, value);
|
|
return true;
|
|
},
|
|
deleteCache: async (key: string) => cache.delete(key),
|
|
} as unknown as IAgentRuntime;
|
|
}
|
|
|
|
function msg(text: string): Memory {
|
|
return {
|
|
id: "00000000-0000-0000-0000-000000000021",
|
|
entityId: "00000000-0000-0000-0000-0000000000ad",
|
|
roomId: "room-accounts-backend",
|
|
content: { text, source: "client_chat" },
|
|
} as unknown as Memory;
|
|
}
|
|
|
|
const OWNER = { isAuthorized: true, isElevated: true };
|
|
const AUTHORIZED = { isAuthorized: true, isElevated: false };
|
|
|
|
function jsonResponse(body: unknown, status = 200): Response {
|
|
return new Response(JSON.stringify(body), {
|
|
status,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
|
|
interface RecordedCall {
|
|
url: string;
|
|
method: string;
|
|
body?: unknown;
|
|
}
|
|
|
|
function recordedCalls(fetchMock: ReturnType<typeof vi.fn>): RecordedCall[] {
|
|
return fetchMock.mock.calls.map((call) => {
|
|
const init = call[1] as RequestInit | undefined;
|
|
return {
|
|
url: String(call[0]),
|
|
method: init?.method ?? "GET",
|
|
...(typeof init?.body === "string"
|
|
? { body: JSON.parse(init.body) }
|
|
: {}),
|
|
};
|
|
});
|
|
}
|
|
|
|
const NOW = Date.now();
|
|
|
|
function accountsPayload(overrides?: {
|
|
workEnabled?: boolean;
|
|
personalEnabled?: boolean;
|
|
sessionPct?: number;
|
|
}) {
|
|
return {
|
|
providers: [
|
|
{
|
|
providerId: "anthropic-subscription",
|
|
strategy: "priority",
|
|
accounts: [
|
|
{
|
|
id: "acct-work-1234",
|
|
label: "work-max",
|
|
email: "work@example.com",
|
|
enabled: overrides?.workEnabled ?? true,
|
|
priority: 0,
|
|
health: "ok",
|
|
hasCredential: true,
|
|
usage: {
|
|
sessionPct: overrides?.sessionPct ?? 42.4,
|
|
weeklyPct: 63.2,
|
|
resetsAt: NOW + 2 * 60 * 60 * 1000,
|
|
refreshedAt: NOW,
|
|
},
|
|
},
|
|
{
|
|
id: "acct-personal-5678",
|
|
label: "personal-pro",
|
|
email: "me@example.com",
|
|
enabled: overrides?.personalEnabled ?? false,
|
|
priority: 1,
|
|
health: "rate-limited",
|
|
hasCredential: true,
|
|
},
|
|
],
|
|
},
|
|
{ providerId: "openai-codex", strategy: "priority", accounts: [] },
|
|
],
|
|
};
|
|
}
|
|
|
|
describe("parseAccountsArgs", () => {
|
|
it("parses the report, toggles, strategy, and refresh shapes", () => {
|
|
expect(
|
|
parseAccountsArgs({ key: "accounts", canonical: "/accounts", args: [] }),
|
|
).toEqual({ kind: "report" });
|
|
expect(
|
|
parseAccountsArgs({
|
|
key: "accounts",
|
|
canonical: "/accounts",
|
|
args: [],
|
|
rawArgs: "use claude work-max",
|
|
}),
|
|
).toEqual({
|
|
kind: "use",
|
|
provider: "anthropic-subscription",
|
|
account: "work-max",
|
|
});
|
|
expect(
|
|
parseAccountsArgs({
|
|
key: "accounts",
|
|
canonical: "/accounts",
|
|
args: [],
|
|
rawArgs: "strategy codex round-robin",
|
|
}),
|
|
).toEqual({
|
|
kind: "strategy",
|
|
provider: "openai-codex",
|
|
strategy: "round-robin",
|
|
});
|
|
expect(
|
|
parseAccountsArgs({
|
|
key: "accounts",
|
|
canonical: "/accounts",
|
|
args: [],
|
|
rawArgs: "refresh cerebras",
|
|
}),
|
|
).toEqual({ kind: "refresh", provider: "cerebras-api" });
|
|
});
|
|
|
|
it("returns usage errors for unknown actions, providers, and strategies", () => {
|
|
expect(
|
|
parseAccountsArgs({
|
|
key: "accounts",
|
|
canonical: "/accounts",
|
|
args: [],
|
|
rawArgs: "frobnicate",
|
|
}),
|
|
).toMatchObject({ kind: "usage" });
|
|
expect(
|
|
parseAccountsArgs({
|
|
key: "accounts",
|
|
canonical: "/accounts",
|
|
args: [],
|
|
rawArgs: "use gemini acct",
|
|
}),
|
|
).toMatchObject({ kind: "usage" });
|
|
expect(
|
|
parseAccountsArgs({
|
|
key: "accounts",
|
|
canonical: "/accounts",
|
|
args: [],
|
|
rawArgs: "strategy claude fastest",
|
|
}),
|
|
).toMatchObject({ kind: "usage" });
|
|
expect(
|
|
parseAccountsArgs({
|
|
key: "accounts",
|
|
canonical: "/accounts",
|
|
args: [],
|
|
rawArgs: "use claude",
|
|
}),
|
|
).toMatchObject({ kind: "usage" });
|
|
});
|
|
});
|
|
|
|
describe("/accounts report", () => {
|
|
let runtime: IAgentRuntime;
|
|
beforeEach(() => {
|
|
initForRuntime("agent-accounts-backend");
|
|
runtime = makeRuntime();
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("renders strategy + per-account usage lines for providers that have accounts", async () => {
|
|
const fetchMock = vi.fn(async () => jsonResponse(accountsPayload()));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const r = await resolveCommand(runtime, msg("/accounts"), AUTHORIZED);
|
|
expect(r.handled).toBe(true);
|
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
|
const [call] = recordedCalls(fetchMock);
|
|
expect(call?.url).toContain("/api/accounts");
|
|
expect(call?.method).toBe("GET");
|
|
expect(r.reply).toContain(
|
|
"**anthropic-subscription** (strategy: priority)",
|
|
);
|
|
expect(r.reply).toContain("work-max (work@example.com) — enabled, prio 0");
|
|
expect(r.reply).toContain("session 42% / weekly 63%");
|
|
expect(r.reply).toContain("resets in");
|
|
expect(r.reply).toContain(
|
|
"personal-pro (me@example.com) — disabled, prio 1, rate-limited",
|
|
);
|
|
// Providers without accounts are omitted from the report.
|
|
expect(r.reply).not.toContain("openai-codex");
|
|
});
|
|
|
|
it("requires authorization for the bare read and never hits the route", async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const r = await resolveCommand(runtime, msg("/accounts"));
|
|
expect(r.reply).toBe("This command requires authorization.");
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("surfaces a route error verbatim", async () => {
|
|
const fetchMock = vi.fn(async () =>
|
|
jsonResponse({ error: "pool exploded" }, 500),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const r = await resolveCommand(runtime, msg("/accounts"), OWNER);
|
|
expect(r.reply).toContain("pool exploded");
|
|
});
|
|
});
|
|
|
|
describe("/accounts writes", () => {
|
|
let runtime: IAgentRuntime;
|
|
beforeEach(() => {
|
|
initForRuntime("agent-accounts-backend");
|
|
runtime = makeRuntime();
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("refuses writes (and their usage errors) without elevation, never hitting the route", async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const write = await resolveCommand(
|
|
runtime,
|
|
msg("/accounts use claude work-max"),
|
|
AUTHORIZED,
|
|
);
|
|
expect(write.reply).toBe("This command requires elevated permissions.");
|
|
|
|
const usage = await resolveCommand(
|
|
runtime,
|
|
msg("/accounts frobnicate"),
|
|
AUTHORIZED,
|
|
);
|
|
expect(usage.reply).toBe("This command requires elevated permissions.");
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("use enables the target then disables its enabled siblings, PATCH by PATCH", async () => {
|
|
const fetchMock = vi.fn(
|
|
async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
if ((init?.method ?? "GET") === "GET") {
|
|
// Second GET (post-patch re-read) reports the flipped states.
|
|
const flipped = fetchMock.mock.calls.length > 1;
|
|
return jsonResponse(
|
|
flipped
|
|
? accountsPayload({ workEnabled: false, personalEnabled: true })
|
|
: accountsPayload({ workEnabled: true, personalEnabled: true }),
|
|
);
|
|
}
|
|
return jsonResponse({ ok: true });
|
|
},
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const r = await resolveCommand(
|
|
runtime,
|
|
msg("/accounts use claude personal-pro"),
|
|
OWNER,
|
|
);
|
|
expect(r.handled).toBe(true);
|
|
const calls = recordedCalls(fetchMock);
|
|
expect(calls.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual([
|
|
"GET /api/accounts",
|
|
"PATCH /api/accounts/anthropic-subscription/acct-personal-5678",
|
|
"PATCH /api/accounts/anthropic-subscription/acct-work-1234",
|
|
"GET /api/accounts",
|
|
]);
|
|
expect(calls[1]?.body).toEqual({ enabled: true });
|
|
expect(calls[2]?.body).toEqual({ enabled: false });
|
|
expect(r.reply).toContain("Now using personal-pro");
|
|
expect(r.reply).toContain("work-max — disabled");
|
|
expect(r.reply).toContain("personal-pro — enabled");
|
|
});
|
|
|
|
it("refuses an ambiguous account with the candidate list and never PATCHes", async () => {
|
|
const payload = accountsPayload();
|
|
const provider = payload.providers[0];
|
|
if (!provider) throw new Error("fixture missing provider");
|
|
// Both accounts share a label so the token matches more than one.
|
|
for (const account of provider.accounts) account.label = "dup";
|
|
const fetchMock = vi.fn(async () => jsonResponse(payload));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const r = await resolveCommand(
|
|
runtime,
|
|
msg("/accounts use claude dup"),
|
|
OWNER,
|
|
);
|
|
expect(r.reply).toContain("matches more than one");
|
|
expect(r.reply).toContain("acct-work-1234");
|
|
expect(r.reply).toContain("acct-personal-5678");
|
|
expect(
|
|
recordedCalls(fetchMock).filter((c) => c.method === "PATCH"),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("refuses an unknown account with the candidate list and never PATCHes", async () => {
|
|
const fetchMock = vi.fn(async () => jsonResponse(accountsPayload()));
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const r = await resolveCommand(
|
|
runtime,
|
|
msg("/accounts enable claude nosuch"),
|
|
OWNER,
|
|
);
|
|
expect(r.reply).toContain(
|
|
'No anthropic-subscription account matches "nosuch"',
|
|
);
|
|
expect(r.reply).toContain("work-max");
|
|
expect(r.reply).toContain("personal-pro");
|
|
expect(
|
|
recordedCalls(fetchMock).filter((c) => c.method === "PATCH"),
|
|
).toEqual([]);
|
|
});
|
|
|
|
it("matches by id prefix (≥6 chars) and email, and disable is a single PATCH", async () => {
|
|
const fetchMock = vi.fn(
|
|
async (_input: RequestInfo | URL, init?: RequestInit) =>
|
|
(init?.method ?? "GET") === "GET"
|
|
? jsonResponse(accountsPayload())
|
|
: jsonResponse({ ok: true }),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const byPrefix = await resolveCommand(
|
|
runtime,
|
|
msg("/accounts disable claude acct-w"),
|
|
OWNER,
|
|
);
|
|
expect(byPrefix.reply).toBe(
|
|
"Disabled work-max for anthropic-subscription.",
|
|
);
|
|
|
|
const byEmail = await resolveCommand(
|
|
runtime,
|
|
msg("/accounts enable claude me@example.com"),
|
|
OWNER,
|
|
);
|
|
expect(byEmail.reply).toBe(
|
|
"Enabled personal-pro for anthropic-subscription.",
|
|
);
|
|
|
|
const patches = recordedCalls(fetchMock).filter(
|
|
(c) => c.method === "PATCH",
|
|
);
|
|
expect(patches).toEqual([
|
|
{
|
|
url: patches[0]?.url ?? "",
|
|
method: "PATCH",
|
|
body: { enabled: false },
|
|
},
|
|
{
|
|
url: patches[1]?.url ?? "",
|
|
method: "PATCH",
|
|
body: { enabled: true },
|
|
},
|
|
]);
|
|
expect(patches[0]?.url).toContain(
|
|
"/api/accounts/anthropic-subscription/acct-work-1234",
|
|
);
|
|
expect(patches[1]?.url).toContain(
|
|
"/api/accounts/anthropic-subscription/acct-personal-5678",
|
|
);
|
|
});
|
|
|
|
it("strategy PATCHes the provider strategy route with the exact body", async () => {
|
|
const fetchMock = vi.fn(async () =>
|
|
jsonResponse({ providerId: "cerebras-api", strategy: "least-used" }),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const r = await resolveCommand(
|
|
runtime,
|
|
msg("/accounts strategy cerebras least-used"),
|
|
OWNER,
|
|
);
|
|
const [call] = recordedCalls(fetchMock);
|
|
expect(call?.method).toBe("PATCH");
|
|
expect(new URL(call?.url ?? "").pathname).toBe(
|
|
"/api/providers/cerebras-api/strategy",
|
|
);
|
|
expect(call?.body).toEqual({ strategy: "least-used" });
|
|
expect(r.reply).toBe(
|
|
"Account strategy for cerebras-api set to least-used.",
|
|
);
|
|
});
|
|
|
|
it("surfaces a strategy route 400 verbatim", async () => {
|
|
const fetchMock = vi.fn(async () =>
|
|
jsonResponse({ error: "Unknown providerId: cerebras-api" }, 400),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const r = await resolveCommand(
|
|
runtime,
|
|
msg("/accounts strategy cerebras priority"),
|
|
OWNER,
|
|
);
|
|
expect(r.reply).toContain("Unknown providerId: cerebras-api");
|
|
});
|
|
|
|
it("refresh POSTs refresh-usage per account then re-reads fresh usage", async () => {
|
|
const fetchMock = vi.fn(
|
|
async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
if ((init?.method ?? "GET") === "GET") {
|
|
const refreshed = fetchMock.mock.calls.length > 1;
|
|
return jsonResponse(
|
|
accountsPayload(refreshed ? { sessionPct: 99 } : {}),
|
|
);
|
|
}
|
|
return jsonResponse({ account: {}, source: "pool" });
|
|
},
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const r = await resolveCommand(
|
|
runtime,
|
|
msg("/accounts refresh claude"),
|
|
OWNER,
|
|
);
|
|
const calls = recordedCalls(fetchMock);
|
|
expect(calls.map((c) => `${c.method} ${new URL(c.url).pathname}`)).toEqual([
|
|
"GET /api/accounts",
|
|
"POST /api/accounts/anthropic-subscription/acct-work-1234/refresh-usage",
|
|
"POST /api/accounts/anthropic-subscription/acct-personal-5678/refresh-usage",
|
|
"GET /api/accounts",
|
|
]);
|
|
expect(r.reply).toContain("Refreshed usage for anthropic-subscription");
|
|
expect(r.reply).toContain("session 99%");
|
|
});
|
|
|
|
it("refresh with an account token targets just that account and reports probe failures", async () => {
|
|
const fetchMock = vi.fn(
|
|
async (_input: RequestInfo | URL, init?: RequestInit) => {
|
|
if ((init?.method ?? "GET") === "GET") {
|
|
return jsonResponse(accountsPayload());
|
|
}
|
|
return jsonResponse({ error: "No credential available" }, 400);
|
|
},
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const r = await resolveCommand(
|
|
runtime,
|
|
msg("/accounts refresh claude work-max"),
|
|
OWNER,
|
|
);
|
|
const posts = recordedCalls(fetchMock).filter((c) => c.method === "POST");
|
|
expect(posts).toHaveLength(1);
|
|
expect(posts[0]?.url).toContain("acct-work-1234/refresh-usage");
|
|
expect(r.reply).toContain("Refresh failed for:");
|
|
expect(r.reply).toContain("work-max: No credential available");
|
|
});
|
|
});
|
|
|
|
describe("/backend", () => {
|
|
let runtime: IAgentRuntime;
|
|
beforeEach(() => {
|
|
initForRuntime("agent-accounts-backend");
|
|
runtime = makeRuntime();
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
it("parses show, writes, the elizaos alias, and usage errors", () => {
|
|
expect(
|
|
parseBackendArgs({ key: "backend", canonical: "/backend", args: [] }),
|
|
).toEqual({ kind: "show" });
|
|
expect(
|
|
parseBackendArgs({
|
|
key: "backend",
|
|
canonical: "/backend",
|
|
args: [],
|
|
rawArgs: "codex",
|
|
}),
|
|
).toEqual({ kind: "write", backend: "codex" });
|
|
expect(
|
|
parseBackendArgs({
|
|
key: "backend",
|
|
canonical: "/backend",
|
|
args: [],
|
|
rawArgs: "elizaos",
|
|
}),
|
|
).toEqual({ kind: "write", backend: "eliza-code" });
|
|
expect(
|
|
parseBackendArgs({
|
|
key: "backend",
|
|
canonical: "/backend",
|
|
args: [],
|
|
rawArgs: "vscode",
|
|
}),
|
|
).toMatchObject({ kind: "usage" });
|
|
expect(
|
|
parseBackendArgs({
|
|
key: "backend",
|
|
canonical: "/backend",
|
|
args: [],
|
|
rawArgs: "codex extra",
|
|
}),
|
|
).toMatchObject({ kind: "usage" });
|
|
});
|
|
|
|
it("bare /backend reads the coding target and lists the available backends", async () => {
|
|
const fetchMock = vi.fn(async () =>
|
|
jsonResponse({
|
|
targets: {
|
|
coding: {
|
|
ELIZA_DEFAULT_AGENT_TYPE: {
|
|
value: "opencode",
|
|
source: "config.env",
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const r = await resolveCommand(runtime, msg("/backend"), AUTHORIZED);
|
|
expect(r.handled).toBe(true);
|
|
const [call] = recordedCalls(fetchMock);
|
|
expect(call?.url).toContain("/api/models/config");
|
|
expect(call?.method).toBe("GET");
|
|
expect(r.reply).toContain("Default coding backend: opencode (config.env)");
|
|
expect(r.reply).toContain("codex, claude, eliza");
|
|
});
|
|
|
|
it("bare /backend reports the unset state", async () => {
|
|
const fetchMock = vi.fn(async () =>
|
|
jsonResponse({ targets: { coding: { ELIZA_DEFAULT_AGENT_TYPE: null } } }),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const r = await resolveCommand(runtime, msg("/backend"), AUTHORIZED);
|
|
expect(r.reply).toContain("not set");
|
|
});
|
|
|
|
it("requires authorization for the read and never hits the route", async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const r = await resolveCommand(runtime, msg("/backend"));
|
|
expect(r.reply).toBe("This command requires authorization.");
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("POSTs the defaultBackend-only write and narrates the restart-free apply", async () => {
|
|
const fetchMock = vi.fn(async () =>
|
|
jsonResponse({
|
|
applied: true,
|
|
restart: false,
|
|
keys: ["ELIZA_DEFAULT_AGENT_TYPE"],
|
|
}),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const r = await resolveCommand(runtime, msg("/backend codex"), OWNER);
|
|
const [call] = recordedCalls(fetchMock);
|
|
expect(call?.url).toContain("/api/models/config");
|
|
expect(call?.method).toBe("POST");
|
|
expect(call?.body).toEqual({ target: "coding", defaultBackend: "codex" });
|
|
expect(r.reply).toContain("Default coding backend set to codex");
|
|
expect(r.reply).toContain("no restart needed");
|
|
});
|
|
|
|
it("refuses writes (and usage errors) without elevation, never hitting the route", async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const write = await resolveCommand(
|
|
runtime,
|
|
msg("/backend codex"),
|
|
AUTHORIZED,
|
|
);
|
|
expect(write.reply).toBe("This command requires elevated permissions.");
|
|
|
|
const usage = await resolveCommand(
|
|
runtime,
|
|
msg("/backend vscode"),
|
|
AUTHORIZED,
|
|
);
|
|
expect(usage.reply).toBe("This command requires elevated permissions.");
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns the usage error for an unknown backend when elevated", async () => {
|
|
const fetchMock = vi.fn();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const r = await resolveCommand(runtime, msg("/backend vscode"), OWNER);
|
|
expect(r.reply).toContain('Unknown coding backend "vscode"');
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("passes the route's 400 validation error through verbatim", async () => {
|
|
const error = 'Unknown defaultBackend "vscode"';
|
|
const fetchMock = vi.fn(async () =>
|
|
jsonResponse({ error, code: "MODEL_CONFIG_INVALID", context: {} }, 400),
|
|
);
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
const r = await resolveCommand(runtime, msg("/backend opencode"), OWNER);
|
|
expect(r.reply).toContain(error);
|
|
});
|
|
});
|
|
|
|
describe("connector read gate (requiresAuth only, not requiresElevated)", () => {
|
|
beforeEach(() => {
|
|
initForRuntime("agent-accounts-backend");
|
|
});
|
|
|
|
// Regression: definition-level requiresElevated made connectors (which gate
|
|
// via gateConnectorCommandByName BEFORE runCommand) refuse the bare read to
|
|
// an authorized-but-not-elevated sender. With requiresAuth only, the read
|
|
// passes the bridge and the write subcommands re-check isElevated in-handler.
|
|
it.each([
|
|
"accounts",
|
|
"backend",
|
|
])("lets an authorized non-elevated sender through the bridge for /%s", (name) => {
|
|
const decision = gateConnectorCommandByName(
|
|
"agent-accounts-backend",
|
|
name,
|
|
{ isAuthorized: true, isElevated: false, senderName: "t" },
|
|
);
|
|
expect(decision.allowed).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
"accounts",
|
|
"backend",
|
|
])("refuses an unauthorized sender at the bridge for /%s", (name) => {
|
|
const decision = gateConnectorCommandByName(
|
|
"agent-accounts-backend",
|
|
name,
|
|
{ isAuthorized: false, isElevated: false, senderName: "t" },
|
|
);
|
|
expect(decision.allowed).toBe(false);
|
|
});
|
|
});
|