agency-lang 0.13.0 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ import type { AccountCommandOptions, RemoteCommandContext } from "./util.js";
2
+ export type CreateKeyOptions = AccountCommandOptions & {
3
+ project: string;
4
+ };
5
+ /** `agency remote keys` — list the account's API keys. */
6
+ export declare function runKeysList(options: AccountCommandOptions, context: RemoteCommandContext): Promise<void>;
7
+ /** `agency remote keys create <name> --project <slug>` — mint a project-scoped
8
+ * key. `--project` is the public slug; the client resolves it to the internal
9
+ * id. The plaintext key is printed once. */
10
+ export declare function runKeysCreate(name: string, options: CreateKeyOptions, context: RemoteCommandContext): Promise<void>;
@@ -0,0 +1,30 @@
1
+ import { createAccountClient } from "../../statelog/accountClient.js";
2
+ import { renderKeys, renderCreatedKey } from "../render.js";
3
+ import { resolveAccountTarget, failAccount } from "./util.js";
4
+ /** `agency remote keys` — list the account's API keys. */
5
+ export async function runKeysList(options, context) {
6
+ const target = resolveAccountTarget(context, options);
7
+ try {
8
+ const keys = await createAccountClient(target.origin, target.apiKey).listKeys();
9
+ console.log(renderKeys(keys));
10
+ }
11
+ catch (error) {
12
+ failAccount(error, target.apiKeyEnvName);
13
+ }
14
+ }
15
+ /** `agency remote keys create <name> --project <slug>` — mint a project-scoped
16
+ * key. `--project` is the public slug; the client resolves it to the internal
17
+ * id. The plaintext key is printed once. */
18
+ export async function runKeysCreate(name, options, context) {
19
+ const target = resolveAccountTarget(context, options);
20
+ try {
21
+ const created = await createAccountClient(target.origin, target.apiKey).createProjectKey({
22
+ name,
23
+ projectId: options.project,
24
+ });
25
+ console.log(renderCreatedKey(created));
26
+ }
27
+ catch (error) {
28
+ failAccount(error, target.apiKeyEnvName);
29
+ }
30
+ }
@@ -0,0 +1,9 @@
1
+ import type { AccountCommandOptions, RemoteCommandContext } from "./util.js";
2
+ export type CreateProjectOptions = AccountCommandOptions & {
3
+ name: string;
4
+ description?: string;
5
+ };
6
+ /** `agency remote projects` — list the account's projects. */
7
+ export declare function runProjectsList(options: AccountCommandOptions, context: RemoteCommandContext): Promise<void>;
8
+ /** `agency remote projects create <project_id>` — create a project. */
9
+ export declare function runProjectsCreate(projectId: string, options: CreateProjectOptions, context: RemoteCommandContext): Promise<void>;
@@ -0,0 +1,36 @@
1
+ import { createAccountClient } from "../../statelog/accountClient.js";
2
+ import { renderProjects, renderProjectCreated } from "../render.js";
3
+ import { resolveAccountTarget, failAccount, fail } from "./util.js";
4
+ // Matches statelog's server rule so a bad slug fails instantly, before any call.
5
+ const PROJECT_ID_PATTERN = /^[a-z0-9-]+$/;
6
+ const MAX_PROJECT_ID_LENGTH = 20;
7
+ /** `agency remote projects` — list the account's projects. */
8
+ export async function runProjectsList(options, context) {
9
+ const target = resolveAccountTarget(context, options);
10
+ try {
11
+ const projects = await createAccountClient(target.origin, target.apiKey).listProjects();
12
+ console.log(renderProjects(projects));
13
+ }
14
+ catch (error) {
15
+ failAccount(error, target.apiKeyEnvName);
16
+ }
17
+ }
18
+ /** `agency remote projects create <project_id>` — create a project. */
19
+ export async function runProjectsCreate(projectId, options, context) {
20
+ if (!PROJECT_ID_PATTERN.test(projectId) || projectId.length > MAX_PROJECT_ID_LENGTH) {
21
+ fail(`Invalid project id "${projectId}" — lowercase letters, digits, and dashes only, ` +
22
+ `${MAX_PROJECT_ID_LENGTH} characters or fewer.`);
23
+ }
24
+ const target = resolveAccountTarget(context, options);
25
+ try {
26
+ const project = await createAccountClient(target.origin, target.apiKey).createProject({
27
+ name: options.name,
28
+ projectId,
29
+ description: options.description,
30
+ });
31
+ console.log(renderProjectCreated(project));
32
+ }
33
+ catch (error) {
34
+ failAccount(error, target.apiKeyEnvName);
35
+ }
36
+ }
@@ -5,11 +5,42 @@ export type RemoteCommandContext = {
5
5
  config: AgencyConfig;
6
6
  configPath: string;
7
7
  };
8
+ /** The API key together with the environment variable it came from, so a command
9
+ * can name that variable in its guidance without the HTTP client knowing it. */
10
+ export type ResolvedApiKey = {
11
+ apiKey: string;
12
+ apiKeyEnvName: string;
13
+ };
14
+ /** An account-management target: a canonical origin plus the resolved key. */
15
+ export type AccountTarget = ResolvedApiKey & {
16
+ origin: string;
17
+ };
18
+ /** Options common to every account-management command. */
19
+ export type AccountCommandOptions = {
20
+ host?: string;
21
+ apiKeyEnv?: string;
22
+ };
8
23
  /** Print an error and exit non-zero. Typed `never` so callers can use it in an
9
24
  * expression position (`const x = maybe() ?? fail(...)`). */
10
25
  export declare function fail(message: string): never;
11
- /** The API key from its environment variable, or exit with a clear message.
12
- * The key is only ever read from the environment, never a flag. */
26
+ /** The API key and its variable name, or exit with a clear message. The key is
27
+ * only ever read from the environment, never a flag. */
28
+ export declare function resolveApiKey(options: {
29
+ apiKeyEnv?: string;
30
+ }): ResolvedApiKey;
31
+ /** The API key value alone, for callers that do not need the variable name. */
13
32
  export declare function apiKeyOrExit(options: {
14
33
  apiKeyEnv?: string;
15
34
  }): string;
