Let a studio say which studio it is, and make the provisioner ask

The provisioner asked a port whether a studio was there and treated any answer as
its own. review-r1 measured that a server returning a body which is not a studio
contract satisfies that check, and that two genuine studios are indistinguishable
by contractVersion, runtime and capabilities. With a sibling already on the port
the run reused it, never started the studio it had just installed, and the
verifier then passed against the sibling's surfaces — wrong, and reported as
success.

manifest.consumer.dir already answered the question. It shipped, two tests
asserted it, and nothing in contract/ documented it, which by contract-meta's own
classification makes it runtime-internal and off-limits to exactly the caller that
needs it. So this promotes what already ships rather than adding a field.

WHY IT WAS UNDOCUMENTED IS THE MORE USEFUL FINDING. The /api/contract drift guard
compares Object.keys(documented.manifest) — top level only — against an example
whose consumer is null. Every nested block was unguarded, so a field could ship,
be tested, and stay unpromised without anything failing. The guard now descends
into that block against an overlay-configured runtime. Written first, confirmed
failing for the right reason, then made to pass; planting an extra field in the
served block makes it fail again.

The reuse decision moves into provision/studio-identity.mjs with three outcomes
rather than two, because "nothing is listening" and "someone else's studio is
listening" call for opposite actions. A foreign port is now fatal on every branch
and fatal BEFORE the verifier runs, since verifying against a stranger is the
failure this fixes.

On a systemd host an occupied port used to abort through run()'s eval AFTER the
install had fully succeeded — apps installed, studio.json rewritten, service down,
nothing reported. It now reports and continues to verify, and says the part that
is easy to leave out: the running studio was NOT restarted, so it keeps the rail
it composed at its own boot.

Tests cover the decision itself, including a ZERO-CONFIG studio — a real studio,
answering every field, that still cannot be this one — and a positive control that
a studio serving this run's directory is still reused. Refusing every hostile
input proves nothing on its own: an over-restrictive guard and a correct one
produce an identical row of refusals.

The shell wiring was exercised against four real servers on this host — a matching
studio, a sibling studio, r1's squatter, and an empty port — because a tested
module reached through untested wiring is where this class hides. The provisioner
end to end remains unrun here: it refuses non-Linux hosts by design.

Slice 04-studio-identity, plan-locked. 122 tests.
This commit is contained in:
mvschwarz committed 2026-08-03 23:16:29 -07:00
1 parent 22a1ae6ed6
commit 300b970b9e
6 files changed
+288 -7

No files matched your search

