@eir-labs/coltrane 0.9.4 → 0.11.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/agents/bandleader.json +54 -0
- package/agents/bill.json +4 -2
- package/agents/change-verifier.json +2 -1
- package/agents/code-implementer.json +7 -4
- package/agents/john.json +5 -3
- package/agents/lineage-scout-external.json +19 -6
- package/agents/lineage-scribe.json +20 -9
- package/agents/lineage-weaver.json +26 -11
- package/agents/miles.json +2 -1
- package/agents/players/bandleader.md +70 -0
- package/agents/pr-publisher.json +4 -2
- package/agents/red-spec-drafter.json +12 -6
- package/agents/room-prober.json +29 -0
- package/agents/source-walker.json +1 -1
- package/charts/room-probe-chart-v1.json +14 -0
- package/charts/worker-contract-docker-v2.json +135 -0
- package/charts/worker-contract-v3.json +206 -0
- package/dist/src/boundary_check.d.ts +18 -0
- package/dist/src/boundary_check.js +70 -0
- package/dist/src/boundary_check.js.map +1 -0
- package/dist/src/boundary_scan.d.ts +62 -0
- package/dist/src/boundary_scan.js +93 -0
- package/dist/src/boundary_scan.js.map +1 -0
- package/dist/src/chart.js +34 -1
- package/dist/src/chart.js.map +1 -1
- package/dist/src/claude_invoker.d.ts +2 -1
- package/dist/src/claude_invoker.js +150 -17
- package/dist/src/claude_invoker.js.map +1 -1
- package/dist/src/cli.d.ts +1 -1
- package/dist/src/cli.js +61 -26
- package/dist/src/cli.js.map +1 -1
- package/dist/src/drain_preflight.d.ts +33 -0
- package/dist/src/drain_preflight.js +97 -0
- package/dist/src/drain_preflight.js.map +1 -0
- package/dist/src/genome_schema.d.ts +390 -9
- package/dist/src/genome_schema.js +163 -8
- package/dist/src/genome_schema.js.map +1 -1
- package/dist/src/genome_store.js +9 -0
- package/dist/src/genome_store.js.map +1 -1
- package/dist/src/genome_writer.d.ts +1 -1
- package/dist/src/genome_writer.js +10 -2
- package/dist/src/genome_writer.js.map +1 -1
- package/dist/src/gig_conformance.d.ts +66 -0
- package/dist/src/gig_conformance.js +153 -0
- package/dist/src/gig_conformance.js.map +1 -0
- package/dist/src/gig_tracker.js +8 -0
- package/dist/src/gig_tracker.js.map +1 -1
- package/dist/src/grounding.d.ts +44 -0
- package/dist/src/grounding.js +127 -0
- package/dist/src/grounding.js.map +1 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/institution_loader.d.ts +29 -5
- package/dist/src/institution_loader.js +67 -6
- package/dist/src/institution_loader.js.map +1 -1
- package/dist/src/lineage_adoption.d.ts +82 -0
- package/dist/src/lineage_adoption.js +82 -0
- package/dist/src/lineage_adoption.js.map +1 -0
- package/dist/src/lineage_persist.d.ts +34 -0
- package/dist/src/lineage_persist.js +60 -0
- package/dist/src/lineage_persist.js.map +1 -0
- package/dist/src/loader.d.ts +13 -0
- package/dist/src/loader.js +43 -2
- package/dist/src/loader.js.map +1 -1
- package/dist/src/mcp.js +37 -4
- package/dist/src/mcp.js.map +1 -1
- package/dist/src/org_hire.d.ts +23 -0
- package/dist/src/org_hire.js +42 -0
- package/dist/src/org_hire.js.map +1 -0
- package/dist/src/output_mirror.js +98 -59
- package/dist/src/output_mirror.js.map +1 -1
- package/dist/src/outputs.d.ts +17 -0
- package/dist/src/outputs.js +86 -1
- package/dist/src/outputs.js.map +1 -1
- package/dist/src/player_to_claude_code.js +26 -2
- package/dist/src/player_to_claude_code.js.map +1 -1
- package/dist/src/registry.d.ts +3 -1
- package/dist/src/registry.js +43 -3
- package/dist/src/registry.js.map +1 -1
- package/dist/src/repo_index.d.ts +58 -0
- package/dist/src/repo_index.js +232 -0
- package/dist/src/repo_index.js.map +1 -0
- package/dist/src/reuse.d.ts +44 -4
- package/dist/src/reuse.js +0 -0
- package/dist/src/reuse.js.map +1 -1
- package/dist/src/runtime.d.ts +82 -0
- package/dist/src/runtime.js +254 -25
- package/dist/src/runtime.js.map +1 -1
- package/dist/src/server.d.ts +37 -0
- package/dist/src/server.js +422 -11
- package/dist/src/server.js.map +1 -1
- package/dist/src/server_relay.d.ts +40 -2
- package/dist/src/server_relay.js +186 -19
- package/dist/src/server_relay.js.map +1 -1
- package/dist/src/tool_providers.d.ts +27 -0
- package/dist/src/tool_providers.js +48 -0
- package/dist/src/tool_providers.js.map +1 -1
- package/dist/src/venue_credential.d.ts +41 -0
- package/dist/src/venue_credential.js +57 -0
- package/dist/src/venue_credential.js.map +1 -0
- package/dist/src/venue_realize.d.ts +23 -0
- package/dist/src/venue_realize.js +43 -3
- package/dist/src/venue_realize.js.map +1 -1
- package/dist/src/venue_realizer.d.ts +224 -0
- package/dist/src/venue_realizer.js +889 -0
- package/dist/src/venue_realizer.js.map +1 -0
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/dist/src/version.js.map +1 -1
- package/dist/src/worker.d.ts +37 -0
- package/dist/src/worker.js +127 -24
- package/dist/src/worker.js.map +1 -1
- package/dist/src/worker_env.d.ts +79 -0
- package/dist/src/worker_env.js +322 -0
- package/dist/src/worker_env.js.map +1 -0
- package/dist/src/workspace.d.ts +12 -1
- package/dist/src/workspace.js +10 -3
- package/dist/src/workspace.js.map +1 -1
- package/domain_types/change-context.json +59 -13
- package/domain_types/lineage-adoption-target.json +22 -0
- package/domain_types/lineage-map.json +7 -2
- package/domain_types/lineage-record.json +55 -5
- package/domain_types/prior-art-hit.json +16 -1
- package/domain_types/red-spec.json +5 -3
- package/domain_types/woodshed-record.json +149 -0
- package/evals/gist-present.json +7 -0
- package/institutions/coltrane.json +178 -6
- package/package.json +6 -3
- package/skills/non-vacuity-probe/fixtures/fixture-001.json +17 -0
- package/skills/non-vacuity-probe/meta.json +12 -0
- package/skills/non-vacuity-probe/skill.md +27 -0
- package/skills/non-vacuity-probe/skill.mjs +81 -0
- package/standards/lineage-adopt-v0.json +37 -0
- package/standards/room-probe-v1.json +28 -0
- package/standards/software-change-pr-v1.json +15 -15
- package/standards/software-change-red-first-v0.json +183 -0
- package/standards/spec-review-and-sequence-v0.json +112 -0
- package/standards/studio-session-v0.json +101 -0
- package/tours/coltrane.json +72 -0
- package/venues/engine-room-v1.json +32 -0
|
@@ -0,0 +1,889 @@
|
|
|
1
|
+
// venue_realizer.ts — WHERE a room is realized, and WHAT it is realized on.
|
|
2
|
+
//
|
|
3
|
+
// This is the other half of Gap 2 (SPEC-worker-contract.md): a venue declares WHICH substrate it
|
|
4
|
+
// needs, a deployment declares WHICH substrates it provides, and this module refuses the mismatch
|
|
5
|
+
// rather than degrading silently. A venue that requires isolation, realized on a substrate that
|
|
6
|
+
// cannot provide it, RUNS and believes it is isolated — a false guarantee is strictly worse than a
|
|
7
|
+
// refusal, because a guarantee is exactly the thing a venue's author reasons against.
|
|
8
|
+
//
|
|
9
|
+
// The renderer is pinned from both directions: it EMITS only from a closed allowlist, so a forbidden
|
|
10
|
+
// setting cannot appear from contract data; and it REFUSES input that smuggles a forbidden value
|
|
11
|
+
// through an allowlisted FIELD, because an allowlist over field NAMES says nothing about what those
|
|
12
|
+
// fields CONTAIN. Rendering a runtime configuration from contract data is code generation from data,
|
|
13
|
+
// and if any part of the input is reachable by a gig, a permissive renderer is remote code execution
|
|
14
|
+
// with extra steps.
|
|
15
|
+
import { existsSync, writeFileSync, rmSync, mkdirSync, mkdtempSync } from "node:fs";
|
|
16
|
+
import { execFileSync } from "node:child_process";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import { VenueSchema, DEVICE_CLASSES } from "./genome_schema.js";
|
|
20
|
+
import { sha256Hex, canonStructuralJson } from "./canonical_form.js";
|
|
21
|
+
import { prepareWorkspace } from "./workspace.js";
|
|
22
|
+
export { DEVICE_CLASSES };
|
|
23
|
+
// ── The refusals. Each NAMES the thing it refused, so a caller is not left to guess. ──────────────
|
|
24
|
+
/** A substrate no available realizer provides is a REFUSAL, never a downgrade — the whole reason
|
|
25
|
+
* this gap is written down. Names what was required and what this host can actually provide. */
|
|
26
|
+
export class VenueSubstrateUnavailable extends Error {
|
|
27
|
+
required;
|
|
28
|
+
available;
|
|
29
|
+
constructor(required, available) {
|
|
30
|
+
super(`no available realizer provides substrate "${required}" — this host provides only ` +
|
|
31
|
+
`[${available.join(", ")}]; a missing substrate is a refusal, never a downgrade to a weaker realizer`);
|
|
32
|
+
this.name = "VenueSubstrateUnavailable";
|
|
33
|
+
this.required = required;
|
|
34
|
+
this.available = available;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** A HOST cannot host this venue: a device class it does not provide, an architecture it is not.
|
|
38
|
+
* Distinct from a substrate refusal — the fix is a different machine, not a different runtime. */
|
|
39
|
+
export class VenueHostUnsuitable extends Error {
|
|
40
|
+
constructor(message) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "VenueHostUnsuitable";
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** A realization would exceed the venue's declared concurrency ceiling. */
|
|
46
|
+
export class VenueConcurrencyRefused extends Error {
|
|
47
|
+
constructor(message) {
|
|
48
|
+
super(message);
|
|
49
|
+
this.name = "VenueConcurrencyRefused";
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** Input would render a setting the contract may not ask for. Carries `forbidden` so the refusal
|
|
53
|
+
* names the value, and `field` so it names where it arrived — "invalid venue" sends the author back
|
|
54
|
+
* to read the whole contract. */
|
|
55
|
+
export class VenueRenderRefusal extends Error {
|
|
56
|
+
forbidden;
|
|
57
|
+
field;
|
|
58
|
+
constructor(args) {
|
|
59
|
+
super(args.message ??
|
|
60
|
+
`the renderer refuses a forbidden value "${args.forbidden}"` +
|
|
61
|
+
(args.field ? ` smuggled through the allowlisted field "${args.field}"` : ""));
|
|
62
|
+
this.name = "VenueRenderRefusal";
|
|
63
|
+
this.forbidden = args.forbidden;
|
|
64
|
+
this.field = args.field;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Gap 2's bidirectional probe, direction one: a tool the CONTRACT grants that the server does not
|
|
68
|
+
* actually advertise. "Granted but unprovided" caught at realization instead of at first use. Carries
|
|
69
|
+
* the bare tool and the server so the refusal names both — the remediation is to add the tool to the
|
|
70
|
+
* server or drop the grant, which is a different fix from a server that is too wide. */
|
|
71
|
+
export class VenueRealizationError extends Error {
|
|
72
|
+
state;
|
|
73
|
+
missingGrant;
|
|
74
|
+
serverSlug;
|
|
75
|
+
constructor(args) {
|
|
76
|
+
super(args.message ??
|
|
77
|
+
`venue realization failed in state ${args.state}: server "${args.serverSlug}" does not advertise ` +
|
|
78
|
+
`granted tool "${args.missingGrant}" — a tool the contract grants that the server cannot supply is ` +
|
|
79
|
+
`"granted but unprovided", refused here rather than discovered mid-run on a box nobody is watching`);
|
|
80
|
+
this.name = "VenueRealizationError";
|
|
81
|
+
this.state = args.state;
|
|
82
|
+
this.missingGrant = args.missingGrant;
|
|
83
|
+
this.serverSlug = args.serverSlug;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Gap 2's bidirectional probe, direction two: the server advertises MORE than the contract declared.
|
|
87
|
+
* A ceiling the thing beneath it can quietly exceed is not a ceiling — the exact intersection R10
|
|
88
|
+
* enforces at compose time. NON-OVERLAPPING fields with `VenueRealizationError` on purpose: the two
|
|
89
|
+
* mismatches demand different remediation, and one merged error would make the direction ambiguous. */
|
|
90
|
+
export class VenueContractViolation extends Error {
|
|
91
|
+
state;
|
|
92
|
+
extraTool;
|
|
93
|
+
serverSlug;
|
|
94
|
+
constructor(args) {
|
|
95
|
+
super(args.message ??
|
|
96
|
+
`venue contract violation in state ${args.state}: server "${args.serverSlug}" advertises ` +
|
|
97
|
+
`"${args.extraTool}", which the contract does not grant — a server wider than the contract is a ` +
|
|
98
|
+
`violation, not a bonus, for the same reason equipment.tools is a ceiling and never a floor`);
|
|
99
|
+
this.name = "VenueContractViolation";
|
|
100
|
+
this.state = args.state;
|
|
101
|
+
this.extraTool = args.extraTool;
|
|
102
|
+
this.serverSlug = args.serverSlug;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
/** The CLOSED allowlist of contract fields the renderer may substitute. Exact, not a floor: a field
|
|
106
|
+
* not on this list contributes nothing to the render whatever it is called, and widening the list
|
|
107
|
+
* is a line someone changes on purpose. */
|
|
108
|
+
export const COMPOSE_SUBSTITUTABLE_FIELDS = [
|
|
109
|
+
"credential_names",
|
|
110
|
+
"doors",
|
|
111
|
+
"installs",
|
|
112
|
+
"mcp_servers",
|
|
113
|
+
"slug",
|
|
114
|
+
];
|
|
115
|
+
// ── Identity: an environment is a function of the CONTRACT, so an unchanged contract rebuilds
|
|
116
|
+
// nothing. floorIdentity isolates the shared base so N venues cost floor + Σ(deltas). ──────────
|
|
117
|
+
/** The identity of the environment built from a venue — a function of the parsed contract's content,
|
|
118
|
+
* so the same contract is the same environment and a changed one is a different environment. */
|
|
119
|
+
export function environmentIdentity(venue) {
|
|
120
|
+
const v = VenueSchema.parse(venue);
|
|
121
|
+
return `env-${sha256Hex(canonStructuralJson(v))}`;
|
|
122
|
+
}
|
|
123
|
+
/** The identity of the shared floor a venue composes over. Two venues declaring the same floor
|
|
124
|
+
* resolve to the SAME floor identity — the precondition for sharing at all. */
|
|
125
|
+
export function floorIdentity(venue) {
|
|
126
|
+
const v = VenueSchema.parse(venue);
|
|
127
|
+
return `floor-${sha256Hex(canonStructuralJson({ floor: v.floor ?? null }))}`;
|
|
128
|
+
}
|
|
129
|
+
// ── The renderer. Parsed input only; emits only from the closed allowlist; refuses a forbidden value
|
|
130
|
+
// smuggled through an allowlisted field. ─────────────────────────────────────────────────────
|
|
131
|
+
/** Runtime sockets are the one-line escape: a container that can reach the runtime's own socket can
|
|
132
|
+
* start a second container with the host filesystem mounted. */
|
|
133
|
+
const RUNTIME_SOCKET = /docker\.sock|containerd\.sock|podman\.sock|\/var\/run\/docker/i;
|
|
134
|
+
/** The room image's engine root — `WORKDIR /app` in Dockerfile.room, where the compiled engine and
|
|
135
|
+
* the base genome live. An absolute path under here is a path in the ROOM IMAGE's filesystem, NOT a
|
|
136
|
+
* host path: naming a binary here names where it lives INSIDE the image the venue declared, which
|
|
137
|
+
* the contract is entitled to do; it is not an arbitrary host path to mount or expose. */
|
|
138
|
+
const ROOM_IMAGE_ROOT = "/app";
|
|
139
|
+
/** A single value about to be emitted through an allowlisted field, checked for what an allowlist
|
|
140
|
+
* over field NAMES cannot see. A runtime socket anywhere is refused in BOTH namespaces; an absolute
|
|
141
|
+
* path is refused as a host path unless it is derived from the per-realization directory or, in the
|
|
142
|
+
* room-image namespace, lives under the room image's engine root. */
|
|
143
|
+
function assertValueRenderable(field, value, realizationDir, namespace = "host") {
|
|
144
|
+
if (RUNTIME_SOCKET.test(value)) {
|
|
145
|
+
throw new VenueRenderRefusal({
|
|
146
|
+
forbidden: value,
|
|
147
|
+
field,
|
|
148
|
+
message: `field "${field}" carries a container runtime socket path "${value}" — a runtime socket ` +
|
|
149
|
+
`inside the room is the end of the room, allowlisted field or not`,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (value.startsWith("/")) {
|
|
153
|
+
// TWO NAMESPACES, AND THE CONFLATION FIXED AT ITS SITE. A path derived from the per-realization
|
|
154
|
+
// directory is always allowed — it is the room's own workspace. Beyond that:
|
|
155
|
+
// host namespace — an absolute path is a HOST path with exactly one meaning (see the host),
|
|
156
|
+
// whichever field it arrived in, and is refused. This is the protection the
|
|
157
|
+
// contract must not be able to defeat by naming an arbitrary mount source or
|
|
158
|
+
// exposure; the law that pins it is
|
|
159
|
+
// tests/spec_venue_realization_substrate.test.ts:540-556 ("refuses an
|
|
160
|
+
// absolute host path outside the realization directory"), left UNMODIFIED.
|
|
161
|
+
// room-image ns — a COMMAND token of a containerized server names WHERE A BINARY LIVES INSIDE
|
|
162
|
+
// the room image the venue declared. A path under the image's engine root
|
|
163
|
+
// (/app, Dockerfile.room WORKDIR) is representable: it is not a host path at
|
|
164
|
+
// all, so refusing it forced venues to ship a placeholder command nothing
|
|
165
|
+
// runs. Any OTHER absolute path — /etc, a mount source — is still a host path
|
|
166
|
+
// even in a command token, and still refused here.
|
|
167
|
+
//
|
|
168
|
+
// DIRECTION 1 (honour the declared command) chosen over Direction 2 (refuse a command for a
|
|
169
|
+
// containerized stdio server at authoring time): it leaves VenueSchema Rule 2 (a stdio server
|
|
170
|
+
// owes a command) unchanged, keeps the compose-service `command` source intact, and fixes the
|
|
171
|
+
// host/room-image conflation at the exact predicate where it lived rather than burying the root
|
|
172
|
+
// cause under a schema gate. The RUNTIME_SOCKET guard above still covers BOTH namespaces: a
|
|
173
|
+
// docker.sock inside a room still escapes the container, so it is refused even as a command token.
|
|
174
|
+
const underRealizationDir = value.startsWith(realizationDir);
|
|
175
|
+
const underRoomImage = namespace === "room-image" && (value === ROOM_IMAGE_ROOT || value.startsWith(`${ROOM_IMAGE_ROOT}/`));
|
|
176
|
+
if (!underRealizationDir && !underRoomImage) {
|
|
177
|
+
throw new VenueRenderRefusal({
|
|
178
|
+
forbidden: value,
|
|
179
|
+
field,
|
|
180
|
+
message: `field "${field}" carries the absolute host path "${value}", which is outside the ` +
|
|
181
|
+
`per-realization directory — an arbitrary host path chosen by whoever wrote the contract`,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** Renders the runtime configuration from a PARSED venue. Never from raw input: re-parsing through
|
|
187
|
+
* `VenueSchema` (which is `.strict()`) is the enforcement point, so an unparsed object carrying an
|
|
188
|
+
* extra key is refused at the door rather than trusted to have been parsed by every future caller. */
|
|
189
|
+
export function renderComposeConfig(venue, opts) {
|
|
190
|
+
// PARSED INPUT ONLY. `.strict()` rejects an object carrying a key the contract never declared, so
|
|
191
|
+
// handing the renderer unparsed input cannot walk around it. A parsed venue re-parses idempotently.
|
|
192
|
+
const v = VenueSchema.parse(venue);
|
|
193
|
+
const { realizationDir } = opts;
|
|
194
|
+
// Scan the CONTENT of every allowlisted field before emitting any of it — the half a naive
|
|
195
|
+
// allowlist misses.
|
|
196
|
+
assertValueRenderable("slug", v.slug, realizationDir);
|
|
197
|
+
for (const dir of ["ingress", "egress"]) {
|
|
198
|
+
for (const host of v.doors?.[dir] ?? [])
|
|
199
|
+
assertValueRenderable("doors", host, realizationDir);
|
|
200
|
+
}
|
|
201
|
+
for (const install of v.installs)
|
|
202
|
+
assertValueRenderable("installs", install, realizationDir);
|
|
203
|
+
for (const cls of v.credential_surface)
|
|
204
|
+
assertValueRenderable("credential_names", cls, realizationDir);
|
|
205
|
+
for (const server of v.mcp_servers) {
|
|
206
|
+
assertValueRenderable("mcp_servers", server.slug, realizationDir);
|
|
207
|
+
assertValueRenderable("mcp_servers", server.transport, realizationDir);
|
|
208
|
+
// Command tokens name the ROOM IMAGE: an in-image absolute path (under /app) is where the server
|
|
209
|
+
// binary lives inside the image the venue declared, so it is representable — while a host path or
|
|
210
|
+
// a runtime socket smuggled through the same field is still refused. See assertValueRenderable.
|
|
211
|
+
for (const token of server.command)
|
|
212
|
+
assertValueRenderable("mcp_servers", token, realizationDir, "room-image");
|
|
213
|
+
for (const cls of server.credential_names)
|
|
214
|
+
assertValueRenderable("credential_names", cls, realizationDir);
|
|
215
|
+
}
|
|
216
|
+
// Every path in the document is DERIVED from the per-realization directory, so "which host paths
|
|
217
|
+
// can this room see" is answerable by reading this line, not by auditing every venue forever.
|
|
218
|
+
const workspace = `${realizationDir}/workspace`;
|
|
219
|
+
const projectName = composeProjectName(realizationDir);
|
|
220
|
+
// Credential CLASSES only — the class is the contract's own vocabulary and may appear; the material
|
|
221
|
+
// never does, and the host environment is never inherited wholesale.
|
|
222
|
+
//
|
|
223
|
+
// ★ NOT AS `${class}` IN `environment`, AND THIS WAS MEASURED RATHER THAN REASONED. Compose runs
|
|
224
|
+
// shell parameter expansion over interpolated values, where `-` is the DEFAULT-VALUE operator. So
|
|
225
|
+
// `${notes-token}` never referenced a variable named `notes-token` at all — it meant "the value of
|
|
226
|
+
// $notes, or the literal string `token`". Run against the real binary:
|
|
227
|
+
//
|
|
228
|
+
// notes unset → room receives the literal "token"
|
|
229
|
+
// notes=LEAKED_VALUE → room receives LEAKED_VALUE
|
|
230
|
+
//
|
|
231
|
+
// An UNDECLARED host variable, matching the class only up to its first hyphen, is silently
|
|
232
|
+
// injected into the room as that credential — and the process holding that environment is the
|
|
233
|
+
// drain, which holds the venue credential. Every class in the shipped naming convention is
|
|
234
|
+
// hyphenated (`notes-token`, `vercel-token`), so every one was affected.
|
|
235
|
+
//
|
|
236
|
+
// The forty laws could not catch this: they assert on the RENDERED DOCUMENT, and the document was
|
|
237
|
+
// correct — class present, material absent, no forbidden setting. The defect lived one step later,
|
|
238
|
+
// in how Compose INTERPRETS it. Verifying the artifact is not verifying the behaviour.
|
|
239
|
+
//
|
|
240
|
+
// So credential CLASSES are DECLARED (in the room metadata below and on each server) and never
|
|
241
|
+
// interpolated: a class name is data the contract owns, so shell parameter-expansion semantics stop
|
|
242
|
+
// being part of the threat model at all. The MATERIAL is in neither this document nor a host
|
|
243
|
+
// bind-mount. The earlier mechanism wrote each resolved value to `<realizationDir>/secrets/<class>`
|
|
244
|
+
// and declared it as a compose file-secret — but a compose file-secret is a BIND MOUNT, not a copy:
|
|
245
|
+
// measured, deleting the host file makes the in-room read fail immediately, so the material had to
|
|
246
|
+
// stay on the host for the room's ENTIRE life, where a seat running as the invoking user could read
|
|
247
|
+
// it off disk (defeating withoutBoxCredentials, which strips the same value from the environment).
|
|
248
|
+
// So this document no longer declares a file-backed secret at all; dockerComposeRealizer copies the
|
|
249
|
+
// material straight into the container's own filesystem at realization (`docker cp` into the
|
|
250
|
+
// created-but-not-started room), and nothing readable from the host holds it while the room runs.
|
|
251
|
+
const room = {
|
|
252
|
+
image: v.floor ? `coltrane/floor:${v.floor}` : "coltrane/room:ephemeral",
|
|
253
|
+
working_dir: workspace,
|
|
254
|
+
// The room HOLDS. Under this realizer's topology the chair reaches the SERVERS inside by
|
|
255
|
+
// `docker exec` (see buildMcpConfigs), and there is nothing to exec into unless the container
|
|
256
|
+
// stays up. A room service with no command starts, finds nothing to do, and exits — which is
|
|
257
|
+
// exactly why nothing stood up before (acknowledged at the "A SERVICE PER DECLARED SERVER"
|
|
258
|
+
// note below). `sleep infinity` keeps it alive for the compose project's lifetime and carries
|
|
259
|
+
// NO path, so the value-level scan and the mount-source law pass it cleanly — unlike
|
|
260
|
+
// `tail -f /dev/null`, whose absolute `/dev/null` is outside the per-realization directory.
|
|
261
|
+
command: ["sleep", "infinity"],
|
|
262
|
+
// Source AND target derived from the realization dir; no absolute path in the document is not.
|
|
263
|
+
volumes: [`${workspace}:${workspace}:rw`],
|
|
264
|
+
// No `secrets:` reference and no environment entry carries a credential — the material is copied
|
|
265
|
+
// into this container's own filesystem at realization (see dockerComposeRealizer), not bound from
|
|
266
|
+
// a host file. The classes the room may READ are declared in `x-coltrane-room.credential_classes`.
|
|
267
|
+
// An internal network, never host networking: the room's network boundary stays the room's.
|
|
268
|
+
networks: ["room-net"],
|
|
269
|
+
// A log bound from the realizer, NOT a venue-substitutable field — a room may not raise its own
|
|
270
|
+
// ceiling. A rendered configuration with no log bound is a defect.
|
|
271
|
+
logging: { driver: "json-file", options: { "max-size": "10m", "max-file": "3" } },
|
|
272
|
+
labels: { "coltrane.managed": "true", "coltrane.slug": v.slug, "coltrane.project": projectName },
|
|
273
|
+
// The allowlisted contract fields, substituted verbatim after the value-level scan above.
|
|
274
|
+
"x-coltrane-room": {
|
|
275
|
+
slug: v.slug,
|
|
276
|
+
doors: v.doors ?? { ingress: [], egress: [] },
|
|
277
|
+
installs: v.installs,
|
|
278
|
+
credential_classes: v.credential_surface,
|
|
279
|
+
mcp_servers: v.mcp_servers.map((s) => ({
|
|
280
|
+
slug: s.slug,
|
|
281
|
+
transport: s.transport,
|
|
282
|
+
command: s.command,
|
|
283
|
+
credential_names: s.credential_names,
|
|
284
|
+
})),
|
|
285
|
+
},
|
|
286
|
+
};
|
|
287
|
+
// ── WHO OWNS THE WORKSPACE. DIRECTION (a): RUN THE ROOM AS THE INVOKING USER. ──────────────────
|
|
288
|
+
//
|
|
289
|
+
// The workspace above is bind-mounted `rw` from a HOST directory the drain mkdirs, so on Linux it
|
|
290
|
+
// is owned by whoever ran the drain (uid 501 on a laptop, ~1001 on a GitHub runner). The image runs
|
|
291
|
+
// as `USER node` = uid 1000. Those uids MISMATCH, so a seat exec'd into the room could not write its
|
|
292
|
+
// own workspace on Linux — the isolation law's in-room write (spec_venue_room_live.test.ts) exited
|
|
293
|
+
// non-zero. macOS Docker Desktop translates ownership across the mount and hid it; Linux does not,
|
|
294
|
+
// so this only surfaced in CI.
|
|
295
|
+
//
|
|
296
|
+
// ★ DO NOT REINTRODUCE A chown FROM THE HOST. A NON-ROOT process on Linux CANNOT chown a file to
|
|
297
|
+
// another uid — a drain running as `runner` cannot hand its workspace to uid 1000, so any
|
|
298
|
+
// host-side `chown` works on a root drain and fails on every other one. The fix must not require
|
|
299
|
+
// the host to own the workspace to a different uid.
|
|
300
|
+
//
|
|
301
|
+
// Instead, pin the container's user to the invoking uid:gid. The container then owns its own bind
|
|
302
|
+
// mount by construction and the in-room write succeeds, WITHOUT the host chowning anything. This
|
|
303
|
+
// stays NON-ROOT (the posture that matters — asserted live by the not-root law): it is simply no
|
|
304
|
+
// longer specifically `node`. Nothing in the room/floor image depends on uid 1000 — /app and
|
|
305
|
+
// /run/secrets/<class> are world-readable (COPY lands root-owned 0644; credential files are mode
|
|
306
|
+
// 0o444), so a non-1000 uid still reads the genome and its credentials.
|
|
307
|
+
//
|
|
308
|
+
// Direction (b) — make the workspace a NAMED VOLUME so Docker creates it with the container's
|
|
309
|
+
// ownership and host-path exposure drops entirely — is the better END STATE, but it changes
|
|
310
|
+
// seat.workspace from a host absolute path to a container-relative one and its callers (notably
|
|
311
|
+
// src/claude_invoker.ts, out of scope here) were unread. It is DEFERRED as a named next step, not
|
|
312
|
+
// discarded, so committing to it does not risk a second failure outside the target surface.
|
|
313
|
+
//
|
|
314
|
+
// A drain-local fact, not a contract field: injected here on the realizer-built object rather than
|
|
315
|
+
// routed through contract data, so it bypasses COMPOSE_SUBSTITUTABLE_FIELDS without widening that
|
|
316
|
+
// allowlist. `getuid`/`getgid` are POSIX-only; where absent (non-POSIX host) the field is omitted
|
|
317
|
+
// and the image's own `USER node` stands, which is the prior behaviour.
|
|
318
|
+
const uid = process.getuid?.();
|
|
319
|
+
const gid = process.getgid?.();
|
|
320
|
+
if (uid !== undefined && gid !== undefined)
|
|
321
|
+
room["user"] = `${uid}:${gid}`;
|
|
322
|
+
// Devices: map EXACTLY the declared class's nodes and the owning group, and widen nothing — no
|
|
323
|
+
// privileged mode, no capabilities, no wildcard device rule. Only when a host maps the class.
|
|
324
|
+
if (opts.host && v.devices.length > 0) {
|
|
325
|
+
const deviceMounts = [];
|
|
326
|
+
const groups = new Set();
|
|
327
|
+
for (const cls of v.devices) {
|
|
328
|
+
const mapped = opts.host.devices[cls];
|
|
329
|
+
if (!mapped)
|
|
330
|
+
continue; // a class the host does not provide is refused in realize(), not here
|
|
331
|
+
for (const node of mapped.nodes)
|
|
332
|
+
deviceMounts.push(`${node}:${node}:rw`);
|
|
333
|
+
groups.add(mapped.group);
|
|
334
|
+
}
|
|
335
|
+
if (deviceMounts.length > 0) {
|
|
336
|
+
room["devices"] = deviceMounts;
|
|
337
|
+
room["group_add"] = [...groups];
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
// ── A SERVICE PER DECLARED SERVER ────────────────────────────────────────────────────────────
|
|
341
|
+
//
|
|
342
|
+
// The `room` service above holds the workspace and the labels; it runs no command, because under
|
|
343
|
+
// the topology this realizer implements TODAY the chair runs on the host and only the SERVERS run
|
|
344
|
+
// inside. A room with no command starts and exits, which is why nothing stood up before: the
|
|
345
|
+
// rendered document described a place and nothing to do in it.
|
|
346
|
+
//
|
|
347
|
+
// Each declared server therefore becomes its own service on the internal network, running the
|
|
348
|
+
// command the contract names, reachable by its slug as a hostname. `command` is a contract field
|
|
349
|
+
// and already went through the value-level scan above — a forbidden value inside it was refused
|
|
350
|
+
// before reaching here, which is the half a field-name allowlist misses.
|
|
351
|
+
//
|
|
352
|
+
// A server is granted ONLY the classes it declared (`credential_names`), never the room's whole
|
|
353
|
+
// `credential_surface`. The surface is the ceiling; the declaration is the grant.
|
|
354
|
+
const serverServices = {};
|
|
355
|
+
for (const s of v.mcp_servers) {
|
|
356
|
+
serverServices[s.slug] = {
|
|
357
|
+
image: v.floor ? `coltrane/floor:${v.floor}` : "coltrane/room:ephemeral",
|
|
358
|
+
...(s.command.length > 0 ? { command: s.command } : {}),
|
|
359
|
+
networks: ["room-net"],
|
|
360
|
+
// The classes this server may read are declared in `x-coltrane-room.mcp_servers[].credential_names`;
|
|
361
|
+
// the material is copied into this container's filesystem at realization, never bound from a host
|
|
362
|
+
// file — so there is no `secrets:` reference here either.
|
|
363
|
+
logging: { driver: "json-file", options: { "max-size": "10m", "max-file": "3" } },
|
|
364
|
+
labels: {
|
|
365
|
+
"coltrane.managed": "true",
|
|
366
|
+
"coltrane.slug": v.slug,
|
|
367
|
+
"coltrane.project": projectName,
|
|
368
|
+
"coltrane.server": s.slug,
|
|
369
|
+
},
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
return {
|
|
373
|
+
version: "3.8",
|
|
374
|
+
name: projectName,
|
|
375
|
+
services: { room, ...serverServices },
|
|
376
|
+
networks: { "room-net": { internal: true } },
|
|
377
|
+
// No top-level `secrets:` block. A compose file-secret needs a host-file source, and that source
|
|
378
|
+
// is a bind mount that keeps the material readable on the host for the room's whole life — the
|
|
379
|
+
// exact exposure this realizer now closes by copying the material into the container instead. The
|
|
380
|
+
// credential CLASSES stay declared, in `x-coltrane-room` above; only the material's delivery moved.
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
// ── selectRealizer: picks the realizer a venue requires from those a deployment supplies. Throws
|
|
384
|
+
// rather than returning a weaker one. ──────────────────────────────────────────────────────
|
|
385
|
+
/** Picks the realizer a venue requires from those a deployment supplies. A required substrate no
|
|
386
|
+
* AVAILABLE realizer provides is a `VenueSubstrateUnavailable`, never a downgrade. A venue naming no
|
|
387
|
+
* substrate is realizable by whatever the deployment supplies — deny-by-default is on capability,
|
|
388
|
+
* not on portability. */
|
|
389
|
+
export function selectRealizer(venue, realizers) {
|
|
390
|
+
const v = VenueSchema.parse(venue);
|
|
391
|
+
const provided = realizers.map((r) => r.substrate);
|
|
392
|
+
const available = realizers.filter((r) => r.available());
|
|
393
|
+
const required = v.substrate;
|
|
394
|
+
if (!required) {
|
|
395
|
+
const pick = available[0];
|
|
396
|
+
if (!pick)
|
|
397
|
+
throw new VenueSubstrateUnavailable("(any)", provided);
|
|
398
|
+
return pick;
|
|
399
|
+
}
|
|
400
|
+
const match = available.find((r) => r.substrate === required);
|
|
401
|
+
if (!match)
|
|
402
|
+
throw new VenueSubstrateUnavailable(required, provided);
|
|
403
|
+
return match;
|
|
404
|
+
}
|
|
405
|
+
// ── The two implementations. ──────────────────────────────────────────────────────────────────
|
|
406
|
+
/** HOST-WIDE observed state, so a sweep reconciles against what EXISTS rather than against anything
|
|
407
|
+
* this process remembers — the only model that survives a killed worker running no `finally`. */
|
|
408
|
+
const HOST_ARTIFACTS = new Map();
|
|
409
|
+
/** Who this process is, so a reconciled artifact says which instance created it. */
|
|
410
|
+
const INSTANCE = `instance-${process.pid}`;
|
|
411
|
+
let ARTIFACT_SEQ = 0;
|
|
412
|
+
/** THE ONE OWNER OF THE COMPOSE PROJECT NAME — and therefore of the room's container name.
|
|
413
|
+
*
|
|
414
|
+
* These two facts must agree or the realization is dead on arrival: the name compose gives the
|
|
415
|
+
* container, and the name the emitted MCP transport execs into. They were derived in two places
|
|
416
|
+
* from two different inputs (the realization directory here, the gig id at the call site) and a
|
|
417
|
+
* comment asserted they matched. A restatement is not an agreement; when the directory was not
|
|
418
|
+
* named `gig-<id8>` the transport pointed at a container that never existed, and every law still
|
|
419
|
+
* passed because every law read the emitted string rather than running it.
|
|
420
|
+
*
|
|
421
|
+
* So there is one derivation and both callers go through it. `-room-1` is docker compose's own
|
|
422
|
+
* default container name for the `room` service of this project. */
|
|
423
|
+
export function composeProjectName(realizationDir) {
|
|
424
|
+
return `coltrane-${realizationDir.split("/").filter(Boolean).pop() ?? "room"}`;
|
|
425
|
+
}
|
|
426
|
+
export function roomContainerName(realizationDir) {
|
|
427
|
+
return `${composeProjectName(realizationDir)}-room-1`;
|
|
428
|
+
}
|
|
429
|
+
/** The container docker compose names for a declared SERVER service — same `{project}-{service}-1`
|
|
430
|
+
* default as the room, with the server's slug as the service. The credential-delivery step copies
|
|
431
|
+
* each server's own classes into this container, mirroring where the prior compose file-secret was
|
|
432
|
+
* mounted, so a server that reads /run/secrets/<class> is unchanged by the delivery move. */
|
|
433
|
+
export function serverContainerName(realizationDir, slug) {
|
|
434
|
+
return `${composeProjectName(realizationDir)}-${slug}-1`;
|
|
435
|
+
}
|
|
436
|
+
/** Deliver resolved credential material INTO a container's own filesystem, never onto a host
|
|
437
|
+
* bind-mount. Each class becomes a file at `/run/secrets/<class>` inside `container`, copied there by
|
|
438
|
+
* `docker cp` — which, into a container that has been CREATED but not yet STARTED, is a real copy
|
|
439
|
+
* (measured: copy a file in, delete the host copy, start the container, the file is still there),
|
|
440
|
+
* unlike a compose file-secret, which is a bind mount that keeps the material on the host for the
|
|
441
|
+
* room's whole life.
|
|
442
|
+
*
|
|
443
|
+
* The staging directory lives OUTSIDE the realization directory and is removed in the `finally`
|
|
444
|
+
* BEFORE `docker compose start`, so the material is on the host only during the created-not-running
|
|
445
|
+
* window and is gone before any process in the room — or any seat reading the host as the invoking
|
|
446
|
+
* user — could observe it while the room runs. This is the property the change exists for; it also
|
|
447
|
+
* covers the killed-worker case, because after realize() returns nothing on the host holds the value
|
|
448
|
+
* whether or not teardown ever runs.
|
|
449
|
+
*
|
|
450
|
+
* Mode 0o444, not 0o600: `docker cp` does not reliably reassign ownership to the container user (uid
|
|
451
|
+
* 1000, USER node), and a 0600 root-owned copy read back "Permission denied" inside the room. Making
|
|
452
|
+
* the copy world-readable makes the in-room read depend on the file mode rather than on whichever uid
|
|
453
|
+
* the copy lands as — and inside the container, where only the room's own processes can see it, that
|
|
454
|
+
* is simply the room reading its own credential. */
|
|
455
|
+
function deliverCredentialFiles(run, container, classes, material) {
|
|
456
|
+
if (classes.length === 0)
|
|
457
|
+
return;
|
|
458
|
+
const staging = mkdtempSync(join(tmpdir(), "coltrane-cred-stage-"));
|
|
459
|
+
try {
|
|
460
|
+
const secretsDir = join(staging, "secrets");
|
|
461
|
+
mkdirSync(secretsDir);
|
|
462
|
+
for (const cls of classes) {
|
|
463
|
+
// A class the resolver did not supply gets an EMPTY file, never a value inherited from the host
|
|
464
|
+
// environment — an empty credential fails at the service that reads it, the right direction for
|
|
465
|
+
// a missing secret to fail.
|
|
466
|
+
writeFileSync(join(secretsDir, cls), material[cls] ?? "", { mode: 0o444 });
|
|
467
|
+
}
|
|
468
|
+
// `/run` exists in the room image; copying the `secrets` directory INTO it yields
|
|
469
|
+
// `/run/secrets/<class>` — the same path the compose file-secret used, so the in-room reader is
|
|
470
|
+
// unchanged and only the delivery mechanism moved off the host.
|
|
471
|
+
run(["cp", secretsDir, `${container}:/run`], 120_000);
|
|
472
|
+
}
|
|
473
|
+
finally {
|
|
474
|
+
rmSync(staging, { recursive: true, force: true });
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
/** Builds the spawn's MCP map. `roomContainer` names the substrate: absent = the local-process path,
|
|
478
|
+
* which points the chair at the server's own bare command; present = the containerized path, where
|
|
479
|
+
* the server runs INSIDE a held room and the chair reaches it by `docker exec` over stdio.
|
|
480
|
+
*
|
|
481
|
+
* ★ COLTRANE_SERVER_DIRECT=1 IS REQUIRED AND WAS MEASURED, NOT READ OFF THE CODE. Without it,
|
|
482
|
+
* dist/src/server_entry.js runs in RELAY mode — it spawns a child and holds the pipe — and the
|
|
483
|
+
* failure is SILENCE: no output, no error, no exit. So the flag is emitted UNCONDITIONALLY on the
|
|
484
|
+
* containerized path, never gated on the command string: a process that does not read it is
|
|
485
|
+
* unharmed, and over-inclusion is far safer than the silent-relay failure under-inclusion causes. */
|
|
486
|
+
function buildMcpConfigs(v, opts, roomContainer) {
|
|
487
|
+
return (async () => {
|
|
488
|
+
const configs = { ...(opts.engineServers ?? {}) };
|
|
489
|
+
for (const server of v.mcp_servers) {
|
|
490
|
+
if (opts.probe)
|
|
491
|
+
await opts.probe({ slug: server.slug });
|
|
492
|
+
if (roomContainer && server.transport === "stdio") {
|
|
493
|
+
// The proven path: `docker exec -i -e COLTRANE_SERVER_DIRECT=1 -e COLTRANE_GENOME=/app
|
|
494
|
+
// <container> <server.command…>` speaks MCP over stdio into the running room — no published
|
|
495
|
+
// port, no HTTP server, no network. sse ({url}) servers keep their existing handling.
|
|
496
|
+
//
|
|
497
|
+
// THE DECLARED COMMAND IS WHAT RUNS. It is appended verbatim after the two -e flags, so a
|
|
498
|
+
// venue declaring `['node','/app/dist/src/server_entry.js']` gets exactly that exec'd into its
|
|
499
|
+
// room, and a venue declaring a different server gets a different one. This path formerly
|
|
500
|
+
// hardcoded `node /app/dist/src/server_entry.js` and DISCARDED whatever the contract declared
|
|
501
|
+
// — a venue could name a server the room would never run, and coltrane's own engine answered
|
|
502
|
+
// under any slug. renderComposeConfig already scans every command token; it now scans them in
|
|
503
|
+
// the room-image namespace (see assertValueRenderable), so an in-image absolute path is
|
|
504
|
+
// representable and the token is validated before it reaches here.
|
|
505
|
+
//
|
|
506
|
+
// ★ WHY THE DECLARED COMMAND NAMES AN ABSOLUTE /app PATH, AND WHY THE TWO -e FLAGS RIDE
|
|
507
|
+
// ALONGSIDE IT REGARDLESS OF WHAT THE COMMAND IS:
|
|
508
|
+
// · ABSOLUTE, not `dist/src/server_entry.js` — the room service sets `working_dir` to the
|
|
509
|
+
// WORKSPACE, so a relative entry path resolves against the mounted (empty) work directory
|
|
510
|
+
// and node dies with "Cannot find module …/workspace/dist/src/server_entry.js". /app is
|
|
511
|
+
// where the room image puts the compiled engine (Dockerfile.room WORKDIR). This is now the
|
|
512
|
+
// venue author's contract to keep; the shipped engine-room-v1 declares exactly it.
|
|
513
|
+
// · COLTRANE_SERVER_DIRECT=1 — emitted UNCONDITIONALLY, never gated on the command string:
|
|
514
|
+
// without it dist/src/server_entry.js runs in RELAY mode and the failure is SILENCE. A
|
|
515
|
+
// command that does not read the flag is unharmed; over-inclusion beats the silent hang.
|
|
516
|
+
// · COLTRANE_GENOME=/app — makes the room serve a REAL genome. bootstrapServerDeps
|
|
517
|
+
// (src/server.ts) resolves the genome root as `genomeRoot ?? COLTRANE_GENOME ?? cwd()`;
|
|
518
|
+
// cwd is the empty workspace, so without this the in-room engine loads nothing and
|
|
519
|
+
// type_browse answers count:0 against 64 on the host. Naming the root explicitly loads it
|
|
520
|
+
// without moving the seat's correct working directory — root and working dir stay distinct.
|
|
521
|
+
configs[server.slug] = {
|
|
522
|
+
command: "docker",
|
|
523
|
+
args: ["exec", "-i", "-e", "COLTRANE_SERVER_DIRECT=1", "-e", "COLTRANE_GENOME=/app", roomContainer, ...server.command],
|
|
524
|
+
};
|
|
525
|
+
continue;
|
|
526
|
+
}
|
|
527
|
+
configs[server.slug] = { command: server.command[0], args: server.command.slice(1) };
|
|
528
|
+
}
|
|
529
|
+
return configs;
|
|
530
|
+
})();
|
|
531
|
+
}
|
|
532
|
+
/** The Gap 2 entry: builds the spawn's MCP environment STRICTLY from the venue contract, verifies it
|
|
533
|
+
* in both directions before anything is committed, and writes the per-gig config the spawn is pointed
|
|
534
|
+
* at. The map is the venue's declared servers plus the engine entries and NOTHING the venue did not
|
|
535
|
+
* declare — the ambient `.mcp.json` `readMcpServerConfigs` builds at bootstrap is never read here,
|
|
536
|
+
* neither to add a server nor to override one, because a drain's cwd is an untrusted clone and a clone
|
|
537
|
+
* that can declare MCP servers for the seat reading it is command execution under the seat. The
|
|
538
|
+
* realized map is precisely what a tool grant is failed closed against, so preflight and spawn read
|
|
539
|
+
* one stable object rather than two copies that agree today. */
|
|
540
|
+
export async function realizeVenue(venue, credentialResolver, opts) {
|
|
541
|
+
const v = VenueSchema.parse(venue);
|
|
542
|
+
// Credentials reach the room only through the resolver, and only the names the contract listed —
|
|
543
|
+
// never the whole surface, never a name the contract did not ask for. The schema already guarantees
|
|
544
|
+
// each named credential is a member of credential_surface.
|
|
545
|
+
const requested = v.mcp_servers.flatMap((s) => s.credential_names);
|
|
546
|
+
await credentialResolver(requested);
|
|
547
|
+
// Engine entries first; declared servers added below. The ambient map is never a source.
|
|
548
|
+
const configs = { ...(opts.engineServers ?? {}) };
|
|
549
|
+
// THE PROBE VERIFIES IN BOTH DIRECTIONS, BEFORE ANYTHING SPAWNS. A room with no declared servers
|
|
550
|
+
// reaches neither branch, so it probes nothing and stands up zero child processes — the empty room
|
|
551
|
+
// stays free.
|
|
552
|
+
for (const server of v.mcp_servers) {
|
|
553
|
+
const prefix = `mcp__${server.slug}__`;
|
|
554
|
+
const granted = v.equipment.tools.filter((t) => t.startsWith(prefix)).map((t) => t.slice(prefix.length));
|
|
555
|
+
const advertised = opts.probe ? await opts.probe({ slug: server.slug }) : [];
|
|
556
|
+
// Direction one: a granted tool the server does not advertise — "granted but unprovided".
|
|
557
|
+
for (const g of granted) {
|
|
558
|
+
if (!advertised.includes(g)) {
|
|
559
|
+
throw new VenueRealizationError({ state: "VERIFIED", missingGrant: g, serverSlug: server.slug });
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
// Direction two: the server is WIDER than the contract — a ceiling quietly exceeded.
|
|
563
|
+
for (const a of advertised) {
|
|
564
|
+
if (!granted.includes(a)) {
|
|
565
|
+
throw new VenueContractViolation({ state: "VERIFIED", extraTool: a, serverSlug: server.slug });
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
configs[server.slug] =
|
|
569
|
+
server.command.length > 0
|
|
570
|
+
? { command: server.command[0], args: server.command.slice(1) }
|
|
571
|
+
: { url: server.url };
|
|
572
|
+
}
|
|
573
|
+
// The per-gig config file the spawn is actually pointed at. Its content IS the realized map, so the
|
|
574
|
+
// handle and the file cannot state two different things (the drift Gap 3 is entirely about). Keyed
|
|
575
|
+
// to the gig so concurrent gigs never collide on one shared path.
|
|
576
|
+
const configPath = join(tmpdir(), `coltrane-venue-${opts.gigId}.mcp.json`);
|
|
577
|
+
writeFileSync(configPath, JSON.stringify({ mcpServers: configs }));
|
|
578
|
+
let torn = false;
|
|
579
|
+
return {
|
|
580
|
+
state: "PLAYING",
|
|
581
|
+
// A single stable object reference — the SAME value on repeated access, so preflight and spawn
|
|
582
|
+
// resolve against one map rather than two copies.
|
|
583
|
+
mcpServerConfigs: configs,
|
|
584
|
+
configPath,
|
|
585
|
+
artifacts: [],
|
|
586
|
+
teardown() {
|
|
587
|
+
torn = true;
|
|
588
|
+
if (existsSync(configPath))
|
|
589
|
+
rmSync(configPath);
|
|
590
|
+
},
|
|
591
|
+
tornDown() {
|
|
592
|
+
return torn;
|
|
593
|
+
},
|
|
594
|
+
};
|
|
595
|
+
}
|
|
596
|
+
function makeHandle(state, mcpServerConfigs, configPath, artifacts, onTeardown, seat) {
|
|
597
|
+
let torn = false;
|
|
598
|
+
for (const a of artifacts)
|
|
599
|
+
HOST_ARTIFACTS.set(a.id, a);
|
|
600
|
+
return {
|
|
601
|
+
state,
|
|
602
|
+
mcpServerConfigs,
|
|
603
|
+
configPath,
|
|
604
|
+
artifacts,
|
|
605
|
+
teardown() {
|
|
606
|
+
torn = true;
|
|
607
|
+
for (const a of artifacts)
|
|
608
|
+
HOST_ARTIFACTS.delete(a.id);
|
|
609
|
+
onTeardown?.();
|
|
610
|
+
},
|
|
611
|
+
tornDown() {
|
|
612
|
+
return torn;
|
|
613
|
+
},
|
|
614
|
+
// Present only for a seat-bearing floor — the invoker execs the chair into this container with
|
|
615
|
+
// the workspace as cwd. Absent = the seat runs on the host (the production room image).
|
|
616
|
+
...(seat ? { seat } : {}),
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
async function sweep(opts) {
|
|
620
|
+
const live = new Set(opts.liveGigs);
|
|
621
|
+
return [...HOST_ARTIFACTS.values()].filter((a) => !live.has(a.labels.gig_id));
|
|
622
|
+
}
|
|
623
|
+
/** The baseline. Runs seats as subprocesses of a host that holds the git binary, the remote and the
|
|
624
|
+
* network — so it CANNOT withhold capabilities and CANNOT enforce `doors` at a network boundary,
|
|
625
|
+
* and it claims neither. src/workspace.ts:44-56 is already candid that process-level protection is
|
|
626
|
+
* not a security control and must not be described as one, so claiming those guarantees would be a
|
|
627
|
+
* false contract a caller would rely on. Must work on a host with no container runtime and no
|
|
628
|
+
* daemon installed. */
|
|
629
|
+
export function localProcessRealizer() {
|
|
630
|
+
const substrate = "local-process";
|
|
631
|
+
return {
|
|
632
|
+
substrate,
|
|
633
|
+
// Claims ONLY what a subprocess of the host can actually keep: the tool surface is reproducible
|
|
634
|
+
// from the contract regardless of substrate. NOT withholds_capabilities, NOT network_policy_doors.
|
|
635
|
+
guarantees: ["reproducible_tool_surface"],
|
|
636
|
+
available: () => true, // the baseline may not require a daemon
|
|
637
|
+
retention: { max_cached_build_artifacts: 32, max_unreferenced_environments: 8, cadence: "PT30M" },
|
|
638
|
+
async realize(venue, _credentialResolver, opts) {
|
|
639
|
+
const v = VenueSchema.parse(venue);
|
|
640
|
+
if (v.substrate && v.substrate !== substrate) {
|
|
641
|
+
// Refuse a venue that names a substrate this realizer is not — before probing anything.
|
|
642
|
+
throw new VenueSubstrateUnavailable(v.substrate, [substrate]);
|
|
643
|
+
}
|
|
644
|
+
const configs = await buildMcpConfigs(v, opts);
|
|
645
|
+
const artifacts = [
|
|
646
|
+
{ kind: "local-process-group", id: `lpg-${opts.gigId}-${ARTIFACT_SEQ++}`, labels: { gig_id: opts.gigId, instance: INSTANCE } },
|
|
647
|
+
];
|
|
648
|
+
return makeHandle("PLAYING", configs, `${opts.gigId}.local`, artifacts);
|
|
649
|
+
},
|
|
650
|
+
sweep,
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
/** The containerized realizer. Names a real boundary and may claim the guarantees it provides. MUST
|
|
654
|
+
* NOT throw on construction — the out-of-scope laws call `.realize()` with mocks, and a construction
|
|
655
|
+
* throw would contaminate the seam family's shared setup. Host suitability (architecture, device
|
|
656
|
+
* classes) and the concurrency ceiling are answered BEFORE probing anything. */
|
|
657
|
+
export function dockerComposeRealizer(opts) {
|
|
658
|
+
// THE DEFAULT IS THE REAL BINARY. The seam exists so the emission and refusal laws — which are
|
|
659
|
+
// about what the realizer DECIDES, not about docker — can run on a host with no daemon, which is
|
|
660
|
+
// every CI runner. It is deliberately NOT a "skip the container" switch: a caller that wants a
|
|
661
|
+
// room and passes nothing gets a room. The one law that must distinguish a described room from a
|
|
662
|
+
// standing one (tests/spec_venue_room_live.test.ts) takes this default and runs the emitted
|
|
663
|
+
// transport verbatim, so a fake here cannot buy a false claim of liveness there.
|
|
664
|
+
const run = opts?.run ??
|
|
665
|
+
((args, timeout) => {
|
|
666
|
+
execFileSync("docker", [...args], { stdio: "pipe", timeout });
|
|
667
|
+
});
|
|
668
|
+
// THE ROOM POPULATES ITS TREE THROUGH THE DRAIN'S OWN FUNCTION. Defaults to the imported
|
|
669
|
+
// `prepareWorkspace` — literally the function worker.ts calls at its own clone site — so there is
|
|
670
|
+
// one clone/credential/cleanup mechanism, never a second the room could drift from. Injectable only
|
|
671
|
+
// for observation in a law (mirrors the `run` seam); production always gets the real shared function.
|
|
672
|
+
const prepare = opts?.prepareWorkspace ?? prepareWorkspace;
|
|
673
|
+
const substrate = "container";
|
|
674
|
+
return {
|
|
675
|
+
substrate,
|
|
676
|
+
// THESE ARE SCOPED TO WHAT IS IN THE ROOM — AND FOR A SEAT-BEARING FLOOR, THE SEAT IS NOW IN IT.
|
|
677
|
+
//
|
|
678
|
+
// This realizer implements server-INSIDE always, and chair-INSIDE when the venue selects a `floor`
|
|
679
|
+
// image (Dockerfile.floor) that carries the toolchain and the agent binary. On a floored room the
|
|
680
|
+
// handle carries `seat = { container, workspace }` and the invoker runs the chair as
|
|
681
|
+
// `docker exec -i -w <workspace> <container> claude …`, so the seat's cwd IS the per-realization
|
|
682
|
+
// workspace and two concurrent gigs cannot share a working tree — the isolation the earlier
|
|
683
|
+
// hand-rolled git worktrees were reaching for, now by construction and reclaimed by the ephemeral
|
|
684
|
+
// lifecycle rather than an operator. Verified against a live container — the room sees no runtime
|
|
685
|
+
// socket, is not privileged, holds no added capabilities, is not on the host network or PID
|
|
686
|
+
// namespace, and its only host path is its own workspace mount.
|
|
687
|
+
//
|
|
688
|
+
// Where each claim now stands:
|
|
689
|
+
// withholds_capabilities — the in-room seat is still narrowed by `agent.allowed_tools ∩
|
|
690
|
+
// venue.equipment.tools` (computed BEFORE the spawn is relocated), so
|
|
691
|
+
// moving it into the room cannot widen it; the room is the ceiling.
|
|
692
|
+
// isolated_filesystem — the seat works in the room's own per-gig workspace, not the host tree
|
|
693
|
+
// network_policy_doors — the network is blanket-`internal`, so `doors.egress` is DECLARED but
|
|
694
|
+
// NOT enforced at a network boundary in v0: the room is egress-less and
|
|
695
|
+
// the change-set is sealed as an OUTPUT for a later effector to push, so
|
|
696
|
+
// no door is needed here. Denying everything is wrong in the safe
|
|
697
|
+
// direction, and this is stated honestly rather than claimed as doors.
|
|
698
|
+
// reproducible_tool_surface — the tool SURFACE is reproducible from the contract; the image pin
|
|
699
|
+
// and `installs` application remain a separate lane.
|
|
700
|
+
// per_chair_isolation — one room per gig; per-chair rooms remain future work.
|
|
701
|
+
//
|
|
702
|
+
// The seat-bearing path activates only when a floor is named — an unfloored room keeps the chair on
|
|
703
|
+
// the host, so this is additive and the host-spawn behaviour is unchanged where no floor is set.
|
|
704
|
+
guarantees: [
|
|
705
|
+
"withholds_capabilities",
|
|
706
|
+
"isolated_filesystem",
|
|
707
|
+
"network_policy_doors",
|
|
708
|
+
"reproducible_tool_surface",
|
|
709
|
+
"per_chair_isolation",
|
|
710
|
+
],
|
|
711
|
+
// Best-effort: a container runtime socket present on this host. Not consulted by selectRealizer
|
|
712
|
+
// in the P1 laws (they inject availability), but honest for a deployment that does.
|
|
713
|
+
available: () => existsSync("/var/run/docker.sock"),
|
|
714
|
+
retention: { max_cached_build_artifacts: 16, max_unreferenced_environments: 8, cadence: "PT15M" },
|
|
715
|
+
async realize(venue, credentialResolver, opts) {
|
|
716
|
+
const v = VenueSchema.parse(venue);
|
|
717
|
+
if (v.substrate && v.substrate !== substrate) {
|
|
718
|
+
throw new VenueSubstrateUnavailable(v.substrate, [substrate]);
|
|
719
|
+
}
|
|
720
|
+
const host = opts.host;
|
|
721
|
+
// Architecture: knowable at construction, so answered at construction rather than as a
|
|
722
|
+
// confusing run-time failure on someone else's machine. Absent means any.
|
|
723
|
+
if (v.architectures.length > 0 && host && !v.architectures.includes(host.architecture)) {
|
|
724
|
+
throw new VenueHostUnsuitable(`venue "${v.slug}" supports [${v.architectures.join(", ")}], but host is "${host.architecture}"`);
|
|
725
|
+
}
|
|
726
|
+
// Device classes: a class the host does not provide is a refusal, not a silent omission.
|
|
727
|
+
for (const cls of v.devices) {
|
|
728
|
+
if (!host || !host.devices[cls]) {
|
|
729
|
+
throw new VenueHostUnsuitable(`venue "${v.slug}" needs device class "${cls}", which this host does not provide`);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
// Concurrency ceiling: a phase wider than the room may hold is refused, not quietly served.
|
|
733
|
+
if (typeof v.max_concurrent_chairs === "number" && typeof opts.chairs === "number" && opts.chairs > v.max_concurrent_chairs) {
|
|
734
|
+
throw new VenueConcurrencyRefused(`venue "${v.slug}" holds ${v.max_concurrent_chairs} chair(s); a phase of ${opts.chairs} exceeds it`);
|
|
735
|
+
}
|
|
736
|
+
// A remote realization obtains an administrative host credential PER GIG, against a live lease,
|
|
737
|
+
// never at boot — the discipline src/workspace.ts:60-89 documents for the git credential. A
|
|
738
|
+
// local realization asks for none.
|
|
739
|
+
if (host?.endpoint && host.credential) {
|
|
740
|
+
await host.credential({ gigId: opts.gigId });
|
|
741
|
+
}
|
|
742
|
+
// Bind credentials only through the resolver, from names ⊆ surface. Never inherit host env.
|
|
743
|
+
const requested = v.mcp_servers.flatMap((s) => s.credential_names).filter((n) => v.credential_surface.includes(n));
|
|
744
|
+
const material = await credentialResolver(requested);
|
|
745
|
+
// ── THE ROOM IS ACTUALLY STOOD UP ────────────────────────────────────────────────────────
|
|
746
|
+
//
|
|
747
|
+
// What used to be here: render the document for its refusal side-effects, THROW IT AWAY,
|
|
748
|
+
// return PLAYING, and hand back a transport naming `coltrane-gig-<id8>-room-1` — a container
|
|
749
|
+
// nothing had created. `docker exec` into it fails with "No such file or directory". Forty
|
|
750
|
+
// laws asserted on that emitted string and all of them passed, because reading a config is
|
|
751
|
+
// not running it. The same lesson as the credential-secret note above, one layer up.
|
|
752
|
+
//
|
|
753
|
+
// The realization directory is a REAL host path, because the document's every path derives
|
|
754
|
+
// from it and something must now write files there. Its last segment is what names the
|
|
755
|
+
// compose project, and both the project and the container come from composeProjectName.
|
|
756
|
+
const realizationDir = join(tmpdir(), "coltrane-realizations", `gig-${opts.gigId.slice(0, 8)}`);
|
|
757
|
+
const doc = renderComposeConfig(v, {
|
|
758
|
+
gigId: opts.gigId,
|
|
759
|
+
realizationDir,
|
|
760
|
+
...(host ? { host } : {}),
|
|
761
|
+
});
|
|
762
|
+
// ── THE WORKSPACE IS POPULATED, OR DECLINED — NEVER INFERRED. ──────────────────────────────
|
|
763
|
+
//
|
|
764
|
+
// Before this join the workspace was an empty `mkdirSync` and nothing else — enough for a
|
|
765
|
+
// read-only seat (room-prober browses the genome, touches no tree), useless for any seat that
|
|
766
|
+
// edits code. The other half already existed in the drain: worker.ts clones a FRESH TREE PER GIG
|
|
767
|
+
// via prepareWorkspace and runs with cwd inside it. This joins them — a realized room's
|
|
768
|
+
// workspace is populated the SAME way the drain populates its own, so a seat running in a room
|
|
769
|
+
// has a repository to edit and two concurrent code-editing gigs get DISJOINT trees.
|
|
770
|
+
//
|
|
771
|
+
// The source is `opts.repoUrl` — the repository named by the RUN (RunDeps.repoUrl: an explicit
|
|
772
|
+
// dispatch field or the claim's governed `repo_url`), supplied by the caller — and NOTHING else.
|
|
773
|
+
// It is never a venue field, never process.cwd() and never an ambient host path: inferring the
|
|
774
|
+
// operator's own checkout is precisely the isolation failure this whole change exists to
|
|
775
|
+
// prevent, so an absent source declines to populate (empties the room, mints no credential)
|
|
776
|
+
// rather than reaching for a default. `prepare` IS the drain's prepareWorkspace (one mechanism),
|
|
777
|
+
// pointed at THIS room's workspace as its clone target; the realizer's own teardown rmSync's
|
|
778
|
+
// realizationDir, so the clone under it needs no separate cleanup, and the credential rides
|
|
779
|
+
// cloneInto's GIT_CONFIG_* env, never a host file.
|
|
780
|
+
mkdirSync(realizationDir, { recursive: true });
|
|
781
|
+
let workspace = null;
|
|
782
|
+
try {
|
|
783
|
+
if (opts.repoUrl) {
|
|
784
|
+
workspace = await prepare({
|
|
785
|
+
repoUrl: opts.repoUrl,
|
|
786
|
+
gigId: opts.gigId,
|
|
787
|
+
drainKey: opts.drainKey,
|
|
788
|
+
instance: opts.instance,
|
|
789
|
+
endpoint: opts.gitCredentialsEndpoint,
|
|
790
|
+
target: join(realizationDir, "workspace"),
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
else {
|
|
794
|
+
mkdirSync(join(realizationDir, "workspace"), { recursive: true });
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
catch (e) {
|
|
798
|
+
// A population failure names its SOURCE (prepareWorkspace/cloneInto say which repo failed) and
|
|
799
|
+
// must not strand the realization directory. It is re-thrown, not swallowed into an ambient
|
|
800
|
+
// fallback — declining silently to the host cwd is the one thing this branch may never do.
|
|
801
|
+
rmSync(realizationDir, { recursive: true, force: true });
|
|
802
|
+
throw e;
|
|
803
|
+
}
|
|
804
|
+
// NO host secrets directory and no per-class file on the host. The former mechanism wrote each
|
|
805
|
+
// resolved credential to <realizationDir>/secrets/<class> at mode 0600 and declared it as a
|
|
806
|
+
// compose file-secret — but a compose file-secret is a BIND MOUNT, not a copy. Measured: stand
|
|
807
|
+
// the room up, read /run/secrets/<class> inside it, delete the host file, read again — the read
|
|
808
|
+
// fails IMMEDIATELY. So the material had to remain on the host for the room's ENTIRE life, where
|
|
809
|
+
// a seat runs as the invoking user, holds Bash, and can derive the path from the gig id: the
|
|
810
|
+
// filesystem readable exactly what withoutBoxCredentials strips from the environment. And a
|
|
811
|
+
// killed worker runs no teardown, so the 0600 file then persisted under the host tmpdir
|
|
812
|
+
// indefinitely. The material is instead copied straight into the container's own filesystem
|
|
813
|
+
// below (docker cp into the created-but-not-started room), so nothing readable from the host
|
|
814
|
+
// holds it while the room runs, and nothing is left behind if this worker is killed.
|
|
815
|
+
const composePath = join(realizationDir, "compose.yaml");
|
|
816
|
+
// JSON is YAML, so the rendered document is written verbatim — the thing the laws inspect is
|
|
817
|
+
// byte-for-byte the thing compose runs. No second serialization to drift from the first.
|
|
818
|
+
writeFileSync(composePath, JSON.stringify(doc, null, 2));
|
|
819
|
+
const roomContainer = roomContainerName(realizationDir);
|
|
820
|
+
try {
|
|
821
|
+
// CREATE → DELIVER → START, three steps where there used to be one `up -d`: the credential
|
|
822
|
+
// has to land AFTER the container filesystem exists and BEFORE the room's processes run. A
|
|
823
|
+
// `docker cp` into a CREATED-but-not-yet-STARTED container is a real copy into the container's
|
|
824
|
+
// own filesystem, unlike the bind mount a compose file-secret would have been.
|
|
825
|
+
run(["compose", "-f", composePath, "create"], 180_000);
|
|
826
|
+
// The room reads the whole surface it was granted; each server reads only the classes it
|
|
827
|
+
// declared. Both are copied straight into the container filesystem at /run/secrets/<class>.
|
|
828
|
+
deliverCredentialFiles(run, roomContainer, v.credential_surface, material);
|
|
829
|
+
for (const s of v.mcp_servers) {
|
|
830
|
+
deliverCredentialFiles(run, serverContainerName(realizationDir, s.slug), s.credential_names, material);
|
|
831
|
+
}
|
|
832
|
+
run(["compose", "-f", composePath, "start"], 120_000);
|
|
833
|
+
}
|
|
834
|
+
catch (e) {
|
|
835
|
+
// The new intermediate state: a container CREATED but never STARTED (a failed cp or start).
|
|
836
|
+
// Remove it so the create→start split cannot turn a stand-up failure into an orphaned
|
|
837
|
+
// container or network. `down` is best-effort — the stand-up already failed — and the throw
|
|
838
|
+
// below is the report the caller acts on.
|
|
839
|
+
try {
|
|
840
|
+
run(["compose", "-f", composePath, "down", "-v", "--remove-orphans"], 120_000);
|
|
841
|
+
}
|
|
842
|
+
catch {
|
|
843
|
+
/* best-effort: the reap is a courtesy on an already-failed stand-up, not a guarantee */
|
|
844
|
+
}
|
|
845
|
+
// A stand-up that fails AFTER population already minted a git credential must not leak it — hand
|
|
846
|
+
// it back the same fire-and-forget way teardown does, so a populate token never outlives the
|
|
847
|
+
// room even when the room never fully stood up. Null when no repo_url was declared.
|
|
848
|
+
void workspace?.revoke();
|
|
849
|
+
rmSync(realizationDir, { recursive: true, force: true });
|
|
850
|
+
const err = e;
|
|
851
|
+
throw new VenueHostUnsuitable(`venue "${v.slug}" could not be stood up: ${err.stderr?.toString().trim() ?? String(e)}`);
|
|
852
|
+
}
|
|
853
|
+
const configs = await buildMcpConfigs(v, opts, roomContainer);
|
|
854
|
+
const artifacts = [
|
|
855
|
+
{ kind: "compose-project", id: `compose-${opts.gigId}-${ARTIFACT_SEQ++}`, labels: { gig_id: opts.gigId, instance: INSTANCE } },
|
|
856
|
+
{ kind: "compose-network", id: `net-${opts.gigId}-${ARTIFACT_SEQ++}`, labels: { gig_id: opts.gigId, instance: INSTANCE } },
|
|
857
|
+
];
|
|
858
|
+
// SEAT-BEARING ONLY. A `floor` selects a toolchain-carrying image (Dockerfile.floor) that holds
|
|
859
|
+
// the agent binary; the default `coltrane/room:ephemeral` (Dockerfile.room) deliberately does
|
|
860
|
+
// not, so a seat can only run inside a floored room. When the venue names a floor, expose the
|
|
861
|
+
// room's own container + the per-realization workspace (`working_dir` of the room service, so
|
|
862
|
+
// the seat's cwd IS this room's tree). The invoker reads this to `docker exec` the chair into
|
|
863
|
+
// the room. Absent → the chair stays on the host, exactly as before this wire existed.
|
|
864
|
+
const seat = v.floor
|
|
865
|
+
? { container: roomContainer, workspace: join(realizationDir, "workspace") }
|
|
866
|
+
: undefined;
|
|
867
|
+
return makeHandle("PLAYING", configs, composePath, artifacts, () => {
|
|
868
|
+
// An ephemeral room that outlives its gig is a leak, and `down -v` is what makes the
|
|
869
|
+
// lifecycle policy a fact rather than a field. --remove-orphans so a service removed from
|
|
870
|
+
// the contract mid-life does not survive as an unreferenced container.
|
|
871
|
+
try {
|
|
872
|
+
run(["compose", "-f", composePath, "down", "-v", "--remove-orphans"], 120_000);
|
|
873
|
+
}
|
|
874
|
+
catch (e) {
|
|
875
|
+
if (process.env["COLTRANE_DRAIN_DEBUG"])
|
|
876
|
+
console.error(`[venue] down failed: ${String(e)}`);
|
|
877
|
+
}
|
|
878
|
+
// Hand the git credential back the way the drain does (worker.ts `void workspace?.revoke()`):
|
|
879
|
+
// fire-and-forget, never awaited, never throwing — a populate token must not outlive the room,
|
|
880
|
+
// and a revoke failure must not fail teardown. Null when the room declared no repo_url (nothing
|
|
881
|
+
// was minted). The rmSync below then subsumes the clone under realizationDir/workspace.
|
|
882
|
+
void workspace?.revoke();
|
|
883
|
+
rmSync(realizationDir, { recursive: true, force: true });
|
|
884
|
+
}, seat);
|
|
885
|
+
},
|
|
886
|
+
sweep,
|
|
887
|
+
};
|
|
888
|
+
}
|
|
889
|
+
//# sourceMappingURL=venue_realizer.js.map
|