35
+ /** Resolve where an account-management command talks to: a canonical origin (from
36
+ * `--host`, then `agency.json` `log.host`, then an existing binding's origin)
37
+ * and the resolved API key. Exits with a clear message on a missing or invalid
38
+ * origin, or a missing key. */
39
+ export declare function resolveAccountTarget(context: RemoteCommandContext, options: {
40
+ host?: string;
41
+ apiKeyEnv?: string;
42
+ }): AccountTarget;
43
+ /** Turn a client error into a clean CLI exit. An AccountScopeError becomes
44
+ * guidance naming the resolved API-key variable — the one place that knows both
45
+ * the scope error (from the client) and the variable name (from the target). */
46
+ export declare function failAccount(error: unknown, apiKeyEnvName: string): never;
@@ -1,6 +1,10 @@
1
- // Command-error presentation and API-key lookup, shared by the remote command
2
- // recipes. Owns exit/error output; never renders successful values.
1
+ // Command-error presentation, API-key lookup, and account-target resolution,
2
+ // shared by the remote command recipes. Owns exit/error output; never renders
3
+ // successful values.
3
4
  import { color } from "../../../utils/termcolors.js";
5
+ import { readBinding } from "../binding.js";
6
+ import { canonicalOrigin } from "../../statelog/serveUrl.js";
7
+ import { AccountScopeError } from "../../statelog/accountClient.js";
4
8
  const DEFAULT_API_KEY_ENV = "STATELOG_API_KEY";
5
9
  /** Print an error and exit non-zero. Typed `never` so callers can use it in an
6
10
  * expression position (`const x = maybe() ?? fail(...)`). */
@@ -8,13 +12,45 @@ export function fail(message) {
8
12
  console.error(color.red(message));
9
13
  process.exit(1);
10
14
  }