+26
View File
@@ -67,6 +67,32 @@ Two schemas sit beside them: `surface-row.schema.json` for a manifest row, and
`surfaces` is the count of valid rows currently served; it changes as the
manifest changes.
`manifest.consumer` is `null` above because that example is a zero-config
runtime. With an overlay configured, `manifest.consumer` is populated:
```json
{
"dir": "<the overlay directory this runtime resolved, absolute>",
"surfaces": 1,
"state": "ok",
"lastLoadedAt": "<ISO timestamp of the last successful load>",
"reloads": 1,
"recoveries": 0,
"lastRecoveryAt": null,
"integrityReloads": 0,
"lastIntegrityReloadAt": null
}
```
Field semantics are in `manifest.md`. The block is shown here as well as
described there because the shape is what a caller matches on, and a shape
documented only in prose is a shape nothing can check: this example is asserted
against a live overlay-configured runtime by the suite, so a field added to or
removed from that block fails a test rather than shipping unpromised. That is not
hypothetical — `dir` shipped, was asserted by two tests, and was documented
nowhere, because the drift guard compared only the TOP-LEVEL `manifest` keys
against an example whose `consumer` was `null`.
This endpoint is the first thing to read against a live runtime.
## Stability classes
+25 -4
View File
@@ -119,10 +119,31 @@ overlay configured, errors and warnings are prefixed with which manifest they
came from (`package` / `consumer`), because a bare `surfaces[0]` no longer says
enough. Without an overlay the messages are unchanged.
**Per-source health** appears at `GET /api/contract` under `manifest.consumer`
— its own `state`, `reloads`, `recoveries` and `integrityReloads`, with the same
meanings as the top-level fields (which describe the SDK's own manifest). It is
`null` when no overlay is configured.
**Per-source health** appears at `GET /api/contract` under `manifest.consumer`.
It is `null` when no overlay is configured; otherwise it carries, field by field:
| field | stability | meaning |
|---|---|---|
| `dir` | stable | the overlay directory this runtime resolved, absolute. **This is the field that tells one studio from another** — every runtime returns the same `runtime` block, so it is the only thing in this response that distinguishes two studios on one box. |
| `surfaces` | stable | count of valid rows currently served from the overlay |
| `state` | stable | `init` / `ok` / `invalid` / `unreadable`, for the overlay manifest |
| `lastLoadedAt` | stable | ISO timestamp of the last successful load, `null` until one happens |
| `reloads` | stable | how many times the overlay manifest has been read |
| `recoveries` | stable | transitions INTO health; booting healthy reports `0` |
| `lastRecoveryAt` | stable | ISO timestamp of the last recovery, `null` if never |
| `integrityReloads` | stable | reloads the watch did not cause — the file at the served path changed identity and no event fired. Non-zero means the fast path is not holding for this deployment shape |
| `lastIntegrityReloadAt` | stable | ISO timestamp of the last integrity reload, `null` if never |
Apart from `dir` and `surfaces`, these have the same meanings as the top-level
fields, which describe the SDK's own manifest.
**If you are checking whether a running studio is YOURS, match `dir`.** A
liveness check does not answer that question: any server can return 200 on
`/api/contract`, and two genuine studios on one box return identical
`contractVersion`, `runtime` and `capabilities`. A provisioner that reuses a
studio because something answered can adopt a sibling, never start the one it
installed, and then verify successfully against the wrong studio's surfaces —
wrong, and reported as success.
**Why the seam exists, not just how.** The registration path above is inside the
package. In a consumer install that is `node_modules/@openrig/studio/app/` —
+54 -3
View File
@@ -244,9 +244,60 @@ fi
# persistence is NOT configured rather than letting a green run imply it is.
HAVE_SYSTEMD=0
command -v systemctl >/dev/null 2>&1 && HAVE_SYSTEMD=1
# WHO OWNS THE PORT — asked once, used by both branches below.
#
# Three outcomes, not two, because "nothing is listening" and "someone else's
# studio is listening" call for OPPOSITE actions and collapsing them is how a
# stranger's studio gets adopted. The old check asked only whether something
# answered /api/contract: a server returning a body that is not a studio
# satisfied it, and two genuine studios on a box are indistinguishable by
# contractVersion/runtime/capabilities alone. The discriminator is
# manifest.consumer.dir — documented contract as of this slice, not a field that
# happened to be there. The decision itself lives in studio-identity.mjs so its
# cases are testable without standing up two studios.
IDENT_MJS="$(cd "$(dirname "$0")" 2>/dev/null && pwd)/studio-identity.mjs"
[ -f "$IDENT_MJS" ] || IDENT_MJS="$STUDIO_DIR/sdk/provision/studio-identity.mjs"
OVERLAY_DIR="$STUDIO_DIR/.runtime/surfaces"
PORT_STATE=nothing # nothing | ours | foreign
PORT_WHY=""
if [ "$DRY_RUN" = 0 ]; then
if PORT_BODY="$(curl -fsS "http://127.0.0.1:${STUDIO_PORT}/api/contract" 2>/dev/null)"; then
if PORT_WHY="$(printf '%s' "$PORT_BODY" | "$NODE_BIN" "$IDENT_MJS" "$OVERLAY_DIR")"; then
PORT_STATE=ours
else
PORT_STATE=foreign
fi
fi
fi
# A port held by something that is not this studio is fatal on EVERY branch, and
# it has to be fatal BEFORE the verifier runs. Reusing it would hand a green run
# to the wrong studio; launching into it dies on EADDRINUSE; and verifying
# against it passes on somebody else's surfaces. Refuse by name instead.
if [ "$PORT_STATE" = foreign ]; then
warn "PORT ${STUDIO_PORT} IS NOT THIS STUDIO: ${PORT_WHY}"
warn "Refusing to reuse it, launch into it, or verify against it. The install is complete;"
warn "free the port or set STUDIO_PORT, then re-run — this run stops here deliberately."
exit 5
fi
if [ "$HAVE_SYSTEMD" = 1 ]; then
run "systemctl --user daemon-reload"
run "systemctl --user enable --now openrig-studio.service"
# An occupied port used to abort the whole run HERE, through run()'s bare eval,
# AFTER the install had fully succeeded: apps installed, studio.json rewritten,
# service down, nothing reported. Throwing that away over the way a studio gets
# restarted helps nobody, so the conflict is reported and verification still
# happens. NOTE WHAT IS NOT CLAIMED: the running studio was NOT restarted, so
# it is serving the rail it composed at its own boot, not necessarily this
# run's studio.json.
if [ "$PORT_STATE" = ours ]; then
warn "port ${STUDIO_PORT} already served by this studio — NOT restarting it."
warn "The running process keeps the rail it composed at ITS boot; this run's studio.json"
warn "takes effect on the next restart. Continuing to verify what is actually running."
else
run "systemctl --user enable --now openrig-studio.service"
fi
else
warn "NO INIT SYSTEM on this host (no systemctl). The studio will NOT survive a reboot."
warn "Starting it directly so the box is usable; persistence is NOT configured."
@@ -254,8 +305,8 @@ else
# Reuse what is already there. A blind relaunch dies on EADDRINUSE while the
# survivor reloads the manifest this run just replaced, so the verifier below
# races a reload it caused and reports a false FAIL on a healthy box.
if curl -fsS "http://127.0.0.1:${STUDIO_PORT}/api/contract" >/dev/null 2>&1; then
printf ' studio already serving on %s — reusing it\n' "$STUDIO_PORT"
if [ "$PORT_STATE" = ours ]; then
printf ' %s\n' "$PORT_WHY"
else
( cd "$STUDIO_DIR/sdk" && OPENRIG_STUDIO_DIR="$STUDIO_DIR" \
nohup "$NODE_BIN" tools/studio.mjs --port "$STUDIO_PORT" > "$STUDIO_DIR/studio.log" 2>&1 & )
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env node
// Decide whether a studio already answering on a port is THIS studio.
//
// The provisioner used to ask only whether something answered `/api/contract`
// and treat any success as "mine". review-r1 measured that a server returning a
// body which is not a studio contract at all satisfies that check, and read that
// every genuine studio on a box returns the same `contractVersion`, `runtime` and
// `capabilities` — so there was nothing to tell two studios apart.
//
// The failure that produces is not a broken install, it is a GREEN one: with a
// sibling studio already on the port, the provisioner reuses it, never starts the
// studio it just installed, and the verifier then passes against the sibling's
// surfaces. Wrong, and reported as success.
//
// `manifest.consumer.dir` is the discriminator, and as of this slice it is
// documented contract rather than a field that happened to be there
// (contract/manifest.md). This module is the whole decision, kept pure so the
// cases below can be tested without standing up two studios.
// Three outcomes, deliberately not two: "nothing is listening" and "something is
// listening and it is not mine" call for opposite actions, and collapsing them is
// how a stranger's studio gets adopted.
export const REUSE = "reuse";
export const NOT_MINE = "not-mine";
export function reuseDecision({ body, expectedDir }) {
let doc;
try {
doc = JSON.parse(body);
} catch {
return {
outcome: NOT_MINE,
reason: "something is serving this port but /api/contract did not return JSON — not a studio",
};
}
if (!doc || typeof doc !== "object" || typeof doc.contractVersion !== "string" || !doc.manifest) {
return {
outcome: NOT_MINE,
reason: "something is serving this port and answering /api/contract, but the response carries " +
"no studio contract (no contractVersion / manifest) — not a studio",
};
}
const dir = doc.manifest.consumer?.dir;
if (typeof dir !== "string") {
return {
outcome: NOT_MINE,
reason: "a studio is serving this port, but with no overlay configured (manifest.consumer is " +
"null), so it cannot be the studio this run installed",
};
}
if (dir !== expectedDir) {
// Name BOTH. A refusal that says only "wrong studio" sends an operator
// looking for the wrong thing — and the likeliest cause is not a sibling at
// all, it is the same studio reached by a different path spelling.
return {
outcome: NOT_MINE,
reason: `a DIFFERENT studio is serving this port: it serves ${dir}, this run installed ${expectedDir}`,
};
}
return { outcome: REUSE, reason: `studio already serving ${dir} — reusing it` };
}
// CLI: body on stdin, expected dir as argv. Exit 0 reuse, 3 not-mine, and the
// reason on stdout either way so the run can print it.
if (process.argv[1] && import.meta.url.endsWith(process.argv[1].split("/").pop())) {
const chunks = [];
for await (const c of process.stdin) chunks.push(c);
const { outcome, reason } = reuseDecision({
body: Buffer.concat(chunks).toString("utf8"),
expectedDir: process.argv[2],
});
process.stdout.write(reason + "\n");
process.exit(outcome === REUSE ? 0 : 3);
}
+34
View File
@@ -535,6 +535,40 @@ test("with an overlay that declares NO seats, the report says the roster is the
assert.equal(report.attachable, false, "the package fixture declares no chatLocalPort");
});
test("the documented consumer block lists exactly the fields an overlay-configured runtime serves", async (t) => {
// THE GUARD HOLE THIS CLOSES. runtime-redeploy.test.mjs already pins the
// documented /api/contract example against the live response — but it compares
// `Object.keys(documented.manifest)`, TOP LEVEL ONLY, against an example whose
// `consumer` is null. So every field inside that block has always been
// unguarded, which is exactly how `dir` came to ship, be asserted by two tests
// in this very file, and be documented nowhere in contract/.
//
// Fixing only `dir` would fix the instance and leave the mechanism. This
// descends, so the NEXT field added to that block fails the suite instead of
// shipping unpromised.
//
// It needs an overlay-configured runtime: with none, `consumer` is null and
// there is nothing to compare, which is the blind spot itself.
const { dir, consumer } = scaffold();
const srv = await start(dir, ["--surfaces", consumer]);
t.after(() => srv.stop());
const doc = fs.readFileSync(path.join(REPO, "contract", "contract-meta.md"), "utf8");
const block = doc.match(/With an overlay configured, `manifest\.consumer` is populated:\n\n```json\n([\s\S]*?)\n```/);
assert.ok(block,
"contract-meta.md carries no populated `manifest.consumer` example, so the shape of that " +
"block is documented nowhere and nothing can guard it");
const documented = JSON.parse(block[1]);
const live = (await srv.contract()).consumer;
assert.ok(live, "positive control: the overlay-configured runtime must actually report a consumer block");
assert.deepEqual(
Object.keys(documented).sort(),
Object.keys(live).sort(),
"the documented consumer example and the served consumer block have diverged"
);
});
test("positive control: this suite is capable of failing", () => {
assert.throws(() => assert.equal(1, 2));
});
+75
View File
@@ -0,0 +1,75 @@
// Controls for the provisioner's reuse decision.
//
// node --test 'test/*.test.mjs'
//
// Slice 04's proof contract, items 3-5. The cases are review-r1's, and the last
// one is the reason the others are not enough: an over-restrictive guard and a
// correct one produce an identical row of refusals, so refusing every hostile
// input proves nothing on its own. The positive case is what separates "checks
// identity" from "never reuses anything".
import { test } from "node:test";
import assert from "node:assert/strict";
import { reuseDecision, REUSE, NOT_MINE } from "../provision/studio-identity.mjs";
const MINE = "/home/op/studio/.runtime/surfaces";
const studio = (dir) => JSON.stringify({
contractVersion: "0.1",
runtime: { name: "openrig-studio", flavor: "reference-fixture" },
capabilities: ["contract.meta", "observe.factory-state", "stream.events", "files.read", "shell.protocol"],
manifest: {
ok: true, errors: [], warnings: [], surfaces: 1,
consumer: dir === null ? null : { dir, surfaces: 1, state: "ok" },
},
});
test("a studio serving THIS run's directory is reused", () => {
// THE POSITIVE CONTROL, and it is the load-bearing one. Every other case here
// refuses; a guard that refused unconditionally would pass all of them and
// ship a provisioner that can never reuse anything.
const d = reuseDecision({ body: studio(MINE), expectedDir: MINE });
assert.equal(d.outcome, REUSE, d.reason);
assert.match(d.reason, /reusing it/);
});
test("a studio serving a DIFFERENT directory is refused, and both paths are named", () => {
// r1's sibling-studio case: the one that produced a green run against the
// wrong studio's surfaces.
const theirs = "/home/op/other-studio/.runtime/surfaces";
const d = reuseDecision({ body: studio(theirs), expectedDir: MINE });
assert.equal(d.outcome, NOT_MINE);
assert.ok(d.reason.includes(theirs) && d.reason.includes(MINE),
`the refusal must name what it found AND what it wanted: ${d.reason}`);
});
test("r1's squatter — 200 on /api/contract with a body that is not a studio — is refused", () => {
const d = reuseDecision({ body: JSON.stringify({ totally: "not a studio" }), expectedDir: MINE });
assert.equal(d.outcome, NOT_MINE);
assert.match(d.reason, /no studio contract/);
});
test("a server answering with something that is not JSON at all is refused", () => {
const d = reuseDecision({ body: "<html>hello</html>", expectedDir: MINE });
assert.equal(d.outcome, NOT_MINE);
assert.match(d.reason, /did not return JSON/);
});
test("a ZERO-CONFIG studio is refused — it is a studio, but it cannot be this one", () => {
// The discriminating middle case. A zero-config studio is genuinely a studio
// and answers every field r1 named, so a check that looked only for "is this a
// studio" would adopt it. It serves no overlay, so it cannot be the studio
// this run installed.
const d = reuseDecision({ body: studio(null), expectedDir: MINE });
assert.equal(d.outcome, NOT_MINE);
assert.match(d.reason, /no overlay configured/);
});
test("a contract response missing manifest entirely is refused, not crashed on", () => {
const d = reuseDecision({ body: JSON.stringify({ contractVersion: "0.1" }), expectedDir: MINE });
assert.equal(d.outcome, NOT_MINE);
});
test("positive control: this suite is capable of failing", () => {
assert.throws(() => assert.equal(1, 2));
});