mirror of
https://github.com/mvschwarz/openrig-studio.git
synced 2026-11-07 13:56:41 -10:00
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:
1 parent
22a1ae6ed6
commit
300b970b9e
6 files changed
+288
-7
No files matched your search
@@ -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
@@ -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/` —
|
||||
|
||||
@@ -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 & )
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
Reference in new issue
Block a user