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.
- package/dist/lib/cli/remote/commands/keys.d.ts +10 -0
- package/dist/lib/cli/remote/commands/keys.js +30 -0
- package/dist/lib/cli/remote/commands/projects.d.ts +9 -0
- package/dist/lib/cli/remote/commands/projects.js +36 -0
- package/dist/lib/cli/remote/commands/util.d.ts +33 -2
- package/dist/lib/cli/remote/commands/util.js +45 -9
- package/dist/lib/cli/remote/commands/whoami.d.ts +4 -0
- package/dist/lib/cli/remote/commands/whoami.js +15 -0
- package/dist/lib/cli/remote/render.d.ts +6 -0
- package/dist/lib/cli/remote/render.js +54 -0
- package/dist/lib/cli/statelog/accountClient.d.ts +50 -0
- package/dist/lib/cli/statelog/accountClient.js +205 -0
- package/dist/lib/cli/statelog/serveUrl.d.ts +6 -0
- package/dist/lib/cli/statelog/serveUrl.js +27 -0
- package/dist/lib/runtime/interrupts.js +6 -0
- package/dist/lib/runtime/rootBudget.d.ts +30 -16
- package/dist/lib/runtime/rootBudget.js +93 -32
- package/dist/lib/runtime/state/context.js +4 -0
- package/dist/lib/serve/http/adapter.js +36 -6
- package/dist/scripts/agency.js +46 -0
- package/package.json +1 -1
|
@@ -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
|
|
12
|
-
*
|
|
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
|
|
2
|
-
// recipes. Owns exit/error output; never renders
|
|
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
|
|
12
|
-
*
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
/**
|
|
5
|
-
* AGENCY_MAX_TIME env vars)
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* budget
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
|
|
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
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
66
|
-
return fail(TOOL_ERROR_MESSAGE);
|
|
96
|
+
return errorResult(err, logger, "resume");
|
|
67
97
|
}
|
|
68
98
|
}
|
|
69
99
|
const FUNCTION_ROUTE = /^\/function\/([^/]+)$/;
|
package/dist/scripts/agency.js
CHANGED
|
@@ -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");
|