diff --git a/README.md b/README.md index e8e5438..31de3d5 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,15 @@ truth, archive transition, and retained resources. Requirements: Herdr exactly `0.7.5`, Node.js 20 or current LTS, Git with 40-hex SHA-1 object IDs, Python 3.11+, and macOS or Linux. +The release and production-support pin remains Herdr `0.7.5` / protocol `17` / +schema `1`. Conductor also has a bounded **development candidate** path for +exactly Herdr `0.9.3` / protocol `22` / schema `1`: it is admitted only when +the client schema exposes every API shape Conductor uses and the selected +server reports the same healthy, compatible identity. This candidate path is +limited to the isolated synthetic smoke below. It is not a support promotion, +release certification, production installation, or reason to change an +existing `0.7.5` deployment. + For every effecting action, Conductor: - accepts only `HERDR_PLUGIN_CONTEXT_JSON`; there is no `CONDUCTOR_REPO`, @@ -275,6 +284,46 @@ npm run evidence:stage2:finalize -- \ --candidate ``` +### Isolated Herdr 0.9.3 development smoke + +The development smoke runs all seven installed actions against an already +running, non-default Herdr `0.9.3` session. It requires an exact opt-in, an +absolute candidate binary, a session-specific socket below a temporary `HOME`, +and matching `XDG_CONFIG_HOME` and `XDG_STATE_HOME` roots: + +```bash +HOME=/home \ +XDG_CONFIG_HOME=/home/.config \ +XDG_STATE_HOME=/home/.local/state \ +HERDR_SOCKET_PATH=/home/.config/herdr/sessions//herdr.sock \ +CONDUCTOR_093_HERDR_BIN= \ +CONDUCTOR_093_SESSION= \ +CONDUCTOR_093_DEVELOPMENT_SMOKE=I_UNDERSTAND_THIS_MUTATES_ONLY_THE_ISOLATED_093_SESSION \ +npm run smoke:development:093 +``` + +The harness refuses a default session, mismatched client/server identity, +unhealthy endpoint, incompatible API schema, or non-temporary home. It links a +temporary plugin copy only in the selected isolated server, restores any prior +isolated Conductor link, removes its temporary agent profiles, and closes its +workspace after stand-down. It does not install or update a production plugin, +restart a server, or touch another Herdr session. + +A passing run retains its private `0700` temporary root and prints its path, +repository/state paths, and cleanup outcomes in the JSON summary. Preserve it +only as development evidence, then dispose of that harness-owned root manually +when it is no longer needed. On failure, the root is retained for diagnosis. + +This smoke uses synthetic local shell workers and unauthenticated report +fixtures. It performs no real Pi/model or network integration and is not +formal evidence, release certification, production approval, or proof of +general Herdr `0.9.x` compatibility. + +Rollback requires no plugin or data migration: cease using the isolated +candidate session and continue with the release-pinned exact Herdr `0.7.5` +client and matching `0.7.5` server. The candidate harness never changes that +installation. + ## Security and limitations - Private state, hashes, modes, journals, and local evidence are cooperative diff --git a/package.json b/package.json index 92cd5c1..88978c2 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "check:release": "node scripts/check-stage2-live-evidence.mjs", "report:publish": "node scripts/report-publisher.mjs publish", "apply:approve": "node scripts/approval-recorder.mjs record", + "smoke:development:093": "node scripts/run-093-development-smoke.mjs", "evidence:stage2:live": "node scripts/run-stage2-live-evidence.mjs", "evidence:stage2:finalize": "node scripts/finalize-stage2-live-evidence.mjs", "check": "npm test && npm run check:shell && npm run check:python && npm run check:manifest && npm run check:docs && npm run check:evidence" diff --git a/scripts/herdr-identity.mjs b/scripts/herdr-identity.mjs index 49dda96..622675e 100755 --- a/scripts/herdr-identity.mjs +++ b/scripts/herdr-identity.mjs @@ -52,14 +52,259 @@ export function herdrJson(exec, herdrBin, args) { return value?.result ?? value; } +const HERDR_093 = Object.freeze({ + version: "0.9.3", + protocol: 22, + schemaVersion: 1, +}); + +const REQUIRED_METHODS_093 = Object.freeze({ + "agent.list": "#/schemas/request/$defs/EmptyParams", + "agent.get": "#/schemas/request/$defs/AgentTarget", + "agent.start": "#/schemas/request/$defs/AgentStartParams", + "pane.split": "#/schemas/request/$defs/PaneSplitParams", + "pane.list": "#/schemas/request/$defs/PaneListParams", + "pane.get": "#/schemas/request/$defs/PaneTarget", + "pane.report_metadata": "#/schemas/request/$defs/PaneReportMetadataParams", + "pane.close": "#/schemas/request/$defs/PaneTarget", +}); + +function object(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function hasType(schema, type) { + return ( + schema?.type === type || + (Array.isArray(schema?.type) && schema.type.includes(type)) + ); +} + +function hasRequired(schema, ...names) { + return ( + Array.isArray(schema?.required) && + names.every((name) => schema.required.includes(name)) + ); +} + +function hasRef(schema, ref) { + return ( + schema?.$ref === ref || + (Array.isArray(schema?.anyOf) && + schema.anyOf.some((candidate) => candidate?.$ref === ref)) + ); +} + +function hasMethod(request, method, paramsRef) { + return request.oneOf.some( + (candidate) => + candidate?.properties?.method?.const === method && + candidate?.properties?.params?.$ref === paramsRef && + hasRequired(candidate, "method", "params"), + ); +} + +function resultVariant(result, type) { + return result.oneOf.find( + (candidate) => candidate?.properties?.type?.const === type, + ); +} + +function supportsConductorApi093(schema) { + const request = schema?.schemas?.request; + const requestDefs = request?.$defs; + const responseDefs = schema?.schemas?.success_response?.$defs; + const results = responseDefs?.ResponseResult; + if ( + !object(request) || + !Array.isArray(request.oneOf) || + !object(requestDefs) || + !object(responseDefs) || + !Array.isArray(results?.oneOf) + ) + return false; + + for (const [method, paramsRef] of Object.entries(REQUIRED_METHODS_093)) + if (!hasMethod(request, method, paramsRef)) return false; + + const paneTarget = requestDefs.PaneTarget; + const agentTarget = requestDefs.AgentTarget; + const split = requestDefs.PaneSplitParams; + const metadata = requestDefs.PaneReportMetadataParams; + const start = requestDefs.AgentStartParams; + const context = requestDefs.PluginInvocationContext; + if ( + !hasType(requestDefs.EmptyParams, "object") || + !hasType(requestDefs.PaneListParams, "object") || + !hasRequired(paneTarget, "pane_id") || + !hasType(paneTarget?.properties?.pane_id, "string") || + !hasRequired(agentTarget, "target") || + !hasType(agentTarget?.properties?.target, "string") || + !hasRequired(split, "direction") || + !hasRef( + split?.properties?.direction, + "#/schemas/request/$defs/SplitDirection", + ) || + !hasType(requestDefs.SplitDirection, "string") || + !requestDefs.SplitDirection.enum?.includes("right") || + !hasType(split?.properties?.target_pane_id, "string") || + !hasType(split?.properties?.cwd, "string") || + !hasType(split?.properties?.focus, "boolean") || + !hasRequired(metadata, "pane_id", "source") || + !hasType(metadata?.properties?.pane_id, "string") || + !hasType(metadata?.properties?.source, "string") || + !hasType(metadata?.properties?.tokens, "object") || + !hasRequired(start, "name", "kind", "pane_id") || + !["name", "kind", "pane_id"].every((name) => + hasType(start?.properties?.[name], "string"), + ) || + !hasType(start?.properties?.timeout_ms, "integer") || + !["workspace_id", "workspace_cwd", "focused_pane_id"].every((name) => + hasType(context?.properties?.[name], "string"), + ) + ) + return false; + + const paneInfo = responseDefs.PaneInfo; + const agentInfo = responseDefs.AgentInfo; + const agentSession = responseDefs.AgentSessionInfo; + const paneIdentityFields = [ + "workspace_id", + "pane_id", + "terminal_id", + "cwd", + "foreground_cwd", + ]; + const agentIdentityFields = [...paneIdentityFields, "name"]; + if ( + !hasRequired(paneInfo, "workspace_id", "pane_id", "terminal_id") || + !paneIdentityFields.every((name) => + hasType(paneInfo?.properties?.[name], "string"), + ) || + !hasType(paneInfo?.properties?.tokens, "object") || + !hasRef( + paneInfo?.properties?.agent_session, + "#/schemas/success_response/$defs/AgentSessionInfo", + ) || + !hasRequired(agentInfo, "workspace_id", "pane_id", "terminal_id") || + !agentIdentityFields.every((name) => + hasType(agentInfo?.properties?.[name], "string"), + ) || + !( + hasType(agentInfo?.properties?.agent_status, "string") || + (hasRef( + agentInfo?.properties?.agent_status, + "#/schemas/success_response/$defs/AgentStatus", + ) && + hasType(responseDefs.AgentStatus, "string")) + ) || + !hasType(agentInfo?.properties?.tokens, "object") || + !hasRef( + agentInfo?.properties?.agent_session, + "#/schemas/success_response/$defs/AgentSessionInfo", + ) || + !hasRequired(agentSession, "source", "agent", "kind", "value") || + !["source", "agent", "value"].every((name) => + hasType(agentSession?.properties?.[name], "string"), + ) || + !( + hasType(agentSession?.properties?.kind, "string") || + (hasRef( + agentSession?.properties?.kind, + "#/schemas/success_response/$defs/AgentSessionRefKind", + ) && + hasType(responseDefs.AgentSessionRefKind, "string")) + ) + ) + return false; + + const paneResult = resultVariant(results, "pane_info"); + const paneListResult = resultVariant(results, "pane_list"); + const agentResult = resultVariant(results, "agent_info"); + const agentListResult = resultVariant(results, "agent_list"); + const agentStartedResult = resultVariant(results, "agent_started"); + const okResult = resultVariant(results, "ok"); + return ( + hasRequired(paneResult, "type", "pane") && + hasRef( + paneResult?.properties?.pane, + "#/schemas/success_response/$defs/PaneInfo", + ) && + hasRequired(paneListResult, "type", "panes") && + hasRef( + paneListResult?.properties?.panes?.items, + "#/schemas/success_response/$defs/PaneInfo", + ) && + hasRequired(agentResult, "type", "agent") && + hasRef( + agentResult?.properties?.agent, + "#/schemas/success_response/$defs/AgentInfo", + ) && + hasRequired(agentListResult, "type", "agents") && + hasRef( + agentListResult?.properties?.agents?.items, + "#/schemas/success_response/$defs/AgentInfo", + ) && + hasRequired(agentStartedResult, "type", "agent", "argv") && + hasRef( + agentStartedResult?.properties?.agent, + "#/schemas/success_response/$defs/AgentInfo", + ) && + hasType(agentStartedResult?.properties?.argv, "array") && + hasRequired(okResult, "type") + ); +} + +function requireHerdrServer(exec, herdrBin, profile, { endpoint = false } = {}) { + const server = herdrJson(exec, herdrBin, ["status", "server", "--json"]); + if ( + server.status !== "running" || + server.running !== true || + server.version !== profile.version || + server.protocol !== profile.protocol || + server.compatible !== true || + server.restart_needed !== false || + (endpoint && + (server.endpoint_compatible !== true || + server.server_binary_stale !== false)) + ) + fail( + "unsupported_herdr", + `Herdr ${profile.version} client requires a healthy compatible ${profile.version}/protocol ${profile.protocol} server`, + ); +} + export function requireHerdrRuntime(exec, herdrBin) { const version = exec(herdrBin, ["--version"]); - if (version !== "herdr 0.7.5") - fail("unsupported_herdr", "Herdr runtime must be exactly 0.7.5"); + if (version === "herdr 0.7.5") { + const schema = herdrJson(exec, herdrBin, ["api", "schema", "--json"]); + if (schema.protocol !== 17 || schema.schema_version !== 1) + fail("unsupported_herdr", "Herdr protocol must be exactly 17/schema 1"); + const profile = Object.freeze({ + version: "0.7.5", + protocol: 17, + schemaVersion: 1, + }); + requireHerdrServer(exec, herdrBin, profile); + return profile; + } + if (version !== `herdr ${HERDR_093.version}`) + fail( + "unsupported_herdr", + "Herdr runtime must be exactly 0.7.5 or capability-validated 0.9.3", + ); const schema = herdrJson(exec, herdrBin, ["api", "schema", "--json"]); - if (schema.protocol !== 17 || schema.schema_version !== 1) - fail("unsupported_herdr", "Herdr protocol must be exactly 17/schema 1"); - return Object.freeze({ version: "0.7.5", protocol: 17, schemaVersion: 1 }); + if ( + schema.protocol !== HERDR_093.protocol || + schema.schema_version !== HERDR_093.schemaVersion || + !supportsConductorApi093(schema) + ) + fail( + "unsupported_herdr", + "Herdr 0.9.3 lacks the required protocol 22/schema 1 Conductor API capabilities", + ); + requireHerdrServer(exec, herdrBin, HERDR_093, { endpoint: true }); + return HERDR_093; } export function paneFrom(result) { diff --git a/scripts/run-093-development-smoke.mjs b/scripts/run-093-development-smoke.mjs new file mode 100755 index 0000000..010d9ce --- /dev/null +++ b/scripts/run-093-development-smoke.mjs @@ -0,0 +1,880 @@ +#!/usr/bin/env node +// Development-only installed-action compatibility smoke. It never starts, +// stops, restarts, or upgrades a Herdr server and refuses default-session paths. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { execFileSync, spawnSync } from "node:child_process"; +import { + chmodSync, + cpSync, + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, relative, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + STAGE3_APPROVAL_STATEMENTS, + canonicalJson, + parseStrictJsonBytes, +} from "./private-state-schema.mjs"; +import { computeChangedPaths } from "./source-policy.mjs"; +import { + parseTaskBytes, + reportDigest, +} from "./task-report-schema.mjs"; + +const OPT_IN = "I_UNDERSTAND_THIS_MUTATES_ONLY_THE_ISOLATED_093_SESSION"; +const PLUGIN_ID = "structupath.conductor"; +const root = realpathSync(join(dirname(fileURLToPath(import.meta.url)), "..")); +const configPathFor = (repository) => join(repository, ".herdr-conductor.json"); +let retainedFailure = null; + +function fail(message) { + throw new Error(message); +} + +function sha256(value) { + return createHash("sha256").update(value).digest("hex"); +} + +function contained(parent, child) { + const path = relative(parent, child); + return path !== "" && path !== ".." && !path.startsWith(`..${sep}`); +} + +function shellQuote(value) { + return `'${String(value).replaceAll("'", `'\\''`)}'`; +} + +function command(binary, args, options = {}) { + return execFileSync(binary, args, { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + timeout: 180_000, + killSignal: "SIGKILL", + ...options, + }).trim(); +} + +function git(repository, ...args) { + return command("git", ["-C", repository, ...args]); +} + +function findObject(value, predicate, seen = new Set()) { + if (value === null || typeof value !== "object" || seen.has(value)) return null; + seen.add(value); + if (predicate(value)) return value; + for (const child of Array.isArray(value) ? value : Object.values(value)) { + const found = findObject(child, predicate, seen); + if (found) return found; + } + return null; +} + +function parseJson(output, label) { + try { + return JSON.parse(output); + } catch (error) { + throw new Error(`${label} did not return JSON`, { cause: error }); + } +} + +function embeddedJson(result, predicate) { + const direct = findObject(result.payload, predicate); + if (direct) return direct; + for (const text of [result.output, result.payload?.stdout, result.payload?.stderr]) { + if (typeof text !== "string") continue; + for (const candidate of [text.trim(), ...text.split("\n")]) { + if (!candidate.startsWith("{")) continue; + try { + const found = findObject(JSON.parse(candidate), predicate); + if (found) return found; + } catch { + // Not a standalone JSON value. + } + } + } + fail("installed action omitted its structured result"); +} + +function repositoryKey(repository) { + const commonPath = realpathSync( + git(repository, "rev-parse", "--path-format=absolute", "--git-common-dir"), + ); + const stats = statSync(commonPath, { bigint: true }); + return sha256( + `herdr-conductor-repository-v1\0${commonPath}\0${stats.dev}\0${stats.ino}`, + ); +} + +function workspaceKey(workspaceId) { + return sha256(`herdr-conductor-workspace-v1\0${workspaceId}`); +} + +function journalEntries(stateRepositoryPath, workspaceId, assembled) { + const directory = join( + stateRepositoryPath, + "workspaces", + workspaceKey(workspaceId), + "runs", + assembled.run_id, + assembled.generation, + "operations", + ); + return readdirSync(directory) + .filter((name) => name.endsWith(".json")) + .sort() + .map((name) => parseStrictJsonBytes(readFileSync(join(directory, name)))); +} + +function retainedOperations(stateRoot) { + const operations = []; + function visit(directory) { + if (!existsSync(directory)) return; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const path = join(directory, entry.name); + if (entry.isDirectory()) { + visit(path); + continue; + } + if (!path.includes(`${sep}operations${sep}`) || !entry.name.endsWith(".json")) + continue; + try { + const value = parseStrictJsonBytes(readFileSync(path)); + operations.push({ + path: relative(stateRoot, path), + operation_id: value.operation_id, + operation_type: value.operation_type, + phase: value.phase, + subject: value.subject, + }); + } catch { + operations.push({ + path: relative(stateRoot, path), + parse_error: true, + }); + } + } + } + visit(stateRoot); + return operations.sort((left, right) => + Buffer.from(left.path).compare(Buffer.from(right.path)), + ); +} + +function buildReport(worker, stateRepositoryPath, workspaceId, assembled) { + const task = parseTaskBytes(readFileSync(worker.task_path)); + const agent = journalEntries( + stateRepositoryPath, + workspaceId, + assembled, + ).find( + (entry) => + entry.operation_type === "agent.start" && + entry.phase === "observed" && + entry.subject.id === task.role.name, + ); + assert.ok(agent, "report requires observed task-bound agent authority"); + const producer = task.source.kind === "role_worktree"; + const output = producer ? git(task.source.root, "rev-parse", "HEAD") : null; + const source = producer + ? { + ...task.source, + expected_sha: output, + tree_sha: git(task.source.root, "rev-parse", "HEAD^{tree}"), + } + : task.source; + const changedPaths = producer + ? computeChangedPaths(task.source.root, task.source.fork_sha, output) + : []; + const requirementResults = [ + ...task.assignment.required_commands.map((requirement) => ({ + requirement_kind: "command", + requirement_id: requirement.id, + assertion: "passed", + evidence_kind: "worker_assertion", + command: requirement.command, + exit_code: 0, + output_sha256: sha256("development smoke command assertion"), + note: "Unauthenticated deterministic development-smoke assertion.", + })), + ...task.assignment.acceptance_criteria.map((requirement) => ({ + requirement_kind: "criterion", + requirement_id: requirement.id, + assertion: "passed", + evidence_kind: "worker_assertion", + command: null, + exit_code: null, + output_sha256: null, + note: "Unauthenticated deterministic development-smoke assertion.", + })), + ]; + const draft = { + document_type: "herdr-conductor-report", + schema_version: 1, + report_id: `development-smoke-${task.role.name}`, + report_generation: sha256(`development-smoke\0${task.task_digest}`).slice( + 0, + 32, + ), + task: { + id: task.task_id, + generation: task.task_generation, + digest: task.task_digest, + }, + scope: task.scope, + role: task.role, + source, + agent_observation: { + operation_id: task.role.agent_operation_id, + entry_digest: agent.entry_digest, + agent_generation: task.role.agent_generation, + pane_generation: task.role.pane_generation, + agent_name: task.role.agent_name, + }, + status: "completed", + result: + task.role.contract_role === "validator" + ? { kind: "validation", verdict: "pass" } + : { kind: "delivery", verdict: "delivered" }, + summary: "Deterministic local development smoke result.", + findings: [], + requirement_results: requirementResults, + changed_paths: changedPaths, + artifacts: [], + completed_at: "2000-01-01T00:00:00.000Z", + }; + return { task, report: { ...draft, report_digest: reportDigest(draft) } }; +} + +function publishReport(pluginRoot, worker, report) { + const result = spawnSync( + process.execPath, + [ + join(pluginRoot, "scripts/report-publisher.mjs"), + "publish", + "--config", + configPathFor(report.scope.repository_root), + "--task", + worker.task_path, + ], + { + cwd: worker.cwd, + input: canonicalJson(report), + encoding: "utf8", + timeout: 120_000, + }, + ); + if (result.status !== 0) + fail(`report publication failed: ${(result.stderr ?? "").trim()}`); +} + +function approvalReceipt(previewed) { + const identity = previewed.preview; + return { + document_type: "herdr-conductor-stage3-approval", + schema_version: 1, + repository_key: identity.repository_key, + workspace_id: identity.workspace_id, + run_id: identity.run_id, + run_generation: identity.run_generation, + attempt_generation: identity.attempt_generation, + preview_entry_digest: previewed.preview_entry_digest, + decision: "approve", + statement: STAGE3_APPROVAL_STATEMENTS.approve, + }; +} + +function recordApproval(pluginRoot, repository, receipt) { + const result = spawnSync( + process.execPath, + [ + join(pluginRoot, "scripts/approval-recorder.mjs"), + "record", + "--config", + configPathFor(repository), + ], + { + cwd: repository, + input: canonicalJson(receipt), + encoding: "utf8", + timeout: 120_000, + }, + ); + if (result.status !== 0) + fail(`approval recording failed: ${(result.stderr ?? "").trim()}`); +} + +function createDevelopmentPlugin(temporaryRoot, herdrBin, socket) { + const pluginRoot = join(temporaryRoot, "plugin"); + cpSync(root, pluginRoot, { + recursive: true, + filter(source) { + const relativePath = relative(root, source); + return ( + relativePath !== ".git" && + !relativePath.startsWith(`.git${sep}`) && + relativePath !== "node_modules" && + !relativePath.startsWith(`node_modules${sep}`) + ); + }, + }); + const wrapperPath = join(pluginRoot, "scripts/development-093-action.sh"); + writeFileSync( + wrapperPath, + `#!/usr/bin/env bash\nset -euo pipefail\ncd "\${HERDR_PLUGIN_ROOT:?}"\nexport HERDR_BIN_PATH=${shellQuote(herdrBin)}\nexport HERDR_SOCKET_PATH=${shellQuote(socket)}\nexec node scripts/stage1-runtime.mjs "$1"\n`, + { mode: 0o700 }, + ); + chmodSync(wrapperPath, 0o700); + const manifestPath = join(pluginRoot, "herdr-plugin.toml"); + const manifest = readFileSync(manifestPath, "utf8").replace( + /command = \["bash", "scripts\/(assemble|board|status|harvest|preview|apply|stand-down)\.sh"\]/g, + (_match, action) => + `command = ["bash", "scripts/development-093-action.sh", "${action}"]`, + ); + for (const action of [ + "assemble", + "board", + "status", + "harvest", + "preview", + "apply", + "stand-down", + ]) + if ( + !manifest.includes( + `command = ["bash", "scripts/development-093-action.sh", "${action}"]`, + ) + ) + fail(`development plugin manifest did not rewrite ${action}`); + writeFileSync(manifestPath, manifest); + return realpathSync(pluginRoot); +} + +function createRepository(repository, stateRoot) { + mkdirSync(repository, { recursive: true }); + command("git", ["init", "-q", "-b", "main", repository]); + git(repository, "config", "user.name", "Conductor Development Smoke"); + git( + repository, + "config", + "user.email", + "conductor-development-smoke@local.invalid", + ); + writeFileSync(join(repository, "base.txt"), "Herdr 0.9.3 development smoke\n"); + writeFileSync( + configPathFor(repository), + canonicalJson({ + version: 3, + apply: { target_ref: "refs/heads/development-smoke-target" }, + state_root: { kind: "absolute", path: stateRoot }, + worktree_root: ".conductor-worktrees", + roles: [ + { + name: "builder", + contract_role: "builder", + kind: "pi", + mode: "write", + assignment: { + title: "Create the deterministic development-smoke file", + mission: "Exercise installed Conductor actions without a model.", + acceptance_criteria: [ + { + id: "smoke-file", + text: "The deterministic smoke file is committed.", + }, + ], + owned_paths: ["src"], + forbidden_paths: [], + required_commands: [], + }, + validator_artifacts: [], + }, + { + name: "validator", + contract_role: "validator", + kind: "pi", + mode: "gated", + assignment: { + title: "Validate the deterministic integration snapshot", + mission: "Exercise the exact-SHA gate without a model.", + acceptance_criteria: [ + { + id: "exact-sha", + text: "The gate source is the integration SHA.", + }, + ], + owned_paths: [], + forbidden_paths: [], + required_commands: [], + }, + validator_artifacts: [], + }, + ], + }), + ); + git(repository, "add", "base.txt", ".herdr-conductor.json"); + command("git", ["-C", repository, "commit", "-qm", "development smoke base"], { + env: { + ...process.env, + GIT_AUTHOR_DATE: "2000-01-01T00:00:00Z", + GIT_COMMITTER_DATE: "2000-01-01T00:00:00Z", + }, + }); + const fork = git(repository, "rev-parse", "HEAD"); + git( + repository, + "update-ref", + "refs/heads/development-smoke-target", + fork, + ); + return fork; +} + +function installSyntheticPi(home, temporaryRoot, herdrBin, session, socket) { + const binDirectory = join(temporaryRoot, "synthetic-bin"); + mkdirSync(binDirectory, { mode: 0o700 }); + const piPath = join(binDirectory, "pi"); + writeFileSync( + piPath, + `#!/usr/bin/env bash\nset -euo pipefail\nPANE="\${HERDR_PANE_ID:?}"\nSESSION_ID="development-smoke-\${PANE//:/-}"\n# Agent detection runs on a periodic process scan. Reporting before the fake\n# foreground pi process is detected is discarded when detection publishes Pi.\nsleep 1\n${shellQuote(herdrBin)} --session ${shellQuote(session)} pane report-agent-session "$PANE" --source herdr:pi --agent pi --seq 1 --session-start-source startup --agent-session-id "$SESSION_ID" >/dev/null\n${shellQuote(herdrBin)} --session ${shellQuote(session)} pane report-agent "$PANE" --source herdr:pi --agent pi --state idle --seq 2 --agent-session-id "$SESSION_ID" >/dev/null\nprintf 'synthetic pi fixture ready\\n'\nwhile IFS= read -r _line; do :; done\n`, + { mode: 0o700 }, + ); + chmodSync(piPath, 0o700); + const profileContent = `export PATH=${shellQuote(binDirectory)}:"$PATH"\nexport HERDR_BIN_PATH=${shellQuote(herdrBin)}\nexport HERDR_SOCKET_PATH=${shellQuote(socket)}\n`; + const profiles = [".zshenv", ".zshrc", ".bash_profile", ".bashrc", ".profile"].map( + (name) => join(home, name), + ); + for (const profile of profiles) + if (existsSync(profile)) + fail(`isolated HOME profile already exists and will not be overwritten: ${profile}`); + for (const profile of profiles) + writeFileSync(profile, profileContent, { mode: 0o600 }); + return () => { + for (const profile of profiles) rmSync(profile, { force: true }); + }; +} + +async function main() { + if (process.env.CONDUCTOR_093_DEVELOPMENT_SMOKE !== OPT_IN) + fail( + `refusing development smoke: set CONDUCTOR_093_DEVELOPMENT_SMOKE=${OPT_IN}`, + ); + const herdrBin = realpathSync( + process.env.CONDUCTOR_093_HERDR_BIN ?? + fail("CONDUCTOR_093_HERDR_BIN is required"), + ); + const session = process.env.CONDUCTOR_093_SESSION; + if (!session || session === "default") + fail("CONDUCTOR_093_SESSION must name a non-default isolated session"); + const socket = realpathSync( + process.env.HERDR_SOCKET_PATH ?? fail("HERDR_SOCKET_PATH is required"), + ); + const home = realpathSync(process.env.HOME ?? fail("isolated HOME is required")); + const temporaryParent = realpathSync(tmpdir()); + const allowedHomeParents = [ + temporaryParent, + ...(existsSync("/tmp") ? [realpathSync("/tmp")] : []), + ]; + if (!allowedHomeParents.some((parent) => contained(parent, home))) + fail("HOME must be a child of a recognized temporary directory"); + const expectedSocketSuffix = join("sessions", session, "herdr.sock"); + if (!socket.endsWith(expectedSocketSuffix) || !contained(home, socket)) + fail("HERDR_SOCKET_PATH is not the named isolated session socket under HOME"); + if (command(herdrBin, ["--version"]) !== "herdr 0.9.3") + fail("development smoke requires the exact Herdr 0.9.3 client"); + const herdrEnv = { + ...process.env, + HERDR_BIN_PATH: herdrBin, + HERDR_SOCKET_PATH: socket, + }; + const runHerdr = (args) => + parseJson( + command(herdrBin, ["--session", session, ...args], { env: herdrEnv }), + `herdr ${args.join(" ")}`, + ); + const runHerdrCommand = (args) => + command(herdrBin, ["--session", session, ...args], { env: herdrEnv }); + const status = runHerdr(["status", "server", "--json"]); + if ( + status.status !== "running" || + status.running !== true || + status.version !== "0.9.3" || + status.protocol !== 22 || + status.compatible !== true || + status.endpoint_compatible !== true || + status.restart_needed !== false || + status.server_binary_stale !== false || + status.session !== session || + realpathSync(status.socket) !== socket + ) + fail("named server is not the exact healthy isolated 0.9.3/protocol 22 server"); + const api = runHerdr(["api", "schema", "--json"]); + if (api.protocol !== 22 || api.schema_version !== 1) + fail("named client does not expose protocol 22/schema 1"); + const priorPluginRecord = findObject( + runHerdr(["plugin", "list", "--json"]), + (value) => value?.plugin_id === PLUGIN_ID, + ); + let priorPlugin = null; + if (priorPluginRecord) { + if ( + priorPluginRecord.source?.kind !== "local" || + typeof priorPluginRecord.plugin_root !== "string" || + typeof priorPluginRecord.enabled !== "boolean" + ) + fail("existing isolated Conductor plugin cannot be restored exactly"); + priorPlugin = { + root: realpathSync(priorPluginRecord.plugin_root), + enabled: priorPluginRecord.enabled, + }; + } + + const temporaryRoot = realpathSync( + mkdtempSync(join(temporaryParent, "herdr-conductor-093-development-")), + ); + chmodSync(temporaryRoot, 0o700); + const stateRoot = join(temporaryRoot, "state"); + const repository = join(temporaryRoot, "repository"); + mkdirSync(stateRoot, { mode: 0o700 }); + let removeProfiles = () => {}; + let profilesRemoved = false; + let pluginLinked = false; + let priorPluginRemoved = false; + let workspaceId = null; + let workspaceClosed = false; + let completed = false; + let summary; + const cleanupErrors = []; + try { + const pluginRoot = createDevelopmentPlugin(temporaryRoot, herdrBin, socket); + removeProfiles = installSyntheticPi( + home, + temporaryRoot, + herdrBin, + session, + socket, + ); + const fork = createRepository(repository, stateRoot); + if (priorPlugin) { + runHerdr(["plugin", "unlink", PLUGIN_ID]); + priorPluginRemoved = true; + } + runHerdr(["plugin", "link", pluginRoot, "--enabled"]); + pluginLinked = true; + const workspaceCreated = runHerdr([ + "workspace", + "create", + "--cwd", + repository, + "--label", + "Conductor 0.9.3 development smoke", + "--focus", + ]); + const workspace = findObject( + workspaceCreated, + (value) => typeof value?.workspace_id === "string", + ); + const rootPane = findObject( + workspaceCreated, + (value) => + typeof value?.pane_id === "string" && + value?.workspace_id === workspace?.workspace_id, + ); + if (!workspace || !rootPane) fail("workspace creation omitted identities"); + workspaceId = workspace.workspace_id; + runHerdrCommand([ + "pane", + "run", + rootPane.pane_id, + "command -v pi >/dev/null && printf 'synthetic-pi-ready\\n'", + ]); + runHerdrCommand([ + "pane", + "wait-output", + rootPane.pane_id, + "--match", + "synthetic-pi-ready", + "--timeout", + "30000", + ]); + + const invokeLog = (actionId) => { + runHerdr(["workspace", "focus", workspaceId]); + const invoked = runHerdr([ + "plugin", + "action", + "invoke", + actionId, + "--plugin", + PLUGIN_ID, + ]); + const invocation = findObject( + invoked, + (value) => + typeof value?.log?.log_id === "string" && + value?.action?.action_id === actionId, + ); + if (!invocation) fail(`Herdr omitted ${actionId} action log identity`); + const deadline = Date.now() + 180_000; + let log; + while (Date.now() < deadline) { + const listed = runHerdr([ + "plugin", + "log", + "list", + "--plugin", + PLUGIN_ID, + "--limit", + "100", + ]); + log = findObject( + listed, + (value) => value?.log_id === invocation.log.log_id, + ); + if (log && log.status !== "running") break; + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 100); + } + if (!log || log.status === "running") + fail(`Herdr ${actionId} action did not reach a terminal state`); + return { + payload: log, + output: `${log.stdout ?? ""}\n${log.stderr ?? ""}`.trim(), + }; + }; + const invoke = (actionId) => { + const result = invokeLog(actionId); + if (result.payload.status !== "succeeded" || result.payload.exit_code !== 0) + fail(`Herdr ${actionId} action failed: ${result.output}`); + return result; + }; + const invokeRefusal = (actionId) => { + const result = invokeLog(actionId); + if (result.payload.status === "succeeded" || result.payload.exit_code === 0) + fail(`Herdr ${actionId} action unexpectedly succeeded`); + return result; + }; + + const assembled = embeddedJson( + invoke("assemble"), + (value) => + typeof value?.run_id === "string" && Array.isArray(value?.workers), + ); + assert.equal(assembled.workers.length, 1); + invoke("status"); + embeddedJson( + invoke("board"), + (value) => + value?.run === assembled.run_id && value?.workspace_id === workspaceId, + ); + const builder = assembled.workers[0]; + mkdirSync(join(builder.cwd, "src")); + writeFileSync(join(builder.cwd, "src", "smoke.txt"), "verified local fixture\n"); + git(builder.cwd, "add", "src/smoke.txt"); + command("git", ["-C", builder.cwd, "commit", "-qm", "development smoke change"], { + env: { + ...process.env, + GIT_AUTHOR_DATE: "2000-01-01T00:01:00Z", + GIT_COMMITTER_DATE: "2000-01-01T00:01:00Z", + }, + }); + const stateRepositoryPath = join( + stateRoot, + "v1", + "repositories", + repositoryKey(repository), + ); + publishReport( + pluginRoot, + builder, + buildReport(builder, stateRepositoryPath, workspaceId, assembled).report, + ); + const firstHarvest = embeddedJson( + invoke("harvest"), + (value) => + value?.lifecycle === "gate_waiting_reports" && + Array.isArray(value?.gate_workers), + ); + assert.equal(firstHarvest.gate_workers.length, 1); + const validator = firstHarvest.gate_workers[0]; + assert.equal( + git(validator.cwd, "rev-parse", "HEAD"), + firstHarvest.integration.final_sha, + ); + publishReport( + pluginRoot, + validator, + buildReport(validator, stateRepositoryPath, workspaceId, assembled).report, + ); + const secondHarvest = embeddedJson( + invoke("harvest"), + (value) => value?.lifecycle === "gate_reports_collected", + ); + const targetBefore = git( + repository, + "rev-parse", + "refs/heads/development-smoke-target", + ); + assert.equal(targetBefore, fork); + const previewed = embeddedJson( + invoke("preview"), + (value) => + value?.lifecycle === "apply_previewed" && + typeof value?.preview_entry_digest === "string", + ); + invokeRefusal("apply"); + assert.equal( + git(repository, "rev-parse", "refs/heads/development-smoke-target"), + targetBefore, + ); + recordApproval(pluginRoot, repository, approvalReceipt(previewed)); + const applied = embeddedJson( + invoke("apply"), + (value) => value?.lifecycle === "applied" && value?.apply?.cas_count === 1, + ); + assert.equal( + git(repository, "rev-parse", "refs/heads/development-smoke-target"), + secondHarvest.integration.final_sha, + ); + const replayedApply = embeddedJson( + invoke("apply"), + (value) => value?.lifecycle === "applied" && value?.replayed === true, + ); + const stoodDown = embeddedJson( + invoke("stand-down"), + (value) => value?.archived === true && Array.isArray(value?.closed), + ); + const replayedStandDown = embeddedJson( + invoke("stand-down"), + (value) => value?.archived === true && value?.replayed === true, + ); + assert.equal(stoodDown.closed.length, 2); + summary = { + document_type: "herdr-conductor-0.9.3-development-smoke", + schema_version: 1, + certification: false, + result: "passed", + runtime: { version: "0.9.3", protocol: 22, api_schema: 1 }, + actions: [ + "assemble", + "status", + "board", + "harvest-producer", + "harvest-validator", + "preview", + "apply-refusal-before-receipt", + "apply", + "apply-replay", + "stand-down", + "stand-down-replay", + ], + integration_final_sha: secondHarvest.integration.final_sha, + apply_target_before: targetBefore, + apply_target_after: applied.apply.final_sha, + apply_replayed: replayedApply.replayed, + closed_worker_count: stoodDown.closed.length, + stand_down_replayed: replayedStandDown.replayed, + limitations: [ + "development-only", + "synthetic local shell workers", + "unauthenticated worker assertions", + "no model or network integration", + "not formal evidence or production approval", + ], + }; + completed = true; + } finally { + if (completed && workspaceId && !workspaceClosed) { + try { + runHerdr(["workspace", "close", workspaceId]); + workspaceClosed = true; + } catch (error) { + cleanupErrors.push(`workspace close: ${error.message}`); + } + } + if (pluginLinked) { + try { + runHerdr(["plugin", "unlink", PLUGIN_ID]); + pluginLinked = false; + } catch (error) { + cleanupErrors.push(`plugin unlink: ${error.message}`); + } + } + if (priorPluginRemoved) { + try { + runHerdr([ + "plugin", + "link", + priorPlugin.root, + priorPlugin.enabled ? "--enabled" : "--disabled", + ]); + priorPluginRemoved = false; + } catch (error) { + cleanupErrors.push(`prior plugin restoration: ${error.message}`); + } + } + try { + removeProfiles(); + profilesRemoved = true; + } catch (error) { + cleanupErrors.push(`profile removal: ${error.message}`); + } + if (completed) { + summary.artifacts = { + temporary_root: temporaryRoot, + repository, + state_root: stateRoot, + retention: "private development evidence; manual disposal required", + }; + summary.cleanup = { + workspace: workspaceClosed ? "closed" : "failed", + development_plugin: pluginLinked ? "failed" : "unlinked", + temporary_profiles: profilesRemoved ? "removed" : "failed", + prior_plugin: priorPlugin + ? priorPluginRemoved + ? "failed" + : "restored" + : "not_present", + }; + if (cleanupErrors.length > 0) + retainedFailure = { + temporary_root: temporaryRoot, + state_root: stateRoot, + repository, + workspace_id: workspaceId, + operations: retainedOperations(stateRoot), + cleanup_errors: cleanupErrors, + }; + } else { + retainedFailure = { + temporary_root: temporaryRoot, + state_root: stateRoot, + repository, + workspace_id: workspaceId, + operations: retainedOperations(stateRoot), + cleanup_errors: cleanupErrors, + }; + } + } + if (cleanupErrors.length > 0) + fail(`development smoke cleanup failed: ${cleanupErrors.join("; ")}`); + if (!completed) fail("development smoke did not complete"); + process.stdout.write(`${canonicalJson(summary)}\n`); +} + +main().catch((error) => { + process.stderr.write(`herdr-conductor development smoke: ${error.message}\n`); + if (retainedFailure) + process.stderr.write( + `retained failed development smoke:\n${JSON.stringify(retainedFailure, null, 2)}\n`, + ); + process.exitCode = 1; +}); diff --git a/tests/fixtures/b3-crash-child.mjs b/tests/fixtures/b3-crash-child.mjs index 683ade5..1745080 100644 --- a/tests/fixtures/b3-crash-child.mjs +++ b/tests/fixtures/b3-crash-child.mjs @@ -49,6 +49,15 @@ const exec = (command, args, options = {}) => { if (args.length === 1 && args[0] === "--version") return "herdr 0.7.5"; if (args[0] === "api" && args[1] === "schema") return JSON.stringify({ protocol: 17, schema_version: 1 }); + if (args.join(" ") === "status server --json") + return JSON.stringify({ + status: "running", + running: true, + version: "0.7.5", + protocol: 17, + compatible: true, + restart_needed: false, + }); const live = JSON.parse(readFileSync(livePath, "utf8")); let result; if (args[0] === "pane" && args[1] === "list") diff --git a/tests/fixtures/fake-herdr.mjs b/tests/fixtures/fake-herdr.mjs index a2327c2..7a69fb8 100755 --- a/tests/fixtures/fake-herdr.mjs +++ b/tests/fixtures/fake-herdr.mjs @@ -26,6 +26,17 @@ if (args.length === 1 && args[0] === "--version") { } else if (args[0] === "api" && args[1] === "schema") { assert.deepEqual(args, ["api", "schema", "--json"]); result({ protocol: 17, schema_version: 1 }); +} else if (args.join(" ") === "status server --json") { + process.stdout.write( + JSON.stringify({ + status: "running", + running: true, + version: "0.7.5", + protocol: 17, + compatible: true, + restart_needed: false, + }), + ); } else if (args[0] === "pane" && args[1] === "get") { assert.deepEqual(args, ["pane", "get", args[2]]); assert.ok(args[2] === anchor || Object.hasOwn(state.panes, args[2])); diff --git a/tests/fixtures/stage1-crash-child.mjs b/tests/fixtures/stage1-crash-child.mjs index 1af56b4..d9a23e8 100644 --- a/tests/fixtures/stage1-crash-child.mjs +++ b/tests/fixtures/stage1-crash-child.mjs @@ -104,6 +104,15 @@ function fakeExec(command, args) { assert.deepEqual(args, ["api", "schema", "--json"]); return JSON.stringify({ protocol: 17, schema_version: 1 }); } + if (args.join(" ") === "status server --json") + return JSON.stringify({ + status: "running", + running: true, + version: "0.7.5", + protocol: 17, + compatible: true, + restart_needed: false, + }); if (args[0] === "pane" && args[1] === "get") { assert.deepEqual(args, ["pane", "get", args[2]]); assert.ok(args[2] === `${workspace}:p0` || panes.has(args[2])); diff --git a/tests/fixtures/stage3-crash-child.mjs b/tests/fixtures/stage3-crash-child.mjs index 9d495c9..a5cbcd7 100644 --- a/tests/fixtures/stage3-crash-child.mjs +++ b/tests/fixtures/stage3-crash-child.mjs @@ -8,6 +8,15 @@ function exec(command, args, options = {}) { if (args.length === 1 && args[0] === "--version") return "herdr 0.7.5"; if (args.join(" ") === "api schema --json") return JSON.stringify({ protocol: 17, schema_version: 1 }); + if (args.join(" ") === "status server --json") + return JSON.stringify({ + status: "running", + running: true, + version: "0.7.5", + protocol: 17, + compatible: true, + restart_needed: false, + }); throw new Error(`unexpected fake Herdr call: ${args.join(" ")}`); } return execFileSync(command, args, { diff --git a/tests/stage1-runtime-assemble.test.mjs b/tests/stage1-runtime-assemble.test.mjs index 8b93664..dd72e9d 100644 --- a/tests/stage1-runtime-assemble.test.mjs +++ b/tests/stage1-runtime-assemble.test.mjs @@ -127,6 +127,10 @@ test("strict fake Git/Herdr calls see durable intent before every effect and pre assert.deepEqual(fake.log, [ { command: "fake-herdr", args: ["--version"] }, { command: "fake-herdr", args: ["api", "schema", "--json"] }, + { + command: "fake-herdr", + args: ["status", "server", "--json"], + }, { command: "fake-herdr", args: ["pane", "get", "wB2:p0"] }, { command: "git", @@ -306,6 +310,7 @@ test("populated repository/workspace scopes select exactly one run without forei [ "--version", "api schema --json", + "status server --json", "pane list", "agent list", ].includes(call.args.join(" ")), @@ -572,8 +577,16 @@ test("pane and agent cwd plus foreground_cwd are independently required", async } }); -test("exact Herdr 0.7.5 protocol gate rejects other and unparseable runtimes before effects", async () => { - for (const variant of ["version", "protocol", "unparseable"]) { +test("exact Herdr 0.7.5 runtime and server gate rejects every mismatch before effects", async () => { + for (const variant of [ + "version", + "protocol", + "unparseable", + "server_version", + "server_protocol", + "server_incompatible", + "server_restart", + ]) { const repository = repo(); const stateRoot = join(temp("conductor-version-gate-"), "state"); const fake = new FakeHerdr(); @@ -589,6 +602,15 @@ test("exact Herdr 0.7.5 protocol gate rejects other and unparseable runtimes bef protocol: variant === "protocol" ? 18 : 17, schema_version: 1, }); + if (command === "fake" && args.join(" ") === "status server --json") + return JSON.stringify({ + status: "running", + running: true, + version: variant === "server_version" ? "0.7.4" : "0.7.5", + protocol: variant === "server_protocol" ? 16 : 17, + compatible: variant !== "server_incompatible", + restart_needed: variant === "server_restart", + }); return fake.exec(command, args); }; await expectCodeAsync("unsupported_herdr", () => diff --git a/tests/stage1-runtime-helpers.mjs b/tests/stage1-runtime-helpers.mjs index cafb47f..c50eadb 100644 --- a/tests/stage1-runtime-helpers.mjs +++ b/tests/stage1-runtime-helpers.mjs @@ -391,6 +391,15 @@ class FakeHerdr { assert.deepEqual(args, ["api", "schema", "--json"]); return JSON.stringify({ protocol: 17, schema_version: 1 }); } + if (args.join(" ") === "status server --json") + return JSON.stringify({ + status: "running", + running: true, + version: "0.7.5", + protocol: 17, + compatible: true, + restart_needed: false, + }); if (args[0] === "pane" && args[1] === "split") { const cwd = args[6]; const role = this.roles.find( @@ -724,11 +733,24 @@ function inspectKilledOperation({ if (args[0] === "--version") return "herdr 0.7.5"; if (args.join(" ") === "api schema --json") return JSON.stringify({ protocol: 17, schema_version: 1 }); + if (args.join(" ") === "status server --json") + return JSON.stringify({ + status: "running", + running: true, + version: "0.7.5", + protocol: 17, + compatible: true, + restart_needed: false, + }); assert.fail("unresolved operation reached a live identity probe"); }, }), ); - assert.deepEqual(runtimeProbes, [["--version"], ["api", "schema", "--json"]]); + assert.deepEqual(runtimeProbes, [ + ["--version"], + ["api", "schema", "--json"], + ["status", "server", "--json"], + ]); return { run, current, guards, lockOwner }; } diff --git a/tests/stage1-runtime-herdr-identity.test.mjs b/tests/stage1-runtime-herdr-identity.test.mjs new file mode 100644 index 0000000..e6053ac --- /dev/null +++ b/tests/stage1-runtime-herdr-identity.test.mjs @@ -0,0 +1,299 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { requireHerdrRuntime } from "../scripts/herdr-identity.mjs"; + +const methodParams = { + "agent.list": "EmptyParams", + "agent.get": "AgentTarget", + "agent.start": "AgentStartParams", + "pane.split": "PaneSplitParams", + "pane.list": "PaneListParams", + "pane.get": "PaneTarget", + "pane.report_metadata": "PaneReportMetadataParams", + "pane.close": "PaneTarget", +}; + +function schema093() { + const nullableString = { type: ["string", "null"] }; + const identity = { + workspace_id: { type: "string" }, + pane_id: { type: "string" }, + terminal_id: { type: "string" }, + cwd: nullableString, + foreground_cwd: nullableString, + tokens: { type: "object", additionalProperties: { type: "string" } }, + agent_session: { + anyOf: [ + { $ref: "#/schemas/success_response/$defs/AgentSessionInfo" }, + { type: "null" }, + ], + }, + }; + const paneRef = "#/schemas/success_response/$defs/PaneInfo"; + const agentRef = "#/schemas/success_response/$defs/AgentInfo"; + return { + protocol: 22, + schema_version: 1, + schemas: { + request: { + oneOf: Object.entries(methodParams).map(([method, params]) => ({ + type: "object", + required: ["method", "params"], + properties: { + method: { type: "string", const: method }, + params: { $ref: `#/schemas/request/$defs/${params}` }, + }, + })), + $defs: { + EmptyParams: { type: "object" }, + PaneTarget: { + type: "object", + required: ["pane_id"], + properties: { pane_id: { type: "string" } }, + }, + PaneListParams: { type: "object" }, + SplitDirection: { type: "string", enum: ["right", "down"] }, + PaneSplitParams: { + type: "object", + required: ["direction"], + properties: { + direction: { + $ref: "#/schemas/request/$defs/SplitDirection", + }, + target_pane_id: nullableString, + cwd: nullableString, + focus: { type: "boolean" }, + }, + }, + PaneReportMetadataParams: { + type: "object", + required: ["pane_id", "source"], + properties: { + pane_id: { type: "string" }, + source: { type: "string" }, + tokens: { type: "object" }, + }, + }, + AgentTarget: { + type: "object", + required: ["target"], + properties: { target: { type: "string" } }, + }, + AgentStartParams: { + type: "object", + required: ["name", "kind", "pane_id"], + properties: { + name: { type: "string" }, + kind: { type: "string" }, + pane_id: { type: "string" }, + timeout_ms: { type: ["integer", "null"] }, + }, + }, + PluginInvocationContext: { + type: "object", + properties: { + workspace_id: nullableString, + workspace_cwd: nullableString, + focused_pane_id: nullableString, + }, + }, + }, + }, + success_response: { + $defs: { + AgentSessionInfo: { + type: "object", + required: ["source", "agent", "kind", "value"], + properties: { + source: { type: "string" }, + agent: { type: "string" }, + kind: { type: "string" }, + value: { type: "string" }, + }, + }, + PaneInfo: { + type: "object", + required: ["workspace_id", "pane_id", "terminal_id"], + properties: identity, + }, + AgentInfo: { + type: "object", + required: ["workspace_id", "pane_id", "terminal_id"], + properties: { + ...identity, + name: nullableString, + agent_status: { type: "string" }, + }, + }, + ResponseResult: { + oneOf: [ + { + required: ["type", "pane"], + properties: { + type: { const: "pane_info" }, + pane: { $ref: paneRef }, + }, + }, + { + required: ["type", "panes"], + properties: { + type: { const: "pane_list" }, + panes: { type: "array", items: { $ref: paneRef } }, + }, + }, + { + required: ["type", "agent"], + properties: { + type: { const: "agent_info" }, + agent: { $ref: agentRef }, + }, + }, + { + required: ["type", "agents"], + properties: { + type: { const: "agent_list" }, + agents: { type: "array", items: { $ref: agentRef } }, + }, + }, + { + required: ["type", "agent", "argv"], + properties: { + type: { const: "agent_started" }, + agent: { $ref: agentRef }, + argv: { type: "array", items: { type: "string" } }, + }, + }, + { + required: ["type"], + properties: { type: { const: "ok" } }, + }, + ], + }, + }, + }, + }, + }; +} + +function server093() { + return { + status: "running", + running: true, + version: "0.9.3", + protocol: 22, + compatible: true, + endpoint_compatible: true, + restart_needed: false, + server_binary_stale: false, + }; +} + +function candidateExec(schema = schema093(), server = server093()) { + const calls = []; + return { + calls, + exec(command, args) { + assert.equal(command, "candidate-herdr"); + calls.push(args); + if (args.join(" ") === "--version") return "herdr 0.9.3"; + if (args.join(" ") === "api schema --json") return JSON.stringify(schema); + if (args.join(" ") === "status server --json") + return JSON.stringify(server); + assert.fail(`unexpected command: ${args.join(" ")}`); + }, + }; +} + +test("legacy 0.7.5 keeps its exact protocol and accepts the legacy status shape", () => { + const calls = []; + const profile = requireHerdrRuntime((_command, args) => { + calls.push(args); + if (args.join(" ") === "--version") return "herdr 0.7.5"; + if (args.join(" ") === "api schema --json") + return JSON.stringify({ protocol: 17, schema_version: 1 }); + if (args.join(" ") === "status server --json") + return JSON.stringify({ + status: "running", + running: true, + version: "0.7.5", + protocol: 17, + compatible: true, + restart_needed: false, + }); + assert.fail(`unexpected command: ${args.join(" ")}`); + }, "legacy-herdr"); + assert.deepEqual(profile, { version: "0.7.5", protocol: 17, schemaVersion: 1 }); + assert.deepEqual(calls, [ + ["--version"], + ["api", "schema", "--json"], + ["status", "server", "--json"], + ]); +}); + +test("0.9.3 requires the proven API capabilities and exact live server identity", () => { + const fake = candidateExec(); + assert.deepEqual(requireHerdrRuntime(fake.exec, "candidate-herdr"), { + version: "0.9.3", + protocol: 22, + schemaVersion: 1, + }); + assert.deepEqual(fake.calls, [ + ["--version"], + ["api", "schema", "--json"], + ["status", "server", "--json"], + ]); +}); + +test("0.9.3 refuses missing API capabilities and stale or incompatible servers", () => { + const cases = [ + { + name: "wrong protocol", + change(schema) { + schema.protocol = 21; + }, + }, + { + name: "missing close method", + change(schema) { + schema.schemas.request.oneOf = schema.schemas.request.oneOf.filter( + (candidate) => candidate.properties.method.const !== "pane.close", + ); + }, + }, + { + name: "missing identity tokens", + change(schema) { + delete schema.schemas.success_response.$defs.PaneInfo.properties.tokens; + }, + }, + { + name: "server version mismatch", + change(_schema, server) { + server.version = "0.9.2"; + }, + }, + { + name: "endpoint incompatible", + change(_schema, server) { + server.endpoint_compatible = false; + }, + }, + { + name: "stale server binary", + change(_schema, server) { + server.server_binary_stale = true; + }, + }, + ]; + for (const fixture of cases) { + const schema = schema093(); + const server = server093(); + fixture.change(schema, server); + const fake = candidateExec(schema, server); + assert.throws( + () => requireHerdrRuntime(fake.exec, "candidate-herdr"), + (error) => error?.code === "unsupported_herdr", + fixture.name, + ); + } +}); diff --git a/tests/stage2-crash-boundary.test.mjs b/tests/stage2-crash-boundary.test.mjs index 10ea534..44477a8 100644 --- a/tests/stage2-crash-boundary.test.mjs +++ b/tests/stage2-crash-boundary.test.mjs @@ -239,6 +239,15 @@ test("true SIGKILL covers producer pane barrier, lock acquisition, and run activ if (args.length === 1 && args[0] === "--version") return "herdr 0.7.5"; if (args[0] === "api" && args[1] === "schema") return JSON.stringify({ protocol: 17, schema_version: 1 }); + if (args.join(" ") === "status server --json") + return JSON.stringify({ + status: "running", + running: true, + version: "0.7.5", + protocol: 17, + compatible: true, + restart_needed: false, + }); throw new Error(`unexpected read-only command: ${args.join(" ")}`); }; if (owner === "run_activation") @@ -373,6 +382,15 @@ test("catalog-driven true SIGKILL covers every producer task-publication checkpo if (args.length === 1 && args[0] === "--version") return "herdr 0.7.5"; if (args[0] === "api" && args[1] === "schema") return JSON.stringify({ protocol: 17, schema_version: 1 }); + if (args.join(" ") === "status server --json") + return JSON.stringify({ + status: "running", + running: true, + version: "0.7.5", + protocol: 17, + compatible: true, + restart_needed: false, + }); throw new Error(`unexpected read-only command: ${args.join(" ")}`); }; const terminalReadOnly =