11
- /** The API key from its environment variable, or exit with a clear message.
12
- * The key is only ever read from the environment, never a flag. */
15
+ /** The API key and its variable name, or exit with a clear message. The key is
16
+ * only ever read from the environment, never a flag. */
17
+ export function resolveApiKey(options) {
18
+ const apiKeyEnvName = options.apiKeyEnv ?? DEFAULT_API_KEY_ENV;
19
+ const apiKey = process.env[apiKeyEnvName];
20
+ if (!apiKey) {
21
+ fail(`Missing API key — set $${apiKeyEnvName}.`);
22
+ }
23
+ return { apiKey, apiKeyEnvName };
24
+ }
25
+ /** The API key value alone, for callers that do not need the variable name. */
13
26
  export function apiKeyOrExit(options) {
14
- const name = options.apiKeyEnv ?? DEFAULT_API_KEY_ENV;
15
- const key = process.env[name];
16
- if (!key) {
17
- fail(`Missing API key set $${name}.`);
27
+ return resolveApiKey(options).apiKey;
28
+ }
29
+ /** Resolve where an account-management command talks to: a canonical origin (from
30
+ * `--host`, then `agency.json` `log.host`, then an existing binding's origin)
31
+ * and the resolved API key. Exits with a clear message on a missing or invalid
32
+ * origin, or a missing key. */
33
+ export function resolveAccountTarget(context, options) {
34
+ const bindingOrigin = readBinding(context.configPath)?.origin;
35
+ const selected = options.host ?? context.config.log?.host ?? bindingOrigin;
36
+ if (!selected) {
37
+ fail("No statelog host. Set log.host in agency.json, pass --host, or link this directory first.");
38
+ }
39
+ const origin = canonicalOrigin(selected);
40
+ if (!origin) {
41
+ fail(`Invalid statelog host "${selected}". Use an HTTP(S) origin with no path, credentials, query, or fragment.`);
42
+ }
43
+ return { origin, ...resolveApiKey(options) };
44
+ }
45
+ /** Turn a client error into a clean CLI exit. An AccountScopeError becomes
46
+ * guidance naming the resolved API-key variable — the one place that knows both
47
+ * the scope error (from the client) and the variable name (from the target). */
48
+ export function failAccount(error, apiKeyEnvName) {
49
+ if (error instanceof AccountScopeError) {
50
+ fail(`$${apiKeyEnvName} is a project-scoped key; this needs an account-scoped key. Create one in the statelog web UI.`);
51
+ }
52
+ if (error instanceof Error) {
53
+ fail(error.message);
18
54
  }
19
- return key;
55
+ fail(String(error));
20
56
  }
@@ -0,0 +1,4 @@
1
+ import type { AccountCommandOptions, RemoteCommandContext } from "./util.js";
2
+ /** `agency remote whoami` — resolve and print the authenticated user. Accepts an
3
+ * account- or project-scoped key, so it doubles as a "is my key valid" check. */
4
+ export declare function runWhoami(options: AccountCommandOptions, context: RemoteCommandContext): Promise<void>;
@@ -0,0 +1,15 @@
1
+ import { createAccountClient } from "../../statelog/accountClient.js";
2
+ import { renderWhoami } from "../render.js";
3
+ import { resolveAccountTarget, fail } from "./util.js";
4
+ /** `agency remote whoami` — resolve and print the authenticated user. Accepts an
5
+ * account- or project-scoped key, so it doubles as a "is my key valid" check. */
6
+ export async function runWhoami(options, context) {
7
+ const target = resolveAccountTarget(context, options);
8
+ try {
9
+ const { userId } = await createAccountClient(target.origin, target.apiKey).whoami();
10
+ console.log(renderWhoami(userId, target.origin));
11
+ }
12
+ catch (error) {
13
+ fail(error instanceof Error ? error.message : String(error));
14
+ }
15
+ }
@@ -1,5 +1,11 @@
1
1
  import type { ServeManifest } from "../statelog/serveClient.js";
2
2
  import type { RemoteBinding } from "./binding.js";
3
+ import type { ProjectSummary, KeySummary, CreatedKey } from "../statelog/accountClient.js";
3
4
  export declare function renderManifest(manifest: ServeManifest, binding: RemoteBinding): string;
4
5
  export declare function renderResult(value: unknown): string;
5
6
  export declare function renderLink(binding: RemoteBinding): string;
7
+ export declare function renderWhoami(userId: string, origin: string): string;
8
+ export declare function renderProjects(projects: ProjectSummary[]): string;
9
+ export declare function renderProjectCreated(project: ProjectSummary): string;
10
+ export declare function renderKeys(keys: KeySummary[]): string;
11
+ export declare function renderCreatedKey(key: CreatedKey): string;
@@ -2,6 +2,7 @@
2
2
  // call result, and the link status. Owns formatting so the command recipes
3
3
  // don't; all colour goes through termcolors.
4
4
  import { color } from "../../utils/termcolors.js";
5
+ const NONE = "—";
5
6
  export function renderManifest(manifest, binding) {
6
7
  const lines = [
7
8
  color.bold(binding.filename) + color.dim(` — ${binding.serveUrl}`),
@@ -34,3 +35,56 @@ export function renderLink(binding) {
34
35
  function effectsSuffix(interruptEffects) {
35
36
  return interruptEffects.length ? color.dim(` raises ${interruptEffects.join(", ")}`) : "";
36
37
  }
38
+ export function renderWhoami(userId, origin) {
39
+ return [
40
+ `${color.bold("User:")} ${userId}`,
41
+ `${color.bold("Host:")} ${color.dim(origin)}`,
42
+ ].join("\n");
43
+ }
44
+ export function renderProjects(projects) {
45
+ if (projects.length === 0) {
46
+ return color.dim("No projects yet.");
47
+ }
48
+ const rows = projects.map((project) => [
49
+ project.projectId,
50
+ project.name,
51
+ project.description ?? NONE,
52
+ ]);
53
+ return formatStaticTable(["PROJECT", "NAME", "DESCRIPTION"], rows);
54
+ }
55
+ export function renderProjectCreated(project) {
56
+ return `${color.green("Created project")} ${color.bold(project.projectId)} — ${project.name}`;
57
+ }
58
+ export function renderKeys(keys) {
59
+ if (keys.length === 0) {
60
+ return color.dim("No API keys yet.");
61
+ }
62
+ const rows = keys.map((key) => [
63
+ key.name ?? NONE,
64
+ key.scope,
65
+ key.projectId ?? NONE,
66
+ key.createdAt,
67
+ key.id,
68
+ ]);
69
+ return formatStaticTable(["NAME", "SCOPE", "PROJECT", "CREATED", "ID"], rows);
70
+ }
71
+ export function renderCreatedKey(key) {
72
+ const project = key.scope === "project" ? ` · ${key.projectId}` : "";
73
+ return [
74
+ `${color.green("Created API key")} ${color.bold(key.name ?? NONE)} (${key.scope}${project})`,
75
+ "",
76
+ color.yellow("Copy this key now — it will not be shown again:"),
77
+ ` ${key.plainKey}`,
78
+ ].join("\n");
79
+ }
80
+ /** A plain, ANSI-free aligned table. Colour is applied around the table by the
81
+ * renderers, never inside a cell, so byte-width never skews alignment. */
82
+ function formatStaticTable(headers, rows) {
83
+ const widths = headers.map((header, columnIndex) => {
84
+ const values = rows.map((row) => row[columnIndex] ?? "");
85
+ return Math.max(header.length, ...values.map((value) => value.length));
86
+ });
87
+ return [headers, ...rows]
88
+ .map((row) => row.map((value, index) => value.padEnd(widths[index] ?? 0)).join(" ").trimEnd())
89
+ .join("\n");
90
+ }
@@ -0,0 +1,50 @@
1
+ export type ProjectSummary = {
2
+ projectId: string;
3
+ name: string;
4
+ description: string | null;
5
+ };
6
+ export type CreateProjectInput = {
7
+ name: string;
8
+ projectId: string;
9
+ description?: string;
10
+ };
11
+ type KeySummaryBase = {
12
+ id: string;
13
+ name: string | null;
14
+ createdAt: string;
15
+ };
16
+ /** A key summary in public terms: a project key's `projectId` is the slug (the
17
+ * client has already translated it from the internal id). */
18
+ export type KeySummary = (KeySummaryBase & {
19
+ scope: "account";
20
+ projectId: null;
21
+ }) | (KeySummaryBase & {
22
+ scope: "project";
23
+ projectId: string;
24
+ });
25
+ export type CreatedKey = KeySummary & {
26
+ plainKey: string;
27
+ };
28
+ export type CreateProjectKeyInput = {
29
+ name: string;
30
+ projectId: string;
31
+ };
32
+ /** Any account request that did not produce a usable result. */
33
+ export declare class AccountRequestError extends Error {
34
+ }
35
+ /** A 403 whose server error is the known account-scope error. The command layer
36
+ * decorates it with the resolved API-key env var name; this file never knows
37
+ * that name. */
38
+ export declare class AccountScopeError extends AccountRequestError {
39
+ }
40
+ export type AccountClient = {
41
+ whoami(): Promise<{
42
+ userId: string;
43
+ }>;
44
+ listProjects(): Promise<ProjectSummary[]>;
45
+ createProject(input: CreateProjectInput): Promise<ProjectSummary>;
46
+ listKeys(): Promise<KeySummary[]>;
47
+ createProjectKey(input: CreateProjectKeyInput): Promise<CreatedKey>;
48
+ };
49
+ export declare function createAccountClient(origin: string, apiKey: string): AccountClient;
50
+ export {};
@@ -0,0 +1,205 @@
1
+ // The statelog account-management API, sealed here. This is the only file that
2
+ // knows the `/api/*` routes, the `Result` envelope, statelog's wire field names,
3
+ // and — critically — the split between the public project slug (`project_id`) and
4
+ // the internal database id (`id`). Callers speak only the public slug; the
5
+ // internal id never leaves this file. Every failure — network, HTTP, JSON,
6
+ // schema, or a `success:false` body — surfaces as an AccountRequestError.
7
+ /** Any account request that did not produce a usable result. */
8
+ export class AccountRequestError extends Error {
9
+ }
10
+ /** A 403 whose server error is the known account-scope error. The command layer
11
+ * decorates it with the resolved API-key env var name; this file never knows
12
+ * that name. */
13
+ export class AccountScopeError extends AccountRequestError {
14
+ }
15
+ const ACCOUNT_SCOPE_ERROR = "This endpoint requires an account-scoped API key";
16
+ function accountRouteUrl(origin, route) {
17
+ return new URL(`/api/${route}`, origin).toString();
18
+ }
19
+ export function createAccountClient(origin, apiKey) {
20
+ async function request(method, route, body) {
21
+ const headers = { Authorization: `Bearer ${apiKey}` };
22
+ const init = { method, headers };
23
+ if (method === "POST") {
24
+ headers["Content-Type"] = "application/json";
25
+ init.body = JSON.stringify(body ?? {});
26
+ }
27
+ let response;
28
+ try {
29
+ response = await fetch(accountRouteUrl(origin, route), init);
30
+ }
31
+ catch (error) {
32
+ throw new AccountRequestError(`could not reach ${origin} (${message(error)})`);
33
+ }
34
+ let json;
35
+ let parsed = true;
36
+ try {
37
+ json = await response.json();
38
+ }
39
+ catch {
40
+ parsed = false;
41
+ }
42
+ // Non-2xx first: auth middleware returns a bare `{ error }`, not an envelope.
43
+ if (!response.ok) {
44
+ const serverError = parsed ? asObject(json)?.error : undefined;
45
+ if (response.status === 403 && serverError === ACCOUNT_SCOPE_ERROR) {
46
+ throw new AccountScopeError(ACCOUNT_SCOPE_ERROR);
47
+ }
48
+ if (typeof serverError === "string") {
49
+ throw new AccountRequestError(serverError);
50
+ }
51
+ if (response.status === 401) {
52
+ throw new AccountRequestError("not authenticated (HTTP 401)");
53
+ }
54
+ throw new AccountRequestError(`statelog request failed (HTTP ${response.status})`);
55
+ }
56
+ if (!parsed) {
57
+ throw new AccountRequestError(`statelog returned a non-JSON response (HTTP ${response.status})`);
58
+ }
59
+ const envelope = asObject(json);
60
+ if (!envelope || typeof envelope.success !== "boolean") {
61
+ throw new AccountRequestError("unexpected account response shape");
62
+ }
63
+ if (!envelope.success) {
64
+ throw new AccountRequestError(typeof envelope.error === "string" ? envelope.error : "account request failed");
65
+ }
66
+ return envelope.value;
67
+ }
68
+ async function listProjectsRaw() {
69
+ const value = await request("GET", "projects");
70
+ return asArray(value, "projects").map(validateRawProject);
71
+ }
72
+ return {
73
+ async whoami() {
74
+ return validateWhoami(await request("GET", "whoami"));
75
+ },
76
+ async listProjects() {
77
+ return (await listProjectsRaw()).map(toProjectSummary);
78
+ },
79
+ async createProject(input) {
80
+ const value = await request("POST", "projects", {
81
+ name: input.name,
82
+ project_id: input.projectId,
83
+ description: input.description ?? null,
84
+ });
85
+ return toProjectSummary(validateRawProject(value));
86
+ },
87
+ async listKeys() {
88
+ const projects = await listProjectsRaw();
89
+ const value = await request("GET", "api_keys");
90
+ const slugById = slugByInternalId(projects);
91
+ return asArray(value, "api_keys").map((entry) => toKeySummary(validateRawKeySummary(entry), slugById));
92
+ },
93
+ async createProjectKey(input) {
94
+ const projects = await listProjectsRaw();
95
+ const match = projects.find((project) => project.project_id === input.projectId);
96
+ if (!match) {
97
+ throw new AccountRequestError(`unknown project '${input.projectId}'`);
98
+ }
99
+ const value = await request("POST", "api_keys", {
100
+ name: input.name,
101
+ scope: "project",
102
+ projectId: match.id,
103
+ });
104
+ return validateCreatedKey(value, slugByInternalId(projects));
105
+ },
106
+ };
107
+ }
108
+ function toProjectSummary(raw) {
109
+ return { projectId: raw.project_id, name: raw.name, description: raw.description };
110
+ }
111
+ function validateWhoami(value) {
112
+ const obj = asObject(value);
113
+ if (!obj || typeof obj.userId !== "string") {
114
+ throw new AccountRequestError("whoami response missing userId");
115
+ }
116
+ return { userId: obj.userId };
117
+ }
118
+ function validateRawProject(value) {
119
+ const obj = asObject(value);
120
+ if (!obj) {
121
+ throw new AccountRequestError("each project entry must be an object");
122
+ }
123
+ const id = requireString(obj.id, "project id");
124
+ const project_id = requireString(obj.project_id, "project project_id");
125
+ const name = requireString(obj.name, "project name");
126
+ if (obj.description !== null && typeof obj.description !== "string") {
127
+ throw new AccountRequestError("project description must be a string or null");
128
+ }
129
+ return { id, project_id, name, description: obj.description };
130
+ }
131
+ /** internal id → public slug, so a listed key's project can be shown by slug.
132
+ * Null-prototype: the keys are server-provided ids, so an id like `__proto__`
133
+ * or `constructor` must be a plain data property, not a prototype write. */
134
+ function slugByInternalId(projects) {
135
+ const map = Object.create(null);
136
+ for (const project of projects) {
137
+ map[project.id] = project.project_id;
138
+ }
139
+ return map;
140
+ }
141
+ function validateRawKeySummary(value) {
142
+ const obj = asObject(value);
143
+ if (!obj) {
144
+ throw new AccountRequestError("each key entry must be an object");
145
+ }
146
+ const id = requireString(obj.id, "key id");
147
+ if (obj.name !== null && typeof obj.name !== "string") {
148
+ throw new AccountRequestError("key name must be a string or null");
149
+ }
150
+ const createdAt = requireString(obj.createdAt, "key createdAt");
151
+ const base = { id, name: obj.name, createdAt };
152
+ if (obj.scope === "account") {
153
+ if (obj.projectId !== null) {
154
+ throw new AccountRequestError("account key must have a null projectId");
155
+ }
156
+ return { ...base, scope: "account", projectId: null };
157
+ }
158
+ if (obj.scope === "project") {
159
+ return { ...base, scope: "project", projectId: requireString(obj.projectId, "project key projectId") };
160
+ }
161
+ throw new AccountRequestError(`unknown key scope ${String(obj.scope)}`);
162
+ }
163
+ /** Replace a project key's internal id with its public slug (or a placeholder
164
+ * when the project no longer exists — never the raw id). */
165
+ function toKeySummary(raw, slugById) {
166
+ if (raw.scope === "account") {
167
+ return { id: raw.id, name: raw.name, createdAt: raw.createdAt, scope: "account", projectId: null };
168
+ }
169
+ return {
170
+ id: raw.id,
171
+ name: raw.name,
172
+ createdAt: raw.createdAt,
173
+ scope: "project",
174
+ projectId: slugById[raw.projectId] ?? "(unknown project)",
175
+ };
176
+ }
177
+ function validateCreatedKey(value, slugById) {
178
+ const summary = toKeySummary(validateRawKeySummary(value), slugById);
179
+ const obj = asObject(value);
180
+ if (!obj || typeof obj.plainKey !== "string") {
181
+ throw new AccountRequestError("created key missing plainKey");
182
+ }
183
+ return { ...summary, plainKey: obj.plainKey };
184
+ }
185
+ function asObject(value) {
186
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
187
+ return null;
188
+ }
189
+ return value;
190
+ }
191
+ function asArray(value, label) {
192
+ if (!Array.isArray(value)) {
193
+ throw new AccountRequestError(`${label} must be an array`);
194
+ }
195
+ return value;
196
+ }
197
+ function requireString(value, label) {
198
+ if (typeof value !== "string") {
199
+ throw new AccountRequestError(`${label} must be a string`);
200
+ }
201
+ return value;
202
+ }
203
+ function message(error) {
204
+ return error instanceof Error ? error.message : String(error);
205
+ }
@@ -27,3 +27,9 @@ export declare function parseServeBaseUrl(rawUrl: string): ServeAddress | null;
27
27
  export declare function serveRouteUrl(serveUrl: string, segments: string[]): string;
28
28
  /** The statelog web-app page for an agent's project. */
29
29
  export declare function projectPageUrl(address: ServeAddress): string;
30
+ /** Reduce a host string to a bare origin (`scheme://host[:port]`), or null if it
31
+ * is not a usable statelog origin. Account-management routes are built from this
32
+ * with `new URL(...)`, so anything ambiguous is rejected: non-HTTP(S) schemes,
33
+ * embedded credentials, a query or fragment, or a non-root path (statelog is
34
+ * served at the root). A trailing slash is canonicalized away. */
35
+ export declare function canonicalOrigin(host: string): string | null;
@@ -90,3 +90,30 @@ export function projectPageUrl(address) {
90
90
  url.searchParams.set("id", address.projectId);
91
91
  return url.toString();
92
92
  }
93
+ /** Reduce a host string to a bare origin (`scheme://host[:port]`), or null if it
94
+ * is not a usable statelog origin. Account-management routes are built from this
95
+ * with `new URL(...)`, so anything ambiguous is rejected: non-HTTP(S) schemes,
96
+ * embedded credentials, a query or fragment, or a non-root path (statelog is
97
+ * served at the root). A trailing slash is canonicalized away. */
98
+ export function canonicalOrigin(host) {
99
+ let parsed;
100
+ try {
101
+ parsed = new URL(host);
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
107
+ return null;
108
+ }
109
+ if (parsed.username || parsed.password) {
110
+ return null;
111
+ }
112
+ if (parsed.search || parsed.hash) {
113
+ return null;
114
+ }
115
+ if (parsed.pathname !== "/" && parsed.pathname !== "") {
116
+ return null;
117
+ }
118
+ return parsed.origin;
119
+ }
@@ -4,6 +4,7 @@ import { z } from "zod";
4
4
  import { approve, reject } from "./interruptResponse.js";
5
5
  import { runInBootstrapFrame } from "./asyncContext.js";
6
6
  import { __initAllRegisteredCallbacks } from "./crossModuleInitRegistry.js";
7
+ import { reinstallRootBudget } from "./rootBudget.js";
7
8
  import { AgencyCancelledError, HandlerRecursionError, RestoreSignal, } from "./errors.js";
8
9
  import { isAborted } from "./abortedResult.js";
9
10
  import { mergeFor, mergeForIpc } from "./effectMerge.js";
@@ -633,6 +634,11 @@ export async function respondToInterrupts(args) {
633
634
  // and lib/runtime/asyncContext.ts (`runInBootstrapFrame`).
634
635
  await runInBootstrapFrame(execCtx, () => __initAllRegisteredCallbacks(execCtx));
635
636
  execCtx.restoreState(checkpoint);
637
+ // Re-assert the root budget's LIMIT from the host context (the checkpoint's
638
+ // ceiling is caller-controllable on a stateless resume), while preserving the
639
+ // guard's accumulated spend so the trusted CLI resume path stays cumulative.
640
+ // No-op in IPC.
641
+ reinstallRootBudget(execCtx.stateStack, execCtx.budget);
636
642
  execCtx.setInterruptResponses(responseMap);
637
643
  if (metadata.callbacks)
638
644
  Object.assign(execCtx.callbacks, metadata.callbacks);
@@ -1,22 +1,36 @@
1
1
  import type { StateStack } from "./state/stateStack.js";
2
- /** Install a root cost/time guard from the CLI flags (AGENCY_MAX_COST /
3
- * AGENCY_MAX_TIME env vars) or, when a flag is absent for that dimension, the
4
- * resolved config `budget` (passed as `contextBudget`). The flag wins per
5
- * dimension, so `agency run --max-cost` still overrides an `agency.json`
6
- * budget; a served agent, which has no flag, is governed by the budget its
7
- * host bound via runtime-config overrides.
2
+ /** Install a root cost/time budget. Applies the disable rule: cost < 0 installs
3
+ * nothing; time <= 0 installs nothing (cost 0 IS a real limit no paid spend,
4
+ * local-models-only). Called once at the root, next to installRunPolicyHandler,
5
+ * before the node body runs, so the budget is outermost and cannot be bypassed.
6
+ * No-op in IPC subprocesses a child's budget is owned by the parent's guard,
7
+ * which meters the subprocess through the branch clone.
8
8
  *
9
- * Applies the disable rule: cost < 0 installs nothing; time <= 0 installs
10
- * nothing (cost 0 IS a real limit no paid spend, local-models-only).
11
- * Called once at the root, next to installRunPolicyHandler, before the
12
- * node body runs, so the budget is outermost and cannot be bypassed.
13
- * No-op in IPC subprocesses — a child's budget is owned by the parent's
14
- * guard, which meters the subprocess through the branch clone.
15
- *
16
- * pushGuard() installs immediately, so a time budget's clock starts at
17
- * run start — the intended whole-run semantics. Interrupt halts and
18
- * input() waits still pause it like any other time guard. */
9
+ * pushGuard() installs immediately, so a time budget's clock starts at run
10
+ * start the intended whole-run semantics. Interrupt halts and input() waits
11
+ * still pause it like any other time guard. */
19
12
  export declare function installRootBudget(stack: StateStack, contextBudget?: {
20
13
  maxCost?: number;
21
14
  maxTimeMs?: number;
22
15
  }): void;
16
+ /** Re-assert the root budget on a RESUMED exec context. The root guard is
17
+ * serialized with the checkpoint, so on a stateless served resume its limit
18
+ * arrives from a client-controllable payload — a crafted resume could raise the
19
+ * ceiling. Clamp the restored root guard's LIMIT to the host value while
20
+ * KEEPING the guard (and its accumulated spend/elapsed):
21
+ *
22
+ * - the limit becomes host-authoritative and un-raisable from the client;
23
+ * - accumulated spend is preserved, so the trusted in-process CLI resume path
24
+ * stays cumulative across legs — no regression — and a served client that
25
+ * lowers its own reported spend only hurts itself (same residual gap a
26
+ * stateless resume always has);
27
+ * - arming is untouched (restored guards are un-armed by restoreState), so this
28
+ * can't reintroduce a timer pop-race.
29
+ *
30
+ * A dimension the host no longer caps has its restored root guard dropped
31
+ * (through `uninstall`); a dimension the host caps but the checkpoint had no
32
+ * root guard for gets a fresh guard. No-op in IPC. */
33
+ export declare function reinstallRootBudget(stack: StateStack, contextBudget?: {
34
+ maxCost?: number;
35
+ maxTimeMs?: number;
36
+ }): void;
@@ -1,50 +1,111 @@
1
1
  import { AGENCY_MAX_COST, AGENCY_MAX_TIME } from "../constants.js";
2
2
  import { CostGuard, TimeGuard } from "./guard.js";
3
3
  import { isIpcMode } from "./subprocessRunInfo.js";
4
- /** Install a root cost/time guard from the CLI flags (AGENCY_MAX_COST /
5
- * AGENCY_MAX_TIME env vars) or, when a flag is absent for that dimension, the
6
- * resolved config `budget` (passed as `contextBudget`). The flag wins per
7
- * dimension, so `agency run --max-cost` still overrides an `agency.json`
8
- * budget; a served agent, which has no flag, is governed by the budget its
9
- * host bound via runtime-config overrides.
10
- *
11
- * Applies the disable rule: cost < 0 installs nothing; time <= 0 installs
12
- * nothing (cost 0 IS a real limit — no paid spend, local-models-only).
13
- * Called once at the root, next to installRunPolicyHandler, before the
14
- * node body runs, so the budget is outermost and cannot be bypassed.
15
- * No-op in IPC subprocesses — a child's budget is owned by the parent's
16
- * guard, which meters the subprocess through the branch clone.
17
- *
18
- * pushGuard() installs immediately, so a time budget's clock starts at
19
- * run start — the intended whole-run semantics. Interrupt halts and
20
- * input() waits still pause it like any other time guard. */
21
- export function installRootBudget(stack, contextBudget) {
22
- if (isIpcMode())
23
- return;
4
+ /** The host-authoritative root limits: the CLI flag (AGENCY_MAX_COST /
5
+ * AGENCY_MAX_TIME env vars) if set, else the resolved config `budget`. The flag
6
+ * wins per dimension, so `agency run --max-cost` still overrides an
7
+ * `agency.json` budget; a served agent, which has no flag, is governed by the
8
+ * budget its host bound via runtime-config overrides. `undefined` means "no
9
+ * cap this dimension"; a value still passes through the disable rule at the
10
+ * call site (cost < 0 / time <= 0 install nothing). FAILS CLOSED on a
11
+ * malformed value. */
12
+ function resolveRootLimits(contextBudget) {
24
13
  const rawCost = process.env[AGENCY_MAX_COST];
25
14
  const cost = rawCost !== undefined
26
15
  ? parseBudgetValue(rawCost, AGENCY_MAX_COST)
27
16
  : contextBudget?.maxCost !== undefined
28
17
  ? finiteContextBudget(contextBudget.maxCost, "budget.maxCost")
29
18
  : undefined;
30
- if (cost !== undefined && cost >= 0) {
31
- const g = new CostGuard(cost);
32
- // The operator's ceiling: never raises an interrupt, never
33
- // extendable by user code. Serialized with the guard.
34
- g.isRootBudget = true;
35
- stack.pushGuard(g);
36
- }
37
19
  const rawTime = process.env[AGENCY_MAX_TIME];
38
- const ms = rawTime !== undefined
20
+ const timeMs = rawTime !== undefined
39
21
  ? parseBudgetValue(rawTime, AGENCY_MAX_TIME)
40
22
  : contextBudget?.maxTimeMs !== undefined
41
23
  ? finiteContextBudget(contextBudget.maxTimeMs, "budget.maxTime")
42
24
  : undefined;
43
- if (ms !== undefined && ms > 0) {
44
- const g = new TimeGuard(ms);
45
- g.isRootBudget = true;
46
- stack.pushGuard(g);
25
+ return { cost, timeMs };
26
+ }
27
+ function pushRootGuard(stack, guard) {
28
+ // The operator's ceiling: never raises an interrupt, never extendable by user
29
+ // code. Serialized with the guard.
30
+ guard.isRootBudget = true;
31
+ stack.pushGuard(guard);
32
+ }
33
+ /** Install a root cost/time budget. Applies the disable rule: cost < 0 installs
34
+ * nothing; time <= 0 installs nothing (cost 0 IS a real limit — no paid spend,
35
+ * local-models-only). Called once at the root, next to installRunPolicyHandler,
36
+ * before the node body runs, so the budget is outermost and cannot be bypassed.
37
+ * No-op in IPC subprocesses — a child's budget is owned by the parent's guard,
38
+ * which meters the subprocess through the branch clone.
39
+ *
40
+ * pushGuard() installs immediately, so a time budget's clock starts at run
41
+ * start — the intended whole-run semantics. Interrupt halts and input() waits
42
+ * still pause it like any other time guard. */
43
+ export function installRootBudget(stack, contextBudget) {
44
+ if (isIpcMode())
45
+ return;
46
+ const { cost, timeMs } = resolveRootLimits(contextBudget);
47
+ if (cost !== undefined && cost >= 0)
48
+ pushRootGuard(stack, new CostGuard(cost));
49
+ if (timeMs !== undefined && timeMs > 0)
50
+ pushRootGuard(stack, new TimeGuard(timeMs));
51
+ }
52
+ /** Re-assert the root budget on a RESUMED exec context. The root guard is
53
+ * serialized with the checkpoint, so on a stateless served resume its limit
54
+ * arrives from a client-controllable payload — a crafted resume could raise the
55
+ * ceiling. Clamp the restored root guard's LIMIT to the host value while
56
+ * KEEPING the guard (and its accumulated spend/elapsed):
57
+ *
58
+ * - the limit becomes host-authoritative and un-raisable from the client;
59
+ * - accumulated spend is preserved, so the trusted in-process CLI resume path
60
+ * stays cumulative across legs — no regression — and a served client that
61
+ * lowers its own reported spend only hurts itself (same residual gap a
62
+ * stateless resume always has);
63
+ * - arming is untouched (restored guards are un-armed by restoreState), so this
64
+ * can't reintroduce a timer pop-race.
65
+ *
66
+ * A dimension the host no longer caps has its restored root guard dropped
67
+ * (through `uninstall`); a dimension the host caps but the checkpoint had no
68
+ * root guard for gets a fresh guard. No-op in IPC. */
69
+ export function reinstallRootBudget(stack, contextBudget) {
70
+ if (isIpcMode())
71
+ return;
72
+ const { cost, timeMs } = resolveRootLimits(contextBudget);
73
+ const hostCost = cost !== undefined && cost >= 0 ? cost : undefined;
74
+ const hostTimeMs = timeMs !== undefined && timeMs > 0 ? timeMs : undefined;
75
+ let sawCost = false;
76
+ let sawTime = false;
77
+ const dropped = [];
78
+ for (const guard of stack.guards) {
79
+ if (!guard.isRootBudget)
80
+ continue;
81
+ if (guard instanceof CostGuard) {
82
+ sawCost = true;
83
+ // Direct clamp (not extendBudget, which only grants upward): the host
84
+ // ceiling replaces whatever the checkpoint carried, preserving `spent`.
85
+ if (hostCost !== undefined)
86
+ guard.costLimit = hostCost;
87
+ else
88
+ dropped.push(guard);
89
+ }
90
+ else if (guard instanceof TimeGuard) {
91
+ sawTime = true;
92
+ if (hostTimeMs !== undefined)
93
+ guard.timeLimit = hostTimeMs;
94
+ else
95
+ dropped.push(guard);
96
+ }
97
+ }
98
+ if (dropped.length > 0) {
99
+ for (const guard of dropped)
100
+ guard.uninstall(stack);
101
+ stack.guards = stack.guards.filter((g) => !dropped.includes(g));
102
+ stack.rebuildAbortSignal();
47
103
  }
104
+ // The host caps a dimension the checkpoint had no root guard for.
105
+ if (!sawCost && hostCost !== undefined)
106
+ pushRootGuard(stack, new CostGuard(hostCost));
107
+ if (!sawTime && hostTimeMs !== undefined)
108
+ pushRootGuard(stack, new TimeGuard(hostTimeMs));
48
109
  }
49
110
  /** FAIL CLOSED on a malformed budget value. The env is an internal
50
111
  * carrier and the CLI validates before setting it, so a non-finite
@@ -316,6 +316,10 @@ export class RuntimeContext {
316
316
  execCtx.maxRestores = this.maxRestores;
317
317
  execCtx.maxCallDepth = this.maxCallDepth;
318
318
  execCtx.failurePropagation = this.failurePropagation;
319
+ // Non-serialized field — must be copied here (see the class comment) so
320
+ // installRootBudget, which reads execCtx.budget, actually sees the resolved
321
+ // config/override budget. Without this the root budget is a silent no-op.
322
+ execCtx.budget = this.budget;
319
323
  execCtx.checkpoints = new CheckpointStore(this.maxRestores);
320
324
  // The execution context is built via Object.create, bypassing the
321
325
  // constructor, so carry the clock over from the global context. Without
@@ -1,6 +1,8 @@
1
1
  import http from "http";
2
2
  import { errorMessage, toArgs, parseJsonBody } from "../util.js";
3
3
  import { validateResumeBatch } from "../../runtime/interrupts.js";
4
+ import { readCause } from "../../runtime/errors.js";
5
+ import { formatBudgetExceeded } from "../../runtime/budgetExit.js";
4
6
  import { DEFAULT_HOST, defaultAllowedHosts, enforceNoKeyOnNonLoopback, logServerStart, makeGuardedRequestListener, } from "./security.js";
5
7
  function ok(value) {
6
8
  return { status: 200, body: { success: true, value } };
@@ -20,14 +22,44 @@ function interruptResult(data) {
20
22
  * avoid leaking secrets, file paths, model API responses, etc.
21
23
  */
22
24
  const TOOL_ERROR_MESSAGE = "Tool execution failed";
25
+ /** A tripped ROOT budget is a spend/time stop, not a generic tool failure.
26
+ * Return a typed, structured result (HTTP 402) carrying the dimension, limit,
27
+ * and spend, so a caller and traces can tell it apart without string-matching
28
+ * the generic message. Unlike other errors, the budget numbers are safe to
29
+ * surface — they are the operator's own limit, not server internals. */
30
+ function budgetExceeded(cause) {
31
+ return {
32
+ status: 402,
33
+ body: {
34
+ success: false,
35
+ code: "budgetExceeded",
36
+ error: formatBudgetExceeded(cause),
37
+ dimension: cause.dimension,
38
+ limit: cause.limit,
39
+ spent: cause.spent,
40
+ },
41
+ };
42
+ }
43
+ /** Map a thrown error to a route result: a root-budget trip becomes a typed
44
+ * budgetExceeded (402); anything else is logged and returned as the generic
45
+ * tool error so no server detail leaks. Detection is by CAUSE (mirrors
46
+ * budgetExit), so it catches a root trip however it surfaced. */
47
+ function errorResult(err, logger, what) {
48
+ const cause = readCause(err);
49
+ if (cause?.kind === "guardTrip") {
50
+ logger.error(`${what}: ${formatBudgetExceeded(cause)}`);
51
+ return budgetExceeded(cause);
52
+ }
53
+ logger.error(`${what} threw: ${errorMessage(err)}`);
54
+ return fail(TOOL_ERROR_MESSAGE);
55
+ }
23
56
  async function callFunction(fn, body, logger) {
24
57
  try {
25
58
  const result = await fn.invoke(toArgs(body));
26
59
  return ok(result);
27
60
  }
28
61
  catch (err) {
29
- logger.error(`function ${fn.name} threw: ${errorMessage(err)}`);
30
- return fail(TOOL_ERROR_MESSAGE);
62
+ return errorResult(err, logger, `function ${fn.name}`);
31
63
  }
32
64
  }
33
65
  async function callNode(node, body, hasInterrupts, logger) {
@@ -40,8 +72,7 @@ async function callNode(node, body, hasInterrupts, logger) {
40
72
  return ok(result.data);
41
73
  }
42
74
  catch (err) {
43
- logger.error(`node ${node.name} threw: ${errorMessage(err)}`);
44
- return fail(TOOL_ERROR_MESSAGE);
75
+ return errorResult(err, logger, `node ${node.name}`);
45
76
  }
46
77
  }
47
78
  async function resumeInterrupts(respondToInterrupts, hasInterrupts, body, logger) {
@@ -62,8 +93,7 @@ async function resumeInterrupts(respondToInterrupts, hasInterrupts, body, logger
62
93
  return ok(result.data);
63
94
  }
64
95
  catch (err) {
65
- logger.error(`resume threw: ${errorMessage(err)}`);
66
- return fail(TOOL_ERROR_MESSAGE);
96
+ return errorResult(err, logger, "resume");
67
97
  }
68
98
  }
69
99
  const FUNCTION_ROUTE = /^\/function\/([^/]+)$/;
@@ -8,6 +8,9 @@ import { runDeploy } from "../lib/cli/remote/commands/deploy.js";
8
8
  import { runLs } from "../lib/cli/remote/commands/ls.js";
9
9
  import { runCall } from "../lib/cli/remote/commands/call.js";
10
10
  import { runOpen } from "../lib/cli/remote/commands/open.js";
11
+ import { runWhoami } from "../lib/cli/remote/commands/whoami.js";
12
+ import { runProjectsList, runProjectsCreate } from "../lib/cli/remote/commands/projects.js";
13
+ import { runKeysList, runKeysCreate } from "../lib/cli/remote/commands/keys.js";
11
14
  import { lintSource } from "../lib/linter/registry.js";
12
15
  import { formatFindings } from "../lib/cli/lint.js";
13
16
  import { resolveBudget } from "../lib/cli/budget.js";
@@ -306,6 +309,49 @@ export function createProgram(deps = {}) {
306
309
  .command("open")
307
310
  .description("Open the linked agent's project page in a browser")
308
311
  .action(() => runOpen(getConfigContext()));
312
+ const HOST_OPTION = "--host <origin>";
313
+ const HOST_DESC = "statelog host (overrides agency.json log.host)";
314
+ const API_KEY_ENV_OPTION = "--api-key-env <name>";
315
+ const API_KEY_ENV_DESC = "env var to read the API key from (default: STATELOG_API_KEY)";
316
+ remoteCmd
317
+ .command("whoami")
318
+ .description("Show the authenticated statelog user")
319
+ .option(HOST_OPTION, HOST_DESC)
320
+ .option(API_KEY_ENV_OPTION, API_KEY_ENV_DESC)
321
+ .action((opts) => runWhoami(opts, getConfigContext()));
322
+ const projectsCmd = remoteCmd
323
+ .command("projects")
324
+ .description("List or create statelog projects (account-scoped key)");
325
+ projectsCmd
326
+ .command("list", { isDefault: true })
327
+ .description("List the account's projects")
328
+ .option(HOST_OPTION, HOST_DESC)
329
+ .option(API_KEY_ENV_OPTION, API_KEY_ENV_DESC)
330
+ .action((opts) => runProjectsList(opts, getConfigContext()));
331
+ projectsCmd
332
+ .command("create <project_id>")
333
+ .description("Create a project")
334
+ .requiredOption("--name <name>", "human-readable project name")
335
+ .option("--description <text>", "optional project description")
336
+ .option(HOST_OPTION, HOST_DESC)
337
+ .option(API_KEY_ENV_OPTION, API_KEY_ENV_DESC)
338
+ .action((projectId, opts) => runProjectsCreate(projectId, opts, getConfigContext()));
339
+ const keysCmd = remoteCmd
340
+ .command("keys")
341
+ .description("List or create statelog API keys (account-scoped key)");
342
+ keysCmd
343
+ .command("list", { isDefault: true })
344
+ .description("List the account's API keys")
345
+ .option(HOST_OPTION, HOST_DESC)
346
+ .option(API_KEY_ENV_OPTION, API_KEY_ENV_DESC)
347
+ .action((opts) => runKeysList(opts, getConfigContext()));
348
+ keysCmd
349
+ .command("create <name>")
350
+ .description("Create a project-scoped API key")
351
+ .requiredOption("--project <slug>", "project slug the key is scoped to")
352
+ .option(HOST_OPTION, HOST_DESC)
353
+ .option(API_KEY_ENV_OPTION, API_KEY_ENV_DESC)
354
+ .action((name, opts) => runKeysCreate(name, opts, getConfigContext()));
309
355
  const traceCmd = program
310
356
  .command("trace")
311
357
  .description("Trace-related commands");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agency-lang",
3
- "version": "0.13.0",
3
+ "version": "0.13.1",
4
4
  "description": "The Agency language",
5
5
  "main": "./dist/lib/index.js",
6
6
  "bin": {