@intentius/chant-lexicon-fountain 0.89.0 → 0.91.0
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/composites/box.d.ts +166 -0
- package/dist/composites/box.d.ts.map +1 -0
- package/dist/composites/steward.d.ts +63 -1
- package/dist/composites/steward.d.ts.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/integrity.json +3 -3
- package/dist/manifest.json +1 -1
- package/dist/op/activities/fountain-run.d.ts +77 -6
- package/dist/op/activities/fountain-run.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +2 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/skills/chant-fountain-locked-sandboxes.md +1 -1
- package/package.json +2 -2
- package/src/composites/box.ts +255 -0
- package/src/composites/composites.test.ts +300 -1
- package/src/composites/steward.ts +81 -2
- package/src/index.ts +9 -0
- package/src/op/activities/fountain-apply.test.ts +0 -54
- package/src/op/activities/fountain-run.test.ts +219 -0
- package/src/op/activities/fountain-run.ts +136 -18
- package/src/op/activities/index.ts +8 -2
- package/src/skills/chant-fountain-locked-sandboxes.md +1 -1
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Box — a persistent machine that serves an app repo and its tools (#2705).
|
|
3
|
+
*
|
|
4
|
+
* A box is an `Environment` whose `setup_script` provisions the machine (for
|
|
5
|
+
* an arugula studio box, the studio's `box/provision-template.sh`), an
|
|
6
|
+
* `Agent` on a persistent sandbox provisioned from it, and optionally a
|
|
7
|
+
* `Vault` for the secrets every box of this kind shares. `repo` is optional:
|
|
8
|
+
* when given, the Environment's `repositories` clones it in before setup
|
|
9
|
+
* runs; when omitted (#2759 — a blank-slate app, such as the one hud's
|
|
10
|
+
* `init` makes, has no git remote yet), the Environment holds no
|
|
11
|
+
* `repositories` at all and `setup_script` still runs, against whatever
|
|
12
|
+
* working tree is already on the sandbox (a self-hosted `runner`'s own).
|
|
13
|
+
*
|
|
14
|
+
* ```ts
|
|
15
|
+
* export const { environment, agent, vault } = Box({
|
|
16
|
+
* name: "studio-box",
|
|
17
|
+
* repo: { url: "https://github.com/arugula-salad/studio", ref: "main" },
|
|
18
|
+
* setupScript: readFileSync("box/provision-template.sh", "utf8"),
|
|
19
|
+
* permissionPolicy: { default: "auto_allow" },
|
|
20
|
+
* allowedHosts: ["registry.npmjs.org", "github.com"],
|
|
21
|
+
* vault: { secrets: [{ key: "STUDIO_SECRET", value: "${STUDIO_SECRET}" }] },
|
|
22
|
+
* });
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* Beside `Steward`: a steward is an environment's one writer, an `acp` agent
|
|
26
|
+
* whose every turn is a chant command line, bound to a teammate with a
|
|
27
|
+
* standing thread and schedules. A box is where people and their agents work
|
|
28
|
+
* on an app: a conversational runtime (`claude` by default), the app repo
|
|
29
|
+
* cloned in (when there is one), the provisioning script run as setup, and
|
|
30
|
+
* one served port. It declares no teammate and no schedule, and nothing here
|
|
31
|
+
* starts a conversation. Starting and reaping a box stays a runtime act, as
|
|
32
|
+
* it is in hud's box runtime.
|
|
33
|
+
*
|
|
34
|
+
* The defaults are the closed ones, as `ConciergeStack`'s are, and loosening
|
|
35
|
+
* one is a visible parameter: networking is `limited` with an empty allowlist
|
|
36
|
+
* until `allowedHosts` names hosts or `unrestrictedNetworking: true` opens it
|
|
37
|
+
* (which FTN011 then warns about); `allowed_vault_ids` holds the box's own
|
|
38
|
+
* vault, or nothing, until `allowedVaults` widens it; `sandbox_provider` is
|
|
39
|
+
* unset (the instance default) until `sandboxProvider` names one of the
|
|
40
|
+
* pinned spec's `sprites`, `e2b`, `daytona` or `runner`. `permission_policy`
|
|
41
|
+
* has no default at all, because fountain's unset policy is `auto_allow` and
|
|
42
|
+
* that should be a choice someone wrote down.
|
|
43
|
+
*
|
|
44
|
+
* ## Credentials
|
|
45
|
+
*
|
|
46
|
+
* A box holds no credential (#2726). What it reaches outside itself
|
|
47
|
+
* (inference, Fountain's own API) goes through a broker, such as a studio's
|
|
48
|
+
* lobby, that holds the key and enforces a scope. Those capabilities are
|
|
49
|
+
* declared in the workspace declaration's `box` block on the member that
|
|
50
|
+
* holds this composite, not here: `chant workspace check` and the read
|
|
51
|
+
* contract read the declaration without running this code. Here, a vault
|
|
52
|
+
* secret's value and an `envVars` value are references (`${VAR}`, `op://`,
|
|
53
|
+
* `bws://`, `infisical://`) that resolve at apply, never a literal, and never
|
|
54
|
+
* `process.env.X`, which writes the secret into the built manifest. `chant
|
|
55
|
+
* workspace check` fails a box member whose files hold one
|
|
56
|
+
* (`box-credential-declared`).
|
|
57
|
+
*
|
|
58
|
+
* ## The port
|
|
59
|
+
*
|
|
60
|
+
* fountain's `Environment` and `Agent` have no field for a served port (the
|
|
61
|
+
* pinned spec, v0.21.0). The port is recorded in the metadata of both, under
|
|
62
|
+
* `box-port`, and returned as `port`, so whatever builds the box's URL reads
|
|
63
|
+
* it from the declaration or from fountain's own record without a guess.
|
|
64
|
+
*
|
|
65
|
+
* ## Allowed vaults in the read contract
|
|
66
|
+
*
|
|
67
|
+
* `sandbox_provider` is a plain `Agent` field, so `chant workspace graph
|
|
68
|
+
* --json` (and `chant graph`) already carry it on the agent's node like any
|
|
69
|
+
* other declared attribute. `allowed_vault_ids` is not as plain to read:
|
|
70
|
+
* fountain's own convention (and this composite's, per `allowedVaults`
|
|
71
|
+
* above) is that an *unset* list means "any vault the tenant owns", so a
|
|
72
|
+
* graph reader can't tell "no vaults declared" from "any vault" just by the
|
|
73
|
+
* key's absence. #2759 — a runtime that creates a vault per box (hud's box
|
|
74
|
+
* runtime) needs to check that the declaration permits it — so the resolved
|
|
75
|
+
* list, or the literal string `"any"`, is also recorded in the agent's
|
|
76
|
+
* metadata under `box-allowed-vault-ids` (`BOX_ALLOWED_VAULTS_METADATA_KEY`).
|
|
77
|
+
* It is read-only bookkeeping, alongside `box-port`: the manifest's real
|
|
78
|
+
* `allowed_vault_ids` field is what fountain applies.
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
import { Agent, Environment, Vault } from "../generated/index";
|
|
82
|
+
|
|
83
|
+
/** The metadata key a box's served port is recorded under, on its Environment and Agent. */
|
|
84
|
+
export const BOX_PORT_METADATA_KEY = "box-port";
|
|
85
|
+
|
|
86
|
+
/** The metadata key a box agent's resolved allowed vaults (or `"any"`) are recorded under, for the read contract. */
|
|
87
|
+
export const BOX_ALLOWED_VAULTS_METADATA_KEY = "box-allowed-vault-ids";
|
|
88
|
+
|
|
89
|
+
/** `Agent.sandbox_provider`'s values at the pinned spec. */
|
|
90
|
+
export const BOX_SANDBOX_PROVIDERS = ["sprites", "e2b", "daytona", "runner"] as const;
|
|
91
|
+
|
|
92
|
+
/** A sandbox backend, per the pinned spec's `sandbox_provider` enum. */
|
|
93
|
+
export type BoxSandboxProvider = (typeof BOX_SANDBOX_PROVIDERS)[number];
|
|
94
|
+
|
|
95
|
+
/** The port a box serves when none is given: the door's. */
|
|
96
|
+
export const BOX_DEFAULT_PORT = 8080;
|
|
97
|
+
|
|
98
|
+
/** Where the app repo is cloned when no `mountPath` is given. */
|
|
99
|
+
export const BOX_DEFAULT_MOUNT_PATH = "/workspace/app";
|
|
100
|
+
|
|
101
|
+
/** A permission verdict, or a number of seconds under the `ask_timeout` key. */
|
|
102
|
+
export type BoxPermissionPolicy = Record<string, "ask" | "auto_allow" | "auto_deny" | number>;
|
|
103
|
+
|
|
104
|
+
/** The app repo the box serves. Shape of fountain's `Repository`. */
|
|
105
|
+
export interface BoxRepositoryOpts {
|
|
106
|
+
/** https clone url. */
|
|
107
|
+
url: string;
|
|
108
|
+
/** Absolute path the repo is cloned to. Default `/workspace/app`. */
|
|
109
|
+
mountPath?: string;
|
|
110
|
+
/** Branch or tag. The default branch when omitted. */
|
|
111
|
+
ref?: string;
|
|
112
|
+
/** Name of the secret holding a clone token. Required for a private repo. */
|
|
113
|
+
secretKey?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The secrets every box of this kind shares, held in a Vault of the box's own. */
|
|
117
|
+
export interface BoxVaultOpts {
|
|
118
|
+
/** Vault name. Default `<name>-secrets`. */
|
|
119
|
+
name?: string;
|
|
120
|
+
description?: string;
|
|
121
|
+
/** Written at apply. A reference such as `${VAR}` or `op://...` that resolves at apply, never a literal (FTN001, and `box-credential-declared` in `chant workspace check`). */
|
|
122
|
+
secrets?: { key: string; value: string }[];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface BoxOpts {
|
|
126
|
+
/** The Agent's name. The Environment is `<name>-env`. */
|
|
127
|
+
name: string;
|
|
128
|
+
/** The app repo the box serves, cloned into the sandbox before setup runs. Omit for a blank-slate app with no repo yet: the Environment declares no `repositories`, and `setupScript` runs against whatever working tree the sandbox already has. */
|
|
129
|
+
repo?: BoxRepositoryOpts;
|
|
130
|
+
/** The provisioning script's text, run as the Environment's `setup_script`. */
|
|
131
|
+
setupScript: string;
|
|
132
|
+
/** Setup exec timeout, 1 to 900 seconds (FTN024). fountain's default is 120. */
|
|
133
|
+
setupTimeoutSeconds?: number;
|
|
134
|
+
/** Agent runtime. Default `claude`. */
|
|
135
|
+
runtime?: "claude" | "codex" | "gemini" | "opencode";
|
|
136
|
+
/** Canonical provider/model_id. Omitted, fountain's default for the runtime. */
|
|
137
|
+
model?: string;
|
|
138
|
+
/** Sandbox backend override. Omitted, the instance default (`SANDBOX_PROVIDER`). A plain `Agent.sandbox_provider` field, so it is already visible on the graph node without extra bookkeeping (unlike `allowedVaults`; see "Allowed vaults in the read contract" above). */
|
|
139
|
+
sandboxProvider?: BoxSandboxProvider;
|
|
140
|
+
/** Per-tool permission policy. Required: fountain's unset policy is `auto_allow`. */
|
|
141
|
+
permissionPolicy: BoxPermissionPolicy;
|
|
142
|
+
/** Environment packages, passed through. */
|
|
143
|
+
packages?: Record<string, unknown>;
|
|
144
|
+
/** Environment variables, passed through. Not for secrets: use `vault`. */
|
|
145
|
+
envVars?: Record<string, string>;
|
|
146
|
+
/** Egress allowlist under `limited` networking. Default [], deny-all. */
|
|
147
|
+
allowedHosts?: string[];
|
|
148
|
+
/** Open the sandbox's network. FTN011 warns on it. Refused with `allowedHosts`. */
|
|
149
|
+
unrestrictedNetworking?: boolean;
|
|
150
|
+
/** Declare a Vault for the box's shared secrets. */
|
|
151
|
+
vault?: BoxVaultOpts;
|
|
152
|
+
/**
|
|
153
|
+
* Vaults a conversation may attach. Default: the box's own vault, or none.
|
|
154
|
+
* `"any"` leaves the list unset, which fountain reads as any vault the
|
|
155
|
+
* tenant owns: what a runtime that makes a vault per box (hud's) needs.
|
|
156
|
+
*/
|
|
157
|
+
allowedVaults?: "any" | Array<InstanceType<typeof Vault> | string>;
|
|
158
|
+
/** The port the box serves. Default 8080, the door's. Recorded as `box-port` metadata. */
|
|
159
|
+
port?: number;
|
|
160
|
+
system?: string;
|
|
161
|
+
skills?: Array<Record<string, unknown>>;
|
|
162
|
+
mcpServers?: Record<string, unknown>;
|
|
163
|
+
/** Extra metadata, merged over the ownership marker on every resource the box declares. */
|
|
164
|
+
metadata?: Record<string, unknown>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface BoxResources {
|
|
168
|
+
environment: InstanceType<typeof Environment>;
|
|
169
|
+
agent: InstanceType<typeof Agent>;
|
|
170
|
+
vault?: InstanceType<typeof Vault>;
|
|
171
|
+
/** The served port, as recorded under `box-port`. */
|
|
172
|
+
port: number;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function Box(opts: BoxOpts): BoxResources {
|
|
176
|
+
const port = opts.port ?? BOX_DEFAULT_PORT;
|
|
177
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
178
|
+
throw new Error(`Box "${opts.name}": port ${port} is not a TCP port (1 to 65535)`);
|
|
179
|
+
}
|
|
180
|
+
if (opts.unrestrictedNetworking && opts.allowedHosts !== undefined) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
`Box "${opts.name}": allowedHosts and unrestrictedNetworking together — ` +
|
|
183
|
+
`an allowlist means nothing on an open network. Pass one.`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
if (!opts.setupScript.trim()) {
|
|
187
|
+
throw new Error(`Box "${opts.name}": setupScript is empty — a box is provisioned by its setup script`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const owned = { "managed-by": "chant", ...(opts.metadata ?? {}) };
|
|
191
|
+
const metadata = { ...owned, [BOX_PORT_METADATA_KEY]: port };
|
|
192
|
+
|
|
193
|
+
const repository = opts.repo
|
|
194
|
+
? {
|
|
195
|
+
url: opts.repo.url,
|
|
196
|
+
mount_path: opts.repo.mountPath ?? BOX_DEFAULT_MOUNT_PATH,
|
|
197
|
+
...(opts.repo.ref !== undefined ? { ref: opts.repo.ref } : {}),
|
|
198
|
+
...(opts.repo.secretKey !== undefined ? { secret_key: opts.repo.secretKey } : {}),
|
|
199
|
+
}
|
|
200
|
+
: undefined;
|
|
201
|
+
|
|
202
|
+
const environment = new Environment({
|
|
203
|
+
name: `${opts.name}-env`,
|
|
204
|
+
...(repository ? { repositories: [repository] } : {}),
|
|
205
|
+
setup_script: opts.setupScript,
|
|
206
|
+
...(opts.setupTimeoutSeconds !== undefined ? { setup_timeout_seconds: opts.setupTimeoutSeconds } : {}),
|
|
207
|
+
...(opts.packages ? { packages: opts.packages } : {}),
|
|
208
|
+
...(opts.envVars ? { env_vars: opts.envVars } : {}),
|
|
209
|
+
...(opts.unrestrictedNetworking
|
|
210
|
+
? { networking_type: "unrestricted" as const }
|
|
211
|
+
: { networking_type: "limited" as const, networking_config: { allowed_hosts: opts.allowedHosts ?? [] } }),
|
|
212
|
+
metadata,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const vault = opts.vault
|
|
216
|
+
? new Vault({
|
|
217
|
+
name: opts.vault.name ?? `${opts.name}-secrets`,
|
|
218
|
+
...(opts.vault.description !== undefined ? { description: opts.vault.description } : {}),
|
|
219
|
+
...(opts.vault.secrets ? { secrets: opts.vault.secrets } : {}),
|
|
220
|
+
metadata: owned,
|
|
221
|
+
})
|
|
222
|
+
: undefined;
|
|
223
|
+
|
|
224
|
+
// A Vault declaration rather than its `.id`, as in Steward: the manifest's
|
|
225
|
+
// reference form is the resource's name (FTN021), and an AttrRef would
|
|
226
|
+
// serialize to the chant export name instead.
|
|
227
|
+
const allowedVaults =
|
|
228
|
+
opts.allowedVaults === "any" ? undefined : (opts.allowedVaults ?? (vault ? [vault] : []));
|
|
229
|
+
|
|
230
|
+
// The resolved allowed-vaults value, for the read contract (see "Allowed
|
|
231
|
+
// vaults in the read contract" above) — "any" when the list is left unset,
|
|
232
|
+
// otherwise each entry's own declared name.
|
|
233
|
+
const allowedVaultsForReadContract: "any" | string[] =
|
|
234
|
+
opts.allowedVaults === "any"
|
|
235
|
+
? "any"
|
|
236
|
+
: allowedVaults!.map((v) => (typeof v === "string" ? v : (v as unknown as { props: { name: string } }).props.name));
|
|
237
|
+
const agentMetadata = { ...metadata, [BOX_ALLOWED_VAULTS_METADATA_KEY]: allowedVaultsForReadContract };
|
|
238
|
+
|
|
239
|
+
const agent = new Agent({
|
|
240
|
+
name: opts.name,
|
|
241
|
+
runtime: opts.runtime ?? "claude",
|
|
242
|
+
...(opts.model !== undefined ? { model: opts.model } : {}),
|
|
243
|
+
sandbox_mode: "persistent",
|
|
244
|
+
...(opts.sandboxProvider !== undefined ? { sandbox_provider: opts.sandboxProvider } : {}),
|
|
245
|
+
environment,
|
|
246
|
+
permission_policy: opts.permissionPolicy,
|
|
247
|
+
...(allowedVaults !== undefined ? { allowed_vault_ids: allowedVaults } : {}),
|
|
248
|
+
...(opts.skills ? { skills: opts.skills } : {}),
|
|
249
|
+
...(opts.mcpServers ? { mcp_servers: opts.mcpServers } : {}),
|
|
250
|
+
...(opts.system !== undefined ? { system: opts.system } : {}),
|
|
251
|
+
metadata: agentMetadata,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
return { environment, agent, ...(vault ? { vault } : {}), port };
|
|
255
|
+
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
-
import { WatchOp, type OpConfig } from "@intentius/chant/op";
|
|
2
|
+
import { WatchOp, isStewardDeclaration, stewardFormFor, type OpConfig } from "@intentius/chant/op";
|
|
3
3
|
import { ConciergeStack } from "./concierge-stack";
|
|
4
4
|
import { Steward, stewardForOp, __resetStewardsForTests } from "./steward";
|
|
5
|
+
import { Box, BOX_ALLOWED_VAULTS_METADATA_KEY, BOX_PORT_METADATA_KEY } from "./box";
|
|
6
|
+
import { postSynthChecks } from "../lint/post-synth/index";
|
|
7
|
+
import spec from "../spec/fountain-openapi.snapshot.json";
|
|
5
8
|
import { Environment, Vault } from "../generated/index";
|
|
6
9
|
import { fountainSerializer } from "../serializer";
|
|
10
|
+
import { buildGraphIr, resolveAttrRefs } from "@intentius/chant";
|
|
7
11
|
import type { Declarable } from "@intentius/chant";
|
|
8
12
|
import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
|
|
9
13
|
import { runtimeModelValidCheck } from "../lint/post-synth/ftn016-runtime-model-valid";
|
|
@@ -234,3 +238,298 @@ describe("Steward", () => {
|
|
|
234
238
|
expect(yaml).toMatch(/allowed_vault_ids:\n\s+- prod-creds/);
|
|
235
239
|
});
|
|
236
240
|
});
|
|
241
|
+
|
|
242
|
+
// ── Steward: the declaration core reads, and its form (#2731) ─────────────
|
|
243
|
+
|
|
244
|
+
describe("Steward declaration and form", () => {
|
|
245
|
+
beforeEach(() => {
|
|
246
|
+
__resetStewardsForTests();
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
const toolchain = () => new Environment({ name: "toolchain" });
|
|
250
|
+
|
|
251
|
+
it("returns the declaration core reads: name, every op, and fountain as the default form", () => {
|
|
252
|
+
const { declaration } = Steward({
|
|
253
|
+
name: "box-steward",
|
|
254
|
+
environment: toolchain(),
|
|
255
|
+
ops: [op("box-converge", { cron: "* * * * *" }), op("box-release")],
|
|
256
|
+
});
|
|
257
|
+
expect(isStewardDeclaration(declaration)).toBe(true);
|
|
258
|
+
expect(declaration.name).toBe("box-steward");
|
|
259
|
+
expect(declaration.ops.map((o) => o.name)).toEqual(["box-converge", "box-release"]);
|
|
260
|
+
expect(declaration.form).toEqual({ default: "fountain", environments: {} });
|
|
261
|
+
expect(stewardFormFor(declaration, "prod")).toBe("fountain");
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("names the environments where the same steward runs locally", () => {
|
|
265
|
+
const { declaration, schedules } = Steward({
|
|
266
|
+
name: "box-steward",
|
|
267
|
+
environment: toolchain(),
|
|
268
|
+
ops: [op("box-converge", { cron: "* * * * *" })],
|
|
269
|
+
form: { default: "fountain", environments: { minimal: "local", spritzer: "local" } },
|
|
270
|
+
});
|
|
271
|
+
expect(stewardFormFor(declaration, "fountain-k3d")).toBe("fountain");
|
|
272
|
+
expect(stewardFormFor(declaration, "minimal")).toBe("local");
|
|
273
|
+
expect(stewardFormFor(declaration, "spritzer")).toBe("local");
|
|
274
|
+
// The Fountain resources are declared whatever the form: which environment
|
|
275
|
+
// they run in is the deploy's business, not the declaration's.
|
|
276
|
+
expect(schedules).toHaveLength(1);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("refuses a form that is local everywhere, which needs none of the Fountain resources", () => {
|
|
280
|
+
expect(() =>
|
|
281
|
+
Steward({ name: "box-steward", environment: toolchain(), ops: [], form: "local" }),
|
|
282
|
+
).toThrow(/local" in every environment/);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it("refuses an op listed twice, which would run twice per fire", () => {
|
|
286
|
+
const converge = op("box-converge", { cron: "* * * * *" });
|
|
287
|
+
expect(() =>
|
|
288
|
+
Steward({ name: "box-steward", environment: toolchain(), ops: [converge, converge] }),
|
|
289
|
+
).toThrow(/listed twice/);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("records the brokered capabilities, and refuses them beside a vault (#2726)", () => {
|
|
293
|
+
const { declaration } = Steward({ name: "box-steward", environment: toolchain(), ops: [], capabilities: ["fountain"] });
|
|
294
|
+
expect(declaration.capabilities).toEqual(["fountain"]);
|
|
295
|
+
expect(declaration.vault).toBeNull();
|
|
296
|
+
__resetStewardsForTests();
|
|
297
|
+
expect(
|
|
298
|
+
Steward({ name: "prod-steward", environment: toolchain(), vault: new Vault({ name: "prod-creds" }), ops: [] }).declaration.vault,
|
|
299
|
+
).toBe("prod-creds");
|
|
300
|
+
__resetStewardsForTests();
|
|
301
|
+
expect(() =>
|
|
302
|
+
Steward({ name: "x", environment: toolchain(), vault: new Vault({ name: "v" }), ops: [], capabilities: ["fountain"] }),
|
|
303
|
+
).toThrow(/holds no credential/);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("refuses an unknown form", () => {
|
|
307
|
+
expect(() =>
|
|
308
|
+
Steward({ name: "box-steward", environment: toolchain(), ops: [], form: "cloud" as never }),
|
|
309
|
+
).toThrow(/"local" or "fountain"/);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// ── Box ───────────────────────────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
describe("Box", () => {
|
|
316
|
+
const base = {
|
|
317
|
+
name: "studio-box",
|
|
318
|
+
repo: { url: "https://github.com/arugula-salad/studio" },
|
|
319
|
+
setupScript: "#!/bin/bash\nexec ~/box/provision-template.sh\n",
|
|
320
|
+
permissionPolicy: { default: "auto_allow" as const },
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
it("declares a closed Environment and a persistent claude Agent, with the port in metadata", () => {
|
|
324
|
+
const { environment, agent, vault, port } = Box(base);
|
|
325
|
+
|
|
326
|
+
const env = props(environment);
|
|
327
|
+
expect(env.name).toBe("studio-box-env");
|
|
328
|
+
expect(env.repositories).toEqual([{ url: "https://github.com/arugula-salad/studio", mount_path: "/workspace/app" }]);
|
|
329
|
+
expect(env.setup_script).toBe(base.setupScript);
|
|
330
|
+
expect(env.networking_type).toBe("limited");
|
|
331
|
+
expect(env.networking_config).toEqual({ allowed_hosts: [] });
|
|
332
|
+
expect(env.metadata).toEqual({ "managed-by": "chant", [BOX_PORT_METADATA_KEY]: 8080 });
|
|
333
|
+
|
|
334
|
+
const a = props(agent);
|
|
335
|
+
expect(a.name).toBe("studio-box");
|
|
336
|
+
expect(a.runtime).toBe("claude");
|
|
337
|
+
expect(a.model).toBeUndefined();
|
|
338
|
+
expect(a.sandbox_mode).toBe("persistent");
|
|
339
|
+
expect(a.environment).toBe(environment);
|
|
340
|
+
expect(a.permission_policy).toEqual({ default: "auto_allow" });
|
|
341
|
+
expect(a.allowed_vault_ids).toEqual([]);
|
|
342
|
+
expect(a.sandbox_provider).toBeUndefined();
|
|
343
|
+
expect(a.metadata).toEqual({
|
|
344
|
+
"managed-by": "chant",
|
|
345
|
+
"box-port": 8080,
|
|
346
|
+
[BOX_ALLOWED_VAULTS_METADATA_KEY]: [],
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
expect(vault).toBeUndefined();
|
|
350
|
+
expect(port).toBe(8080);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it("declares the shared vault and scopes the agent to it, named in the read contract", () => {
|
|
354
|
+
const { agent, vault } = Box({
|
|
355
|
+
...base,
|
|
356
|
+
vault: { secrets: [{ key: "STUDIO_SECRET", value: "${STUDIO_SECRET}" }] },
|
|
357
|
+
});
|
|
358
|
+
expect(props(vault).name).toBe("studio-box-secrets");
|
|
359
|
+
expect(props(vault).secrets).toEqual([{ key: "STUDIO_SECRET", value: "${STUDIO_SECRET}" }]);
|
|
360
|
+
expect(props(vault).metadata).toEqual({ "managed-by": "chant" });
|
|
361
|
+
expect(props(agent).allowed_vault_ids).toEqual([vault]);
|
|
362
|
+
expect((props(agent).metadata as Record<string, unknown>)[BOX_ALLOWED_VAULTS_METADATA_KEY]).toEqual([
|
|
363
|
+
"studio-box-secrets",
|
|
364
|
+
]);
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
it("resolves an explicit allowedVaults list to names in the read contract", () => {
|
|
368
|
+
const { agent } = Box({ ...base, allowedVaults: ["other-secrets", "studio-box-secrets"] });
|
|
369
|
+
expect(props(agent).allowed_vault_ids).toEqual(["other-secrets", "studio-box-secrets"]);
|
|
370
|
+
expect((props(agent).metadata as Record<string, unknown>)[BOX_ALLOWED_VAULTS_METADATA_KEY]).toEqual([
|
|
371
|
+
"other-secrets",
|
|
372
|
+
"studio-box-secrets",
|
|
373
|
+
]);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("loosening is a visible parameter", () => {
|
|
377
|
+
const open = Box({
|
|
378
|
+
...base,
|
|
379
|
+
runtime: "codex",
|
|
380
|
+
model: "openai/gpt-5",
|
|
381
|
+
port: 3000,
|
|
382
|
+
unrestrictedNetworking: true,
|
|
383
|
+
allowedVaults: "any",
|
|
384
|
+
sandboxProvider: "e2b",
|
|
385
|
+
repo: { url: "https://github.com/o/app", mountPath: "/srv/app", ref: "v1", secretKey: "GH_TOKEN" },
|
|
386
|
+
envVars: { NODE_ENV: "production" },
|
|
387
|
+
packages: { apt: ["podman"] },
|
|
388
|
+
setupTimeoutSeconds: 900,
|
|
389
|
+
metadata: { team: "studio" },
|
|
390
|
+
});
|
|
391
|
+
const env = props(open.environment);
|
|
392
|
+
expect(env.networking_type).toBe("unrestricted");
|
|
393
|
+
expect(env.networking_config).toBeUndefined();
|
|
394
|
+
expect(env.repositories).toEqual([{ url: "https://github.com/o/app", mount_path: "/srv/app", ref: "v1", secret_key: "GH_TOKEN" }]);
|
|
395
|
+
expect(env.env_vars).toEqual({ NODE_ENV: "production" });
|
|
396
|
+
expect(env.packages).toEqual({ apt: ["podman"] });
|
|
397
|
+
expect(env.setup_timeout_seconds).toBe(900);
|
|
398
|
+
expect(env.metadata).toEqual({ "managed-by": "chant", team: "studio", "box-port": 3000 });
|
|
399
|
+
const a = props(open.agent);
|
|
400
|
+
expect(a.runtime).toBe("codex");
|
|
401
|
+
expect(a.model).toBe("openai/gpt-5");
|
|
402
|
+
expect(a.sandbox_provider).toBe("e2b");
|
|
403
|
+
// "any" leaves the manifest's own list unset: fountain reads that as any
|
|
404
|
+
// vault the tenant owns. The read contract still names it explicitly.
|
|
405
|
+
expect("allowed_vault_ids" in a).toBe(false);
|
|
406
|
+
expect((a.metadata as Record<string, unknown>)[BOX_ALLOWED_VAULTS_METADATA_KEY]).toBe("any");
|
|
407
|
+
expect(open.port).toBe(3000);
|
|
408
|
+
|
|
409
|
+
const listed = Box({ ...base, allowedHosts: ["registry.npmjs.org"] });
|
|
410
|
+
expect(props(listed.environment).networking_config).toEqual({ allowed_hosts: ["registry.npmjs.org"] });
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("builds with no repo — no clone, the Environment declares no repositories", () => {
|
|
414
|
+
const { environment, agent, port } = Box({
|
|
415
|
+
name: "blank-slate",
|
|
416
|
+
setupScript: "#!/bin/bash\nnpm install\n",
|
|
417
|
+
permissionPolicy: { default: "auto_allow" as const },
|
|
418
|
+
sandboxProvider: "runner",
|
|
419
|
+
});
|
|
420
|
+
const env = props(environment);
|
|
421
|
+
expect("repositories" in env).toBe(false);
|
|
422
|
+
expect(env.setup_script).toBe("#!/bin/bash\nnpm install\n");
|
|
423
|
+
const a = props(agent);
|
|
424
|
+
expect(a.sandbox_provider).toBe("runner");
|
|
425
|
+
expect(port).toBe(8080);
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it("refuses what it cannot mean", () => {
|
|
429
|
+
expect(() => Box({ ...base, port: 0 })).toThrow(/not a TCP port/);
|
|
430
|
+
expect(() => Box({ ...base, port: 8080.5 })).toThrow(/not a TCP port/);
|
|
431
|
+
expect(() => Box({ ...base, unrestrictedNetworking: true, allowedHosts: ["github.com"] })).toThrow(
|
|
432
|
+
/allowedHosts and unrestrictedNetworking/,
|
|
433
|
+
);
|
|
434
|
+
expect(() => Box({ ...base, setupScript: " " })).toThrow(/setupScript is empty/);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("serializes to a manifest whose specs the pinned API accepts, clean under every post-synth check", () => {
|
|
438
|
+
const { environment, agent, vault } = Box({
|
|
439
|
+
...base,
|
|
440
|
+
allowedHosts: ["registry.npmjs.org", "github.com"],
|
|
441
|
+
vault: { secrets: [{ key: "STUDIO_SECRET", value: "${STUDIO_SECRET}" }] },
|
|
442
|
+
});
|
|
443
|
+
const entities = new Map<string, Declarable>([
|
|
444
|
+
["boxAgent", agent as unknown as Declarable],
|
|
445
|
+
["boxVault", vault as unknown as Declarable],
|
|
446
|
+
["boxEnv", environment as unknown as Declarable],
|
|
447
|
+
]);
|
|
448
|
+
|
|
449
|
+
const yaml = fountainSerializer.serialize(entities) as string;
|
|
450
|
+
expect([...yaml.matchAll(/^kind: (\w+)$/gm)].map((m) => m[1])).toEqual(["Environment", "Vault", "Agent"]);
|
|
451
|
+
expect(yaml).toContain("environment: studio-box-env");
|
|
452
|
+
expect(yaml).toMatch(/allowed_vault_ids:\n\s+- studio-box-secrets/);
|
|
453
|
+
expect(yaml).toContain("box-port: 8080");
|
|
454
|
+
|
|
455
|
+
// Every spec key is a field of the pinned create request, or one of the
|
|
456
|
+
// two manifest-level forms fountainApply and `fountain apply -f` resolve:
|
|
457
|
+
// a name reference to the agent's environment, and inline secrets.
|
|
458
|
+
const schemas = (spec as unknown as { components: { schemas: Record<string, { properties: Record<string, unknown> }> } })
|
|
459
|
+
.components.schemas;
|
|
460
|
+
const manifestOnly = new Set(["environment", "secrets"]);
|
|
461
|
+
for (const [kind, entity] of [
|
|
462
|
+
["EnvironmentRequest", environment],
|
|
463
|
+
["VaultRequest", vault],
|
|
464
|
+
["AgentRequest", agent],
|
|
465
|
+
] as const) {
|
|
466
|
+
const fields = Object.keys(schemas[kind].properties);
|
|
467
|
+
for (const key of Object.keys(props(entity))) {
|
|
468
|
+
if (key === "name" || manifestOnly.has(key)) continue;
|
|
469
|
+
expect(fields, `${kind} has no field ${key}`).toContain(key);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const ctx = { outputs: new Map(), entities, buildResult: { warnings: [], errors: [] } } as unknown as PostSynthContext;
|
|
474
|
+
const diagnostics = postSynthChecks.flatMap((c) => c.check(ctx));
|
|
475
|
+
expect(diagnostics).toEqual([]);
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it("a no-repo, sandboxProvider: runner box builds a manifest whose every field exists in the pinned spec, clean under every post-synth check", () => {
|
|
479
|
+
const { environment, agent } = Box({
|
|
480
|
+
name: "blank-slate",
|
|
481
|
+
setupScript: "#!/bin/bash\nnpm install\n",
|
|
482
|
+
permissionPolicy: { default: "auto_allow" as const },
|
|
483
|
+
sandboxProvider: "runner",
|
|
484
|
+
});
|
|
485
|
+
const entities = new Map<string, Declarable>([
|
|
486
|
+
["boxAgent", agent as unknown as Declarable],
|
|
487
|
+
["boxEnv", environment as unknown as Declarable],
|
|
488
|
+
]);
|
|
489
|
+
|
|
490
|
+
const yaml = fountainSerializer.serialize(entities) as string;
|
|
491
|
+
expect([...yaml.matchAll(/^kind: (\w+)$/gm)].map((m) => m[1])).toEqual(["Environment", "Agent"]);
|
|
492
|
+
expect(yaml).not.toContain("repositories:");
|
|
493
|
+
expect(yaml).toContain("sandbox_provider: runner");
|
|
494
|
+
|
|
495
|
+
const schemas = (spec as unknown as { components: { schemas: Record<string, { properties: Record<string, unknown> }> } })
|
|
496
|
+
.components.schemas;
|
|
497
|
+
const manifestOnly = new Set(["environment", "secrets"]);
|
|
498
|
+
for (const [kind, entity] of [
|
|
499
|
+
["EnvironmentRequest", environment],
|
|
500
|
+
["AgentRequest", agent],
|
|
501
|
+
] as const) {
|
|
502
|
+
const fields = Object.keys(schemas[kind].properties);
|
|
503
|
+
for (const key of Object.keys(props(entity))) {
|
|
504
|
+
if (key === "name" || manifestOnly.has(key)) continue;
|
|
505
|
+
expect(fields, `${kind} has no field ${key}`).toContain(key);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
const ctx = { outputs: new Map(), entities, buildResult: { warnings: [], errors: [] } } as unknown as PostSynthContext;
|
|
510
|
+
const diagnostics = postSynthChecks.flatMap((c) => c.check(ctx));
|
|
511
|
+
expect(diagnostics).toEqual([]);
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("shows the agent's sandbox provider and allowed vaults on the graph node (#2759)", () => {
|
|
515
|
+
const { agent, environment } = Box({
|
|
516
|
+
...base,
|
|
517
|
+
sandboxProvider: "runner",
|
|
518
|
+
allowedVaults: "any",
|
|
519
|
+
});
|
|
520
|
+
const entities = new Map<string, Declarable>([
|
|
521
|
+
["boxAgent", agent as unknown as Declarable],
|
|
522
|
+
["boxEnv", environment as unknown as Declarable],
|
|
523
|
+
]);
|
|
524
|
+
resolveAttrRefs(entities);
|
|
525
|
+
|
|
526
|
+
const ir = buildGraphIr(entities);
|
|
527
|
+
const agentNode = ir.nodes.find((n) => n.id === "boxAgent")!;
|
|
528
|
+
expect(agentNode.attrs.sandbox_provider).toBe("runner");
|
|
529
|
+
// "any" leaves the manifest's allowed_vault_ids unset, so it's the
|
|
530
|
+
// metadata marker a graph reader checks, not an absent key it has to
|
|
531
|
+
// interpret.
|
|
532
|
+
expect(agentNode.attrs.allowed_vault_ids).toBeUndefined();
|
|
533
|
+
expect((agentNode.attrs.metadata as Record<string, unknown>)[BOX_ALLOWED_VAULTS_METADATA_KEY]).toBe("any");
|
|
534
|
+
});
|
|
535
|
+
});
|