/** * The child-side half of the mutation verbs (#126): a thin client, and nothing * else. * * This module runs **inside the session process**, as the daemon's own user, so * it is code the model can read and on a bad day rewrite — and it can reach the * same credentials the daemon can. That is exactly why it decides nothing: * * - **No policy branch.** It does not know what `authority.merge` says, which * release shapes are granted, or whether the fleet is paused. Every answer * it renders was decided on the other side of the socket. * - **No credential.** It never reads a token, an env var holding one, or a * config file. Look at this file's imports: `node:net`, and the verb table. * There is nothing here to steal. * - **No local fallback.** No socket means the verb fails, loudly, with an * explanation. A client that fell back to `git push` when the daemon was * unreachable would leave the settlement record silently incomplete, and it * would do so precisely when something had already gone wrong. * * The identity fields are absent by construction rather than stripped: this * client has no project, run or role to send, because it was never told one. */ import { connect } from "node:net"; import { VERB_NAMES } from "../types.ts"; import type { VerbName } from "../types.ts"; import { VERB_SPECS, verbParameterSchema } from "./protocol.ts"; /** * Where the daemon told this session its socket is. * * An environment variable rather than a config lookup, because a config lookup * is a decision: the child would have to work out *which* run it is, and that * is the question the whole transport exists to stop it answering. The daemon * sets exactly one path per child, and that path is the child's whole identity. */ export const VERB_SOCKET_ENV = "OMP_CONDUCTOR_VERB_SOCKET"; /** Long enough for a `gh pr merge` behind branch protection, short enough that * a wedged daemon does not consume the session's wall clock. */ const CALL_TIMEOUT_MS = 120_000; export interface VerbCallResult { ok: boolean; /** Rendered verbatim into the tool result. Never rewritten by this client. */ text: string; } /** * What a session is told when there is no socket to call. * * Fails closed and says why, rather than suggesting a workaround: the correct * next step is to report and stop, and a message that hinted at `git push` * would be read as permission. */ export function noSocketResult(verb: VerbName): VerbCallResult { return { ok: false, text: `${verb} is unavailable: this session has no conductor verb socket (${VERB_SOCKET_ENV} is unset ` + "or the daemon did not create one). This tool is a client for the daemon, which holds the credential " + "and makes the decision; there is no local way to do this and you must not attempt one. Report that " + "the verb socket is missing and stop.", }; } /** * One request, one reply, one connection. * * Deliberately not a pooled or persistent connection: the daemon asserts peer * credentials at accept time, and a connection that outlives the turn it was * opened for is a connection whose caller was verified once and reused many * times. */ export async function callVerb( socketPath: string | undefined, verb: VerbName, args: Record, timeoutMs = CALL_TIMEOUT_MS, ): Promise { if (socketPath === undefined || socketPath.length === 0) return noSocketResult(verb); return new Promise((resolve) => { let settled = false; const finish = (result: VerbCallResult): void => { if (settled) return; settled = true; socket.destroy(); resolve(result); }; const socket = connect(socketPath); socket.setTimeout(timeoutMs, () => { finish({ ok: false, text: `${verb} timed out after ${Math.round(timeoutMs / 1000)}s waiting for the conductor daemon. Nothing was retried; ask again or report it.`, }); }); socket.on("error", (err: NodeJS.ErrnoException) => { finish({ ok: false, text: `${verb} could not reach the conductor daemon on ${socketPath} (${err.code ?? err.message}). ` + "The daemon decides and executes this verb; there is no local fallback. Report it and stop.", }); }); let buffer = ""; socket.on("data", (chunk) => { buffer += chunk.toString("utf8"); const newline = buffer.indexOf("\n"); if (newline >= 0) finish(render(verb, buffer.slice(0, newline))); }); socket.on("end", () => { if (buffer.trim().length > 0) finish(render(verb, buffer)); else finish({ ok: false, text: `${verb} was refused at the transport: the daemon closed the connection without a reply. ` + "That is what an unverified caller gets — check the daemon log for an impersonation entry.", }); }); // Identity is not in this payload because this client does not have one. socket.write(`${JSON.stringify({ verb, args })}\n`); }); } /** * Render the daemon's answer. The only transformation is JSON parsing: the * refusal text is the daemon's wording, verbatim, because a client that * paraphrased a refusal would be a second, weaker copy of the policy. */ function render(verb: VerbName, line: string): VerbCallResult { try { const reply = JSON.parse(line) as { ok?: unknown; text?: unknown }; if (typeof reply.text !== "string") { return { ok: false, text: `${verb}: the daemon sent a reply with no text.` }; } return { ok: reply.ok === true, text: reply.text }; } catch { return { ok: false, text: `${verb}: the daemon sent an unreadable reply.` }; } } /** The tool-result shape the harness expects back from `execute`. */ interface VerbToolResult { content: { type: "text"; text: string }[]; isError?: boolean; } /** * The members of the harness extension API this client uses. Declared * structurally for the reason `ReleasePolicyPi` is: the harness is an optional * peer dependency that is absent when this package is type-checked. */ export interface VerbPi { registerTool(tool: { name: string; label: string; description: string; parameters: unknown; approval?: "read" | "write" | "exec"; execute( toolCallId: string, params: Record, ): Promise; }): void; } /** * Register every verb on a session. * * *Every* verb, on every session, including the ones this session will always * be refused. That is not an oversight: the refusal is the teaching surface. A * worker that cannot see `conductor_pr_merge` learns nothing when it wants to * merge, whereas a worker that calls it is told "merge authority is the * orchestrator's" — and the attempt lands in the ledger, where an operator can * see that a worker tried. Hiding the tool would move that signal nowhere. * * It is also the honest shape: filtering the list here would be this client * making a policy decision, which is exactly what it must not do. */ export function conductorVerbs(socketPath: string | undefined): (pi: VerbPi) => void { return (pi) => { for (const name of VERB_NAMES) { const spec = VERB_SPECS[name]; pi.registerTool({ name, label: name, description: spec.description, parameters: verbParameterSchema(spec), approval: "write", execute: async (_toolCallId, params) => { const result = await callVerb(socketPath, name, params ?? {}); return { content: [{ type: "text", text: result.text }], ...(result.ok ? {} : { isError: true }), }; }, }); } }; }