diff --git a/CATALOG.md b/CATALOG.md index 233e7a6..3d9f403 100644 --- a/CATALOG.md +++ b/CATALOG.md @@ -1,7 +1,7 @@ # Current eval catalog and evidence -This is the sole current corpus surface: **58 included cells** (18 model-free, +This is the sole current corpus surface: **59 included cells** (19 model-free, 40 model-backed) and **4 retired exclusions**. Inventory fields are derived from the active KDL and executable judge scripts. Latest accepted PASS evidence remains distinct from the append-only run history: a failed last run is visible without being advertised as accepted evidence, and cells with no @@ -50,6 +50,7 @@ harness-native loader plus canonical hook file. | `presence-ding-matrix` | model-free | — | 0 | none | `60s` | 6 | — | **NO STRUCTURED RUN** | | `pty-attach-machine-stream` | model-free | — | 0 | none | `90s` | 5 | — | **NO STRUCTURED RUN** | | `pty-attach-only` | model-free | — | 0 | none | `90s` | 7 | — | **NO STRUCTURED RUN** | +| `pty-attach-outcomes-and-roles` | model-free | — | 0 | none | `60s` | 5 | — | **NO STRUCTURED RUN** | | `pty-send-peek` | model-free | — | 0 | none | `120s` | 5 | **PASS** 2026-07-28T19:53:59Z, 13/13, source [`b7da882`](https://github.com/compoundingtech/evals/commit/b7da8826b503e0408e309af8702fb684d28e3a19), [receipt](evidence/stable-main-phase4-runs-20260728.json) | **PASS** 2026-07-28T19:53:59Z, 13/13, source [`b7da882`](https://github.com/compoundingtech/evals/commit/b7da8826b503e0408e309af8702fb684d28e3a19), [receipt](evidence/stable-main-phase4-runs-20260728.json) | | `reconcile-retire-keep` | model-free | — | 0 | none | `90s` | 6 | — | **NO STRUCTURED RUN** | | `render-target-safety` | model-free | — | 0 | none | `90s` | 5 | — | **NO STRUCTURED RUN** | @@ -102,6 +103,7 @@ while the last-run column makes a recorded failure distinct from a cell with no - `hook-integrity` / `cell` — deterministic native hook-installation probe; no live harness seat - `host-lock-health-negatives` / `cell` — deterministic native doctor negative probe; no harness seat - `pty-attach-machine-stream` / `cell` — deterministic installed PTY attach composition probe; no harness seat +- `pty-attach-outcomes-and-roles` / `cell` — deterministic installed PTY outcome and raw-socket role-transition probe; no harness seat - `pty-attach-only` / `cell` — deterministic PTY dead-attach policy probe; no harness seat - `pty-send-peek` / `cell` — deterministic PTY transport probe; no harness seat - `presence-ding-matrix` / `cell` — deterministic native presence and DING probe; no harness seat @@ -114,7 +116,7 @@ while the last-run column makes a recorded failure distinct from a cell with no - `targeted-reconcile-isolation` / `cell` — deterministic selected reconcile isolation probe; no bus-connected agent - `two-networks-coexist` / `cell` — deterministic catalog-isolation probe; no harness seat -These **19** rows are generated from structured exclusions. A cell-level row is required for +These **20** rows are generated from structured exclusions. A cell-level row is required for every derived model-free cell. The one-shot docs cold reader is a model-backed grader, but not a bus-connected agent: it has no st2 identity, DING sidecar, or lifecycle hooks. diff --git a/cells/pty-attach-outcomes-and-roles/README.md b/cells/pty-attach-outcomes-and-roles/README.md new file mode 100644 index 0000000..c702bd6 --- /dev/null +++ b/cells/pty-attach-outcomes-and-roles/README.md @@ -0,0 +1,55 @@ +# pty-attach-outcomes-and-roles + +**Type:** PTY installed-launcher and raw-socket composition. **Upstream +contracts:** the intentional machine-detach outcome and explicit same-socket +client-role replacement proposed against PTY main. + +**Capabilities required:** `pty,node`. No model and no bus. The cell executes +the `pty` binary selected by `PATH`; it does not import PTY source modules or +invoke `dist/cli.js`. + +## What it proves + +- **Intentional outcome:** the shipped `bin/pty` launcher preserves fd 3. Once + its initial baseline is observed, one real Ctrl-\\ input produces one + terminal, empty `DETACH`, clean descriptor EOF, empty stdout/stderr, and + process status zero. +- **Truncation control:** a public administrative `pty kill` of a separate + eval-owned session after its initial baseline closes fd 3 without `EXIT` or + `DETACH`; the attach process must exit nonzero. This distinguishes abrupt + session loss from an intentional local user action. +- **Role promotion:** one raw socket sends `PEEK`, then `ATTACH`, terminal data, + `RESIZE`, and `STATUS`. A separate attached anchor provides an output barrier. + The promoted client becomes writable and changes the shared grid from + `30x100` to `18x60`. +- **Role demotion:** another raw socket sends `ATTACH`, then `PEEK`, followed by + forbidden `DATA` and `RESIZE` plus a `STATUS` barrier. Its constraint is + removed, the grid returns to the anchor's `30x100`, and no forbidden bytes + reach the PTY before a later anchor marker. +- **Oracle mutations:** synthetic observations reject a missing, non-empty, or + wrong detach outcome; false truncation success; failed promotion; wrong + min-grid; leaked demoted input; a retained demoted size constraint; and an + empty session catalog with a live daemon or child PID. +- **Cleanup:** every socket is closed, cleanup commands succeed, the session + catalog is empty, and every captured eval-owned daemon and child PID is dead. + +The output barrier is causal rather than sleep-based: the anchor writes only +after the transitioning socket receives `STATUS`, and the PTY must emit the +anchor marker. Any earlier accepted input necessarily precedes that marker. + +## Validation revisions + +The acceptance cell was proven failing against PTY main `d5fabc3` and passing +against the packaged PTY built from merged upstream main +`9eb958c5aae026d5c05690ab72b528662c55708d`. That revision contains the attach +stream fixture prerequisite from #145, intentional detach outcomes from #146, +explicit same-socket role replacement from #147, and live-daemon registry +recovery from #128. The maintained cell now +runs directly against packaged PTY main; it has no validation-only runtime +dependency. + +## Run it + +```sh +st2 eval ./cells/pty-attach-outcomes-and-roles/ +``` diff --git a/cells/pty-attach-outcomes-and-roles/fixture/probe.mjs b/cells/pty-attach-outcomes-and-roles/fixture/probe.mjs new file mode 100644 index 0000000..1832ab6 --- /dev/null +++ b/cells/pty-attach-outcomes-and-roles/fixture/probe.mjs @@ -0,0 +1,404 @@ +#!/usr/bin/env node +import assert from "node:assert/strict" +import fs from "node:fs" +import net from "node:net" +import os from "node:os" +import path from "node:path" +import { spawn, spawnSync } from "node:child_process" + +const TYPE = { DATA: 0, ATTACH: 1, DETACH: 2, RESIZE: 3, EXIT: 4, SCREEN: 5, PEEK: 6, STATUS: 7, GEOMETRY: 10 } +const OUTCOMES = new Set([TYPE.DETACH, TYPE.EXIT]) +const PTY = process.env.PTY_BIN ?? "pty" +const root = fs.mkdtempSync(path.join(os.tmpdir(), "pty-corrections-")) +const env = { ...process.env, PTY_ROOT: root, PTY_ROOT_LEGACY_SILENT: "1" } +delete env.PTY_SESSION +delete env.PTY_SESSION_DIR +const startedProcesses = [] + +const frame = (type, payload = Buffer.alloc(0)) => { + const header = Buffer.alloc(5) + header.writeUInt8(type) + header.writeUInt32BE(payload.length, 1) + return Buffer.concat([header, payload]) +} + +const sizeFrame = (type, rows, columns) => { + const payload = Buffer.alloc(4) + payload.writeUInt16BE(rows, 0) + payload.writeUInt16BE(columns, 2) + return frame(type, payload) +} + +const decode = (data, complete = true) => { + const packets = [] + let offset = 0 + while (offset + 5 <= data.length) { + const type = data.readUInt8(offset) + const length = data.readUInt32BE(offset + 1) + if (length > 32 * 1024 * 1024) throw new Error(`oversized packet: ${length}`) + if (offset + 5 + length > data.length) break + packets.push({ type, payload: data.subarray(offset + 5, offset + 5 + length) }) + offset += 5 + length + } + if (complete && offset !== data.length) throw new Error("truncated packet") + return packets +} + +const geometry = (packet) => ({ rows: packet.payload.readUInt16BE(0), columns: packet.payload.readUInt16BE(2) }) +const exactGeometry = (value, rows, columns) => value?.rows === rows && value?.columns === columns +const roleStats = (stats) => ({ + clients: { attached: stats.clients.attached, readOnly: stats.clients.readOnly }, + terminal: { rows: stats.terminal.rows, cols: stats.terminal.cols }, +}) + +const validateDetach = (result) => { + assert.equal(result.code, 0, "intentional detach must exit zero") + assert.equal(result.fdEnded, true, "machine descriptor must reach EOF") + assert.deepEqual(result.stdout, Buffer.alloc(0), "machine stdout must stay empty") + assert.deepEqual(result.stderr, Buffer.alloc(0), "intentional detach stderr must stay empty") + const outcomes = result.packets.filter((packet) => OUTCOMES.has(packet.type)) + assert.equal(outcomes.length, 1, "machine stream must have exactly one terminal outcome") + assert.equal(result.packets.at(-1)?.type, TYPE.DETACH, "DETACH must be terminal") + assert.equal(result.packets.at(-1)?.payload.length, 0, "DETACH payload must be empty") +} + +const validateTruncation = (result) => { + assert.equal(Number.isInteger(result.code) && result.code !== 0, true, "truncated stream must fail with a nonzero exit code") + assert.equal(result.signal, null, "truncated stream must not rely on signal termination") + assert.equal(result.fdEnded, true, "truncated machine descriptor must reach EOF") + assert.equal(result.packets.some((packet) => OUTCOMES.has(packet.type)), false, "truncation must not invent an outcome") +} + +const validatePromotion = (result) => { + assert.deepEqual(result.stats.clients, { attached: 2, readOnly: 0 }) + assert.deepEqual(result.stats.terminal, { rows: 18, cols: 60 }) + assert.equal(result.output.includes("PROMOTED_WRITABLE_7c42"), true, "promoted DATA did not reach the PTY") + assert.equal(result.geometrySeen, true, "promoted RESIZE did not update the shared grid") +} + +const validateDemotion = (result) => { + assert.deepEqual(result.stats.clients, { attached: 1, readOnly: 1 }) + assert.deepEqual(result.stats.terminal, { rows: 30, cols: 100 }) + assert.equal(result.output.includes("DEMOTED_MUST_NOT_WRITE_7c42"), false, "demoted DATA reached the PTY") + assert.equal(result.restoredGeometrySeen, true, "demoted client retained a grid constraint") +} + +const validateCleanup = (result) => { + assert.deepEqual(result.sessions, [], "eval-owned PTY sessions remain") + assert.equal(result.processes.length > 0, true, "cleanup proof captured no process identities") + for (const process of result.processes) { + assert.equal(process.daemonAlive, false, `${process.name} daemon remains alive`) + assert.equal(process.childAlive, false, `${process.name} child remains alive`) + } +} + +const mutate = (value, patch) => ({ ...value, ...patch }) + +const selfTest = () => { + const detach = { code: 0, fdEnded: true, stdout: Buffer.alloc(0), stderr: Buffer.alloc(0), packets: [{ type: TYPE.DETACH, payload: Buffer.alloc(0) }] } + validateDetach(detach) + for (const bad of [ + mutate(detach, { code: 1 }), + mutate(detach, { fdEnded: false }), + mutate(detach, { stdout: Buffer.from("leak") }), + mutate(detach, { stderr: Buffer.from("leak") }), + mutate(detach, { packets: [] }), + mutate(detach, { packets: [{ type: TYPE.EXIT, payload: Buffer.alloc(4) }] }), + mutate(detach, { packets: [{ type: TYPE.DETACH, payload: Buffer.from("x") }] }), + mutate(detach, { packets: [detach.packets[0], detach.packets[0]] }), + mutate(detach, { packets: [{ type: TYPE.EXIT, payload: Buffer.alloc(4) }, detach.packets[0]] }), + ]) assert.throws(() => validateDetach(bad)) + + const truncation = { code: 1, signal: null, fdEnded: true, packets: [{ type: TYPE.GEOMETRY, payload: Buffer.alloc(4) }, { type: TYPE.SCREEN, payload: Buffer.alloc(0) }] } + validateTruncation(truncation) + for (const bad of [ + mutate(truncation, { code: 0 }), + mutate(truncation, { code: null, signal: "SIGKILL" }), + mutate(truncation, { fdEnded: false }), + mutate(truncation, { packets: [...truncation.packets, { type: TYPE.DETACH, payload: Buffer.alloc(0) }] }), + mutate(truncation, { packets: [...truncation.packets, { type: TYPE.EXIT, payload: Buffer.alloc(4) }] }), + ]) assert.throws(() => validateTruncation(bad)) + + const promotion = { + stats: { clients: { attached: 2, readOnly: 0 }, terminal: { rows: 18, cols: 60 } }, + output: "PROMOTED_WRITABLE_7c42\r\nANCHOR_AFTER_PROMOTION_7c42", + geometrySeen: true, + } + validatePromotion(promotion) + for (const bad of [ + mutate(promotion, { stats: { ...promotion.stats, clients: { attached: 1, readOnly: 1 } } }), + mutate(promotion, { stats: { ...promotion.stats, terminal: { rows: 30, cols: 100 } } }), + mutate(promotion, { output: "ANCHOR_AFTER_PROMOTION_7c42" }), + mutate(promotion, { geometrySeen: false }), + ]) assert.throws(() => validatePromotion(bad)) + + const demotion = { + stats: { clients: { attached: 1, readOnly: 1 }, terminal: { rows: 30, cols: 100 } }, + output: "ANCHOR_AFTER_DEMOTION_7c42", + restoredGeometrySeen: true, + } + validateDemotion(demotion) + for (const bad of [ + mutate(demotion, { stats: { ...demotion.stats, clients: { attached: 2, readOnly: 0 } } }), + mutate(demotion, { stats: { ...demotion.stats, terminal: { rows: 12, cols: 40 } } }), + mutate(demotion, { output: "DEMOTED_MUST_NOT_WRITE_7c42\r\nANCHOR_AFTER_DEMOTION_7c42" }), + mutate(demotion, { restoredGeometrySeen: false }), + ]) assert.throws(() => validateDemotion(bad)) + + const cleanup = { sessions: [], processes: [{ name: "synthetic", daemonAlive: false, childAlive: false }] } + validateCleanup(cleanup) + for (const bad of [ + mutate(cleanup, { sessions: [{ name: "synthetic" }] }), + mutate(cleanup, { processes: [mutate(cleanup.processes[0], { daemonAlive: true })] }), + mutate(cleanup, { processes: [mutate(cleanup.processes[0], { childAlive: true })] }), + ]) assert.throws(() => validateCleanup(bad)) + console.log("ORACLE-MUTATIONS-GREEN-7c42") +} + +const runPty = (args, options = {}) => spawnSync(PTY, args, { env, encoding: "utf8", ...options }) + +const startSession = (name, command) => { + const result = runPty(["run", "-d", "--id", name, "--no-display-name", "--", "sh", "-c", command]) + assert.equal(result.status, 0, result.stderr) + const stats = runPty(["stats", "--json"]) + assert.equal(stats.status, 0, stats.stderr) + const session = JSON.parse(stats.stdout).find((candidate) => candidate.name === name) + assert.equal(Number.isInteger(session?.daemon?.pid), true, `${name} daemon PID was not reported`) + assert.equal(Number.isInteger(session?.process?.pid), true, `${name} child PID was not reported`) + startedProcesses.push({ name, daemonPid: session.daemon.pid, childPid: session.process.pid }) +} + +const removeSession = (name) => { + const listed = runPty(["list", "--json"]) + assert.equal(listed.status, 0, listed.stderr) + const session = JSON.parse(listed.stdout).find((candidate) => candidate.name === name) + assert.notEqual(session, undefined, `${name} metadata disappeared before cleanup`) + if (session.status === "running") { + const killed = runPty(["kill", name]) + assert.equal(killed.status, 0, killed.stderr) + } + const removed = runPty(["rm", name]) + assert.equal(removed.status, 0, removed.stderr) +} + +const isProcessAlive = (pid) => { + try { + process.kill(pid, 0) + return true + } catch (error) { + if (error?.code === "ESRCH") return false + throw error + } +} + +const timeout = (label, milliseconds = 8_000) => new Promise((_, reject) => { + const timer = setTimeout(() => reject(new Error(`timed out: ${label}`)), milliseconds) + timer.unref?.() +}) + +const collect = (stream) => { + const chunks = [] + stream.on("data", (chunk) => chunks.push(Buffer.from(chunk))) + return () => Buffer.concat(chunks) +} + +const childResult = async (child, afterScreen) => { + const stdout = collect(child.stdout) + const stderr = collect(child.stderr) + const fdChunks = [] + let fdEnded = false + let requested = false + child.stdio[3].on("data", (chunk) => { + fdChunks.push(Buffer.from(chunk)) + if (!requested && decode(Buffer.concat(fdChunks), false).some((packet) => packet.type === TYPE.SCREEN)) { + requested = true + afterScreen(child) + } + }) + child.stdio[3].on("end", () => { fdEnded = true }) + const exit = new Promise((resolve) => child.once("close", (code, signal) => resolve({ code, signal }))) + const { code, signal } = await Promise.race([exit, timeout("machine attach exit")]) + const data = Buffer.concat(fdChunks) + return { code, signal, fdEnded, stdout: stdout(), stderr: stderr(), packets: decode(data) } +} + +const machineScenarios = async () => { + const detachName = `eval-detach-${process.pid}` + const truncName = `eval-trunc-${process.pid}` + try { + startSession(detachName, "printf DETACH_READY_7c42; sleep 300") + const detached = spawn(PTY, ["attach", "--attach-stream-fd-v1", "3", detachName], { env, stdio: ["pipe", "pipe", "pipe", "pipe"] }) + const detachResult = await childResult(detached, (child) => child.stdin.write(Buffer.from([0x1c]))) + validateDetach(detachResult) + console.log("MACHINE-DETACH-GREEN-7c42") + + startSession(truncName, "printf TRUNC_READY_7c42; sleep 300") + const truncated = spawn(PTY, ["attach", "--attach-stream-fd-v1", "3", truncName], { env, stdio: ["pipe", "pipe", "pipe", "pipe"] }) + const truncResult = await childResult(truncated, () => { + const killed = runPty(["kill", truncName]) + assert.equal(killed.status, 0, killed.stderr) + }) + validateTruncation(truncResult) + console.log("MACHINE-TRUNCATION-GREEN-7c42") + } finally { + removeSession(detachName) + removeSession(truncName) + } +} + +class ProtocolSocket { + constructor(socketPath) { + this.socket = net.createConnection(socketPath) + this.packets = [] + this.pending = new Set() + this.buffer = Buffer.alloc(0) + this.socket.on("data", (chunk) => { + this.buffer = Buffer.concat([this.buffer, chunk]) + let offset = 0 + while (offset + 5 <= this.buffer.length) { + const length = this.buffer.readUInt32BE(offset + 1) + if (offset + 5 + length > this.buffer.length) break + this.packets.push({ type: this.buffer.readUInt8(offset), payload: Buffer.from(this.buffer.subarray(offset + 5, offset + 5 + length)) }) + offset += 5 + length + } + this.buffer = this.buffer.subarray(offset) + for (const check of this.pending) check() + }) + } + + async connected() { + if (!this.socket.connecting) return + await Promise.race([new Promise((resolve, reject) => { + this.socket.once("connect", resolve) + this.socket.once("error", reject) + }), timeout("raw socket connect")]) + } + + send(...frames) { this.socket.write(Buffer.concat(frames)) } + + async waitFor(predicate, label) { + return this.waitForSince(0, predicate, label) + } + + async waitForSince(index, predicate, label) { + const found = this.packets.slice(index).find(predicate) + if (found) return found + let check + const ready = new Promise((resolve) => { + check = () => { + const packet = this.packets.slice(index).find(predicate) + if (!packet) return + this.pending.delete(check) + resolve(packet) + } + this.pending.add(check) + }) + try { return await Promise.race([ready, timeout(label)]) } + finally { this.pending.delete(check) } + } + + async status() { + const before = this.packets.filter((packet) => packet.type === TYPE.STATUS).length + this.send(frame(TYPE.STATUS)) + await this.waitFor(() => this.packets.filter((packet) => packet.type === TYPE.STATUS).length > before, "STATUS response") + return JSON.parse(this.packets.filter((packet) => packet.type === TYPE.STATUS).at(-1).payload.toString()) + } + + outputSince(index) { + return this.packets.slice(index).filter((packet) => packet.type === TYPE.DATA || packet.type === TYPE.SCREEN).map((packet) => packet.payload.toString()).join("") + } + + close() { this.socket.destroy() } +} + +const roleScenarios = async () => { + const name = `eval-role-${process.pid}` + const sockets = [] + try { + startSession(name, "exec cat") + const socketPath = path.join(root, `${name}.sock`) + const anchor = new ProtocolSocket(socketPath) + sockets.push(anchor) + await anchor.connected() + anchor.send(sizeFrame(TYPE.ATTACH, 30, 100)) + await anchor.waitFor((packet) => packet.type === TYPE.SCREEN, "anchor baseline") + + const promoted = new ProtocolSocket(socketPath) + sockets.push(promoted) + await promoted.connected() + promoted.send(frame(TYPE.PEEK, Buffer.from([0]))) + await promoted.waitFor((packet) => packet.type === TYPE.SCREEN, "PEEK baseline") + const promotedScreens = promoted.packets.filter((packet) => packet.type === TYPE.SCREEN).length + const promotionStart = anchor.packets.length + promoted.send( + sizeFrame(TYPE.ATTACH, 20, 70), + frame(TYPE.DATA, Buffer.from("PROMOTED_WRITABLE_7c42\n")), + sizeFrame(TYPE.RESIZE, 18, 60), + ) + await promoted.waitFor(() => promoted.packets.filter((packet) => packet.type === TYPE.SCREEN).length > promotedScreens, "ATTACH replacement baseline") + const promotionStats = await promoted.status() + anchor.send(frame(TYPE.DATA, Buffer.from("ANCHOR_AFTER_PROMOTION_7c42\n"))) + await anchor.waitFor((packet) => packet.type === TYPE.DATA && packet.payload.includes(Buffer.from("ANCHOR_AFTER_PROMOTION_7c42")), "promotion output barrier") + const promotionOutput = anchor.outputSince(promotionStart) + const promotionGeometry = anchor.packets.slice(promotionStart).filter((packet) => packet.type === TYPE.GEOMETRY).map(geometry) + validatePromotion({ + stats: roleStats(promotionStats), + output: promotionOutput, + geometrySeen: promotionGeometry.some((value) => exactGeometry(value, 18, 60)), + }) + console.log("ROLE-PROMOTION-GREEN-7c42") + const restoreStart = anchor.packets.length + promoted.close() + await anchor.waitForSince(restoreStart, (packet) => packet.type === TYPE.GEOMETRY && exactGeometry(geometry(packet), 30, 100), "grid restore after promoted client closes") + + const demoted = new ProtocolSocket(socketPath) + sockets.push(demoted) + await demoted.connected() + demoted.send(sizeFrame(TYPE.ATTACH, 20, 70)) + await demoted.waitFor((packet) => packet.type === TYPE.SCREEN, "ATTACH baseline") + const demotedScreens = demoted.packets.filter((packet) => packet.type === TYPE.SCREEN).length + const demotionStart = anchor.packets.length + demoted.send(frame(TYPE.PEEK, Buffer.from([0]))) + await demoted.waitFor(() => demoted.packets.filter((packet) => packet.type === TYPE.SCREEN).length > demotedScreens, "PEEK replacement baseline") + demoted.send( + sizeFrame(TYPE.RESIZE, 12, 40), + frame(TYPE.DATA, Buffer.from("DEMOTED_MUST_NOT_WRITE_7c42\n")), + ) + const demotionStats = await demoted.status() + anchor.send(frame(TYPE.DATA, Buffer.from("ANCHOR_AFTER_DEMOTION_7c42\n"))) + await anchor.waitFor((packet) => packet.type === TYPE.DATA && packet.payload.includes(Buffer.from("ANCHOR_AFTER_DEMOTION_7c42")), "demotion output barrier") + const demotionOutput = anchor.outputSince(demotionStart) + const demotionGeometry = anchor.packets.slice(demotionStart).filter((packet) => packet.type === TYPE.GEOMETRY).map(geometry) + validateDemotion({ + stats: roleStats(demotionStats), + output: demotionOutput, + restoredGeometrySeen: demotionGeometry.some((value) => exactGeometry(value, 30, 100)), + }) + console.log("ROLE-DEMOTION-GREEN-7c42") + } finally { + for (const socket of sockets) socket.close() + removeSession(name) + } +} + +try { + if (process.argv[2] === "--self-test") { + selfTest() + } else { + if (process.argv[2] !== "--roles-only") await machineScenarios() + if (process.argv[2] !== "--machine-only") await roleScenarios() + const remaining = runPty(["list", "--json"]) + assert.equal(remaining.status, 0, remaining.stderr) + validateCleanup({ + sessions: JSON.parse(remaining.stdout), + processes: startedProcesses.map((process) => ({ + name: process.name, + daemonAlive: isProcessAlive(process.daemonPid), + childAlive: isProcessAlive(process.childPid), + })), + }) + console.log("ATTACH-CORRECTIONS-CLEANUP-GREEN-7c42") + } +} finally { + fs.rmSync(root, { recursive: true, force: true }) +} diff --git a/cells/pty-attach-outcomes-and-roles/pty-attach-outcomes-and-roles.kdl b/cells/pty-attach-outcomes-and-roles/pty-attach-outcomes-and-roles.kdl new file mode 100644 index 0000000..784f10f --- /dev/null +++ b/cells/pty-attach-outcomes-and-roles/pty-attach-outcomes-and-roles.kdl @@ -0,0 +1,30 @@ +// Compose the shipped PTY attach launcher and raw socket protocol around the two role/outcome corrections. +eval { + copy "./fixture" + max-timeout "60s" + + run "contracts" { + command "node ./probe.mjs" + } + run "oracle-mutations" { + command "node ./probe.mjs --self-test" + } + + judges { + judge "DETACH - intentional machine detach is framed before clean EOF" { + exec "grep -Fqx MACHINE-DETACH-GREEN-7c42 $RUNS_DIR/contracts.out" + } + judge "TRUNCATION - socket loss cannot masquerade as an intentional outcome" { + exec "grep -Fqx MACHINE-TRUNCATION-GREEN-7c42 $RUNS_DIR/contracts.out" + } + judge "PROMOTION - PEEK to ATTACH restores writes and min-grid participation" { + exec "grep -Fqx ROLE-PROMOTION-GREEN-7c42 $RUNS_DIR/contracts.out" + } + judge "DEMOTION - ATTACH to PEEK suppresses writes and removes its constraint" { + exec "grep -Fqx ROLE-DEMOTION-GREEN-7c42 $RUNS_DIR/contracts.out" + } + judge "CLEANUP - every eval-owned process and PTY session is removed" { + exec "grep -Fqx ATTACH-CORRECTIONS-CLEANUP-GREEN-7c42 $RUNS_DIR/contracts.out" + } + } +} diff --git a/docs/vrs/spec.md b/docs/vrs/spec.md index ab2b1f3..c63ed14 100644 --- a/docs/vrs/spec.md +++ b/docs/vrs/spec.md @@ -55,6 +55,19 @@ tracked cell truncated frames, and side-channel terminal bytes. The fixture controls route selection and transport failure but does not import PTY source modules or bypass the packaged launcher. +- **R01, R04, R05, R07, R11:** The model-free + `pty-attach-outcomes-and-roles` cell composes two corrections at their public + boundaries. The installed launcher must frame an intentional Ctrl-\\ detach + as one terminal, empty `DETACH` before clean descriptor EOF and status zero; + an eval-owned daemon loss must instead end without `EXIT` or `DETACH` and + fail. Independent raw-socket transitions must replace, not accumulate, the + client role: `PEEK` to `ATTACH` restores terminal input and shared min-grid + participation, while `ATTACH` to `PEEK` suppresses input and resize and + removes its geometry constraint. `STATUS` and later PTY output provide causal + barriers for the negative assertions. A model-free mutation matrix rejects + ambiguous outcomes, false truncation success, failed promotion, retained + constraints, leaked demoted input, and an empty session catalog that still + has a live captured daemon or child PID. ## Current execution diff --git a/evidence/harness-exclusions.tsv b/evidence/harness-exclusions.tsv index 2ac5f66..e996174 100644 --- a/evidence/harness-exclusions.tsv +++ b/evidence/harness-exclusions.tsv @@ -7,6 +7,7 @@ exec-lifecycle-logging cell deterministic native exec lifecycle probe; no harnes hook-integrity cell deterministic native hook-installation probe; no live harness seat host-lock-health-negatives cell deterministic native doctor negative probe; no harness seat pty-attach-machine-stream cell deterministic installed PTY attach composition probe; no harness seat +pty-attach-outcomes-and-roles cell deterministic installed PTY outcome and raw-socket role-transition probe; no harness seat pty-attach-only cell deterministic PTY dead-attach policy probe; no harness seat pty-send-peek cell deterministic PTY transport probe; no harness seat presence-ding-matrix cell deterministic native presence and DING probe; no harness seat