@eir-labs/coltrane 0.10.0 → 0.12.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.
Files changed (144) hide show
  1. package/agents/bandleader.json +54 -0
  2. package/agents/bill.json +4 -2
  3. package/agents/change-verifier.json +2 -1
  4. package/agents/code-implementer.json +7 -4
  5. package/agents/john.json +5 -3
  6. package/agents/lineage-scout-external.json +19 -6
  7. package/agents/lineage-scribe.json +20 -9
  8. package/agents/lineage-weaver.json +26 -11
  9. package/agents/miles.json +2 -1
  10. package/agents/players/bandleader.md +70 -0
  11. package/agents/pr-publisher.json +4 -2
  12. package/agents/red-spec-drafter.json +12 -6
  13. package/agents/room-prober.json +29 -0
  14. package/agents/source-walker.json +1 -1
  15. package/charts/room-probe-chart-v1.json +14 -0
  16. package/charts/worker-contract-docker-v2.json +135 -0
  17. package/charts/worker-contract-v3.json +206 -0
  18. package/dist/src/boundary_check.d.ts +18 -0
  19. package/dist/src/boundary_check.js +70 -0
  20. package/dist/src/boundary_check.js.map +1 -0
  21. package/dist/src/boundary_scan.d.ts +62 -0
  22. package/dist/src/boundary_scan.js +93 -0
  23. package/dist/src/boundary_scan.js.map +1 -0
  24. package/dist/src/chart.js +34 -1
  25. package/dist/src/chart.js.map +1 -1
  26. package/dist/src/claude_invoker.d.ts +2 -13
  27. package/dist/src/claude_invoker.js +242 -18
  28. package/dist/src/claude_invoker.js.map +1 -1
  29. package/dist/src/cli.d.ts +1 -1
  30. package/dist/src/cli.js +115 -27
  31. package/dist/src/cli.js.map +1 -1
  32. package/dist/src/drain_preflight.d.ts +33 -0
  33. package/dist/src/drain_preflight.js +97 -0
  34. package/dist/src/drain_preflight.js.map +1 -0
  35. package/dist/src/genome_schema.d.ts +390 -9
  36. package/dist/src/genome_schema.js +163 -8
  37. package/dist/src/genome_schema.js.map +1 -1
  38. package/dist/src/genome_store.js +9 -0
  39. package/dist/src/genome_store.js.map +1 -1
  40. package/dist/src/genome_writer.d.ts +1 -1
  41. package/dist/src/genome_writer.js +10 -2
  42. package/dist/src/genome_writer.js.map +1 -1
  43. package/dist/src/gig_conformance.d.ts +66 -0
  44. package/dist/src/gig_conformance.js +153 -0
  45. package/dist/src/gig_conformance.js.map +1 -0
  46. package/dist/src/gig_tracker.js +8 -0
  47. package/dist/src/gig_tracker.js.map +1 -1
  48. package/dist/src/grounding.d.ts +44 -0
  49. package/dist/src/grounding.js +127 -0
  50. package/dist/src/grounding.js.map +1 -0
  51. package/dist/src/index.d.ts +3 -0
  52. package/dist/src/index.js +9 -0
  53. package/dist/src/index.js.map +1 -1
  54. package/dist/src/institution_loader.d.ts +29 -5
  55. package/dist/src/institution_loader.js +67 -6
  56. package/dist/src/institution_loader.js.map +1 -1
  57. package/dist/src/lineage_adoption.d.ts +82 -0
  58. package/dist/src/lineage_adoption.js +82 -0
  59. package/dist/src/lineage_adoption.js.map +1 -0
  60. package/dist/src/lineage_persist.d.ts +34 -0
  61. package/dist/src/lineage_persist.js +60 -0
  62. package/dist/src/lineage_persist.js.map +1 -0
  63. package/dist/src/loader.d.ts +13 -0
  64. package/dist/src/loader.js +43 -2
  65. package/dist/src/loader.js.map +1 -1
  66. package/dist/src/local_queue.d.ts +105 -0
  67. package/dist/src/local_queue.js +380 -0
  68. package/dist/src/local_queue.js.map +1 -0
  69. package/dist/src/mcp.js +37 -4
  70. package/dist/src/mcp.js.map +1 -1
  71. package/dist/src/org_hire.d.ts +23 -0
  72. package/dist/src/org_hire.js +42 -0
  73. package/dist/src/org_hire.js.map +1 -0
  74. package/dist/src/outputs.d.ts +17 -0
  75. package/dist/src/outputs.js +86 -1
  76. package/dist/src/outputs.js.map +1 -1
  77. package/dist/src/player_to_claude_code.js +26 -2
  78. package/dist/src/player_to_claude_code.js.map +1 -1
  79. package/dist/src/registry.d.ts +3 -1
  80. package/dist/src/registry.js +43 -3
  81. package/dist/src/registry.js.map +1 -1
  82. package/dist/src/repo_index.d.ts +58 -0
  83. package/dist/src/repo_index.js +232 -0
  84. package/dist/src/repo_index.js.map +1 -0
  85. package/dist/src/residency.d.ts +149 -0
  86. package/dist/src/residency.js +283 -0
  87. package/dist/src/residency.js.map +1 -0
  88. package/dist/src/reuse.d.ts +44 -4
  89. package/dist/src/reuse.js +0 -0
  90. package/dist/src/reuse.js.map +1 -1
  91. package/dist/src/runtime.d.ts +82 -0
  92. package/dist/src/runtime.js +254 -25
  93. package/dist/src/runtime.js.map +1 -1
  94. package/dist/src/server.d.ts +37 -0
  95. package/dist/src/server.js +422 -11
  96. package/dist/src/server.js.map +1 -1
  97. package/dist/src/server_relay.d.ts +40 -2
  98. package/dist/src/server_relay.js +186 -19
  99. package/dist/src/server_relay.js.map +1 -1
  100. package/dist/src/tool_providers.d.ts +27 -0
  101. package/dist/src/tool_providers.js +48 -0
  102. package/dist/src/tool_providers.js.map +1 -1
  103. package/dist/src/venue_credential.d.ts +41 -0
  104. package/dist/src/venue_credential.js +57 -0
  105. package/dist/src/venue_credential.js.map +1 -0
  106. package/dist/src/venue_realize.d.ts +23 -0
  107. package/dist/src/venue_realize.js +43 -3
  108. package/dist/src/venue_realize.js.map +1 -1
  109. package/dist/src/venue_realizer.d.ts +224 -0
  110. package/dist/src/venue_realizer.js +889 -0
  111. package/dist/src/venue_realizer.js.map +1 -0
  112. package/dist/src/version.d.ts +1 -1
  113. package/dist/src/version.js +1 -1
  114. package/dist/src/worker.d.ts +57 -0
  115. package/dist/src/worker.js +155 -24
  116. package/dist/src/worker.js.map +1 -1
  117. package/dist/src/worker_env.d.ts +79 -0
  118. package/dist/src/worker_env.js +322 -0
  119. package/dist/src/worker_env.js.map +1 -0
  120. package/dist/src/workspace.d.ts +12 -1
  121. package/dist/src/workspace.js +10 -3
  122. package/dist/src/workspace.js.map +1 -1
  123. package/domain_types/change-context.json +59 -13
  124. package/domain_types/lineage-adoption-target.json +22 -0
  125. package/domain_types/lineage-map.json +7 -2
  126. package/domain_types/lineage-record.json +55 -5
  127. package/domain_types/prior-art-hit.json +16 -1
  128. package/domain_types/red-spec.json +5 -3
  129. package/domain_types/woodshed-record.json +149 -0
  130. package/evals/gist-present.json +7 -0
  131. package/institutions/coltrane.json +178 -6
  132. package/package.json +6 -3
  133. package/skills/non-vacuity-probe/fixtures/fixture-001.json +17 -0
  134. package/skills/non-vacuity-probe/meta.json +12 -0
  135. package/skills/non-vacuity-probe/skill.md +27 -0
  136. package/skills/non-vacuity-probe/skill.mjs +81 -0
  137. package/standards/lineage-adopt-v0.json +37 -0
  138. package/standards/room-probe-v1.json +28 -0
  139. package/standards/software-change-pr-v1.json +15 -15
  140. package/standards/software-change-red-first-v0.json +183 -0
  141. package/standards/spec-review-and-sequence-v0.json +112 -0
  142. package/standards/studio-session-v0.json +101 -0
  143. package/tours/coltrane.json +72 -0
  144. 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