From 019dfa3ea523d2077a18935954c3bfe33a7f80cb Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:02:29 -0400 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=90=9B=20Type=20the=20review=20schema?= =?UTF-8?q?'s=20conditional=20branches=20so=20the=20form=20can=20compile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `xmd plan` completed its turn and then ended at the review with schema could not be compiled for the browser: strict mode: missing type "object" for keyword "required" (strictTypes) Compiling a schema for the browser takes it apart: RJSF's parser extracts `if` and `then` and compiles each as a schema of its own. The review schema's two branches reached `required` through the parent's `type`, so by the time they were compiled they were typeless schemas, which strict mode refuses. The server, compiling the whole schema in one piece, accepts them — the one shape the two sides read differently. Both branches now declare `type: "object"`. The web suite pins the rule in both directions, the packaged-document suite pins that this document follows it, and the spec says so where the preflight boundary is described. Claude-Session: https://claude.ai/code/session_01TNJwcFmnt3kYSn9gGsx9u7 --- packages/cli/src/documents/plan-command.md | 7 ++- .../cli/tests/plan-command-document.test.ts | 23 +++++++ packages/web/tests/compile.test.ts | 60 +++++++++++++++++++ specs/web-form-spec.md | 8 +++ 4 files changed, 97 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/documents/plan-command.md b/packages/cli/src/documents/plan-command.md index 2900f58ea..c9327fe32 100644 --- a/packages/cli/src/documents/plan-command.md +++ b/packages/cli/src/documents/plan-command.md @@ -155,10 +155,15 @@ you may ask the coding agent to explain what went wrong or stop. required: ["decision"], additionalProperties: false, if: { + type: "object", properties: { decision: { const: "Request changes" } }, required: ["decision"], }, - then: { required: ["feedback"], properties: { feedback: { type: "string", minLength: 1 } } }, + then: { + type: "object", + required: ["feedback"], + properties: { feedback: { type: "string", minLength: 1 } }, + }, }} > ### Original Prompt diff --git a/packages/cli/tests/plan-command-document.test.ts b/packages/cli/tests/plan-command-document.test.ts index 0eeed583e..adc0776b5 100644 --- a/packages/cli/tests/plan-command-document.test.ts +++ b/packages/cli/tests/plan-command-document.test.ts @@ -189,4 +189,27 @@ describe("the packaged plan command document", () => { expect(run.failure).toBe(undefined); expect(run.value).toBe(CANDIDATE); }); + + /** + * The review this document asks has to be servable as a browser form, which + * is how `xmd plan` asks it: `installWebElicitation` compiles the request's + * schema before a port exists. + * + * Compiling for the browser extracts each conditional branch and compiles it + * as a schema of its own, so a branch that reached `required` through its + * parent's type is refused there while the server accepts it + * (`packages/web/tests/compile.test.ts`, and specs/web-form-spec.md + * §The preflight boundary). Until both branches said `object`, a real + * `xmd plan` completed its turn and then ended at the review with + * ` schema could not be compiled for the browser`. + */ + it("C9: every conditional branch of the review schema declares its own type", function* () { + const run = yield* useWorkingDirectory(function* () { + return yield* runDocument(); + }); + + const schema = Object(run.reviews[0]?.schema); + expect(Reflect.get(Object(Reflect.get(schema, "if")), "type")).toBe("object"); + expect(Reflect.get(Object(Reflect.get(schema, "then")), "type")).toBe("object"); + }); }); diff --git a/packages/web/tests/compile.test.ts b/packages/web/tests/compile.test.ts index 15c239668..8d5b86978 100644 --- a/packages/web/tests/compile.test.ts +++ b/packages/web/tests/compile.test.ts @@ -232,6 +232,66 @@ describe("compile: the server is built the way the browser is", () => { }); } }); + + /** + * A conditional branch is compiled on its own, so it declares its own type. + * + * The generator does not compile the schema as one piece: RJSF's schema parser + * extracts `if`, `then` and the rest into separate schemas and compiles each as + * a root. A branch that leaned on its parent for `type` is then a schema with + * `required` and no type at all, which strict mode refuses — while the server, + * compiling the whole thing in one piece, reads the type from the parent and + * accepts it. + * + * So the two sides disagree about a schema an author can write, and the + * disagreement surfaces at the moment the form is asked for. This is the + * authoring rule that follows, pinned in both directions. + */ + it("refuses a conditional branch that leans on its parent for a type", function* () { + const branchWithoutType = { + type: "object", + properties: { decision: { type: "string" }, feedback: { type: "string" } }, + required: ["decision"], + if: { properties: { decision: { const: "Request changes" } }, required: ["decision"] }, + then: { required: ["feedback"], properties: { feedback: { type: "string", minLength: 1 } } }, + }; + // The server compiles it, which is the disagreement: only the message the + // browser generator produced says this schema cannot be served. + expect(() => compileForm(parseDeclaration("WebForm", branchWithoutType))).toThrow( + SchemaCompileError, + ); + expect(() => compileForm(parseDeclaration("WebForm", branchWithoutType))).toThrow( + /compiled for the browser[\s\S]*strictTypes/, + ); + + const branchWithType = { + ...branchWithoutType, + if: { + type: "object", + properties: { decision: { const: "Request changes" } }, + required: ["decision"], + }, + then: { + type: "object", + required: ["feedback"], + properties: { feedback: { type: "string", minLength: 1 } }, + }, + }; + const compiled = compileForm(parseDeclaration("WebForm", branchWithType)); + // More than one, which is the mechanism itself: the branches were extracted + // and compiled as schemas of their own, which is why each needs a type. + const registration = yield* runValidatorScript(compiled.validatorScript); + expect(Object.keys(registration.validateFns).length).toBeGreaterThan(1); + + const accepted: JsonObject[] = [ + { decision: "Approve" }, + { decision: "Request changes", feedback: "say more" }, + ]; + for (const data of accepted) { + expect({ data, server: compiled.validate(data) }).toEqual({ data, server: true }); + } + expect(compiled.validate({ decision: "Request changes" })).toBe(false); + }); }); describe("compile: what the browser receives is JSON, not source", () => { diff --git a/specs/web-form-spec.md b/specs/web-form-spec.md index 2c3991b87..7c7eee9ee 100644 --- a/specs/web-form-spec.md +++ b/specs/web-form-spec.md @@ -50,6 +50,14 @@ same-document `$ref` that resolves to nothing is valid draft-07 and unusable. Ha compilation happened during the run, that schema would have read the browser assets, begun a durable operation, and recorded its failure. +Compiling for the browser takes the schema apart: `if`, `then`, `else` and the +other subschema keywords are extracted and compiled as schemas of their own. A +branch therefore declares its own `type` — one that reached `required` through +its parent's type is a typeless schema by the time it is compiled, and strict +mode refuses it. The server, compiling the whole schema in one piece, accepts +that same branch, so this is the one shape the two sides read differently and the +refusal names which side produced it. + ## The live form `liveForm()` is the browser interaction without the component around it, so From f74ceaf423d6e44640dc77235857f80e49122c16 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:11:01 -0400 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=90=9B=20Open=20the=20plan=20review?= =?UTF-8?q?=20form=20as=20the=20host,=20not=20as=20the=20document?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `xmd plan` printed its review URL and then said it could not open it: could not open a browser automatically (xmd plan asked for a command, which the authorship profile grants to nothing). Open the URL above to continue. Opening a form runs `open`, `xdg-open` or `start`, and the profile refuses a command to everything inside it — the same shape as the adapter install fixed in #674. Showing a person the review is the host's act: the host's provider asking the host's question about a URL the host is serving, decided by no document, agent or authored element. `FormOpener` is where that act already had a seam, so the profile composes around it and runs the open in the scope the command was called in. Nothing else moves: a file, a command, the network and a service stay refused, and a failed open is still a warning printed beside a URL that stands on its own. Claude-Session: https://claude.ai/code/session_01TNJwcFmnt3kYSn9gGsx9u7 --- packages/cli/src/authorship-profile.ts | 35 +++++++- packages/cli/tests/plan-host-acts.test.ts | 102 ++++++++++++++++++++++ packages/web/mod.ts | 7 ++ specs/acp-client-spec.md | 9 +- specs/plan-command-spec.md | 10 ++- 5 files changed, 152 insertions(+), 11 deletions(-) create mode 100644 packages/cli/tests/plan-host-acts.test.ts diff --git a/packages/cli/src/authorship-profile.ts b/packages/cli/src/authorship-profile.ts index c4574c33a..8c8643332 100644 --- a/packages/cli/src/authorship-profile.ts +++ b/packages/cli/src/authorship-profile.ts @@ -49,6 +49,7 @@ import { createAcpxProvider } from "@executablemd/acp"; import type { AcpxProviderDependencies } from "@executablemd/acp"; import { InMemoryStream } from "@executablemd/durable-streams"; import { API } from "@executablemd/runtime"; +import { FormOpener } from "@executablemd/web"; import { hostAcpDependencies } from "./agent-stack.ts"; import type { AgentStack } from "./agent-stack.ts"; @@ -143,10 +144,12 @@ export function* runPlanCommandDocument(profile: AuthorshipProfile): Operation> { @@ -162,6 +165,7 @@ export function* runPlanCommandDocument(profile: AuthorshipProfile): Operation { + return FormOpener.around({ + *open([url], next): Operation { + yield* inScope(host, () => next(url)); + }, + }); +} + /** * Run one operation in a scope this one is nested inside, and wait for it there. * diff --git a/packages/cli/tests/plan-host-acts.test.ts b/packages/cli/tests/plan-host-acts.test.ts new file mode 100644 index 000000000..669ae2a37 --- /dev/null +++ b/packages/cli/tests/plan-host-acts.test.ts @@ -0,0 +1,102 @@ +/** + * Tier PH — the acts `xmd plan` performs as the host + * (specs/plan-command-spec.md §The authorship profile, + * specs/acp-client-spec.md §The `xmd plan` authorship profile). + * + * The profile refuses the command document a command, and two of the things the + * command itself does run one: it installs this build's ACP adapter, and it opens + * the review form in a browser. Neither is the document's act, and both were + * refused as though they were — the second one visibly, as + * `could not open a browser automatically (xmd plan asked for a command, …)` + * printed beside the URL a person then had to open by hand. + * + * These drive the real command with the real profile. What stands in for the + * outside world is the command itself: an `API.Process` recorder answers instead + * of spawning, installed at `min` so the profile's own refusal still outranks it + * wherever it applies — a recorder at full strength would answer for a refused + * call too, and these cases would pass against the defect. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { join } from "node:path"; +import { API } from "@executablemd/runtime"; +import { FormOpener } from "@executablemd/web"; +import type { Operation } from "effection"; + +import { runPlan } from "../src/plan.ts"; +import type { PlanCommand } from "../src/plan.ts"; +import { scanPlanArgs } from "../src/plan-args.ts"; +import type { AgentStack } from "../src/agent-stack.ts"; +import { ADAPTERS, AGENT, createPlanHarness, useWorkingDirectory } from "./support/plan-harness.ts"; +import type { PlanHarness } from "./support/plan-harness.ts"; + +const REQUEST = "write a greeting"; + +/** A Plan the host's validator accepts. */ +const PLAN = ['the draft ran', ""].join("\n"); + +const STACK: AgentStack = { + provider: "acpx", + defaultAgent: AGENT, + permissionMode: "deny-all", + adapters: ADAPTERS, +}; + +function writing(dir: string, output: string): PlanCommand { + const argv = ["plan", REQUEST]; + return { argv, scan: scanPlanArgs(argv), include: [dir], output, run: false, stack: STACK }; +} + +/** Every command this invocation reached, answered rather than spawned. */ +function* recordCommands(commands: string[][]): Operation { + yield* API.Process.around( + { + // deno-lint-ignore require-yield + *exec([options]) { + commands.push([...options.command]); + return { exitCode: 0, stdout: "", stderr: "" }; + }, + }, + { at: "min" }, + ); +} + +/** + * A harness whose review opens a form first, the way the CLI's own does. + * + * `installWebElicitation` announces the URL and asks `FormOpener` to open it + * before it waits for an answer; this is that one act, without a port, a page or + * a browser. + */ +function openingHarness(harness: PlanHarness, url: string): PlanHarness { + const scripted = harness.deps.installElicitation; + harness.deps.installElicitation = function* (): Operation { + yield* FormOpener.operations.open(url); + yield* scripted(); + }; + return harness; +} + +describe("Tier PH — the acts xmd plan performs as the host", () => { + it("PH1: opening the review form reaches a command the document cannot", function* () { + yield* useWorkingDirectory(function* (dir, authorshipRoot) { + const commands: string[][] = []; + yield* recordCommands(commands); + + const url = "http://127.0.0.1:0/f/token/"; + const harness = openingHarness(createPlanHarness({ authorshipRoot }), url); + harness.fake.script({ reply: PLAN }); + harness.script({ decision: "Approve" }); + + const code = yield* runPlan(writing(dir, join(dir, "plan.md")), harness.deps); + + // The command that opens a browser on this platform, whichever it is, with + // the URL the form is being served at. + expect(commands).toHaveLength(1); + expect(commands[0]).toContain(url); + expect(code).toBe(0); + expect(harness.reviews).toHaveLength(1); + }); + }); +}); diff --git a/packages/web/mod.ts b/packages/web/mod.ts index addefa3f2..6ef87cd60 100644 --- a/packages/web/mod.ts +++ b/packages/web/mod.ts @@ -7,9 +7,16 @@ * elicitations with the same form, which is how `` reaches a person * under the CLI. `liveForm()` is the browser interaction on its own, for a host * that wants an answer without either component. + * + * `FormOpener` is how any of them asks for the URL to be opened. A host composes + * around it to say where that act belongs — a profile that refuses the document a + * command still opens its own form — and a failed open is a warning: the URL is + * printed first and the form keeps waiting either way. */ export { installWebComponents, WEB_REGISTRATIONS } from "./src/components.ts"; export { installWebElicitation } from "./src/elicitation.ts"; export { liveForm } from "./src/live-form.ts"; export type { LiveFormInput } from "./src/live-form.ts"; +export { FormOpener } from "./src/opener.ts"; +export type { FormOpenerApi } from "./src/opener.ts"; diff --git a/specs/acp-client-spec.md b/specs/acp-client-spec.md index 2df500657..b8ffbd7dd 100644 --- a/specs/acp-client-spec.md +++ b/specs/acp-client-spec.md @@ -581,11 +581,14 @@ network capability, and the host decides for that whole execution that a failing `` ends it — so a turn that streamed text and then failed presents nothing. -That refusal covers the document, and installing this build's adapter is not the -document's act: it runs a command, and it runs it in the scope the invocation was +That refusal covers the document. Two things the command itself does are not the +document's acts, and both run a command: installing this build's adapter, and +opening the review form in a browser. Each runs in the scope the invocation was called in rather than inside the profile. Nothing the document, the assistant or an authored element reaches can get there — the host prepares the adapter it was -always going to launch, and an ended command takes an unfinished install with it. +always going to launch and opens the form it is already serving — and an ended +command takes an unfinished install with it. A failed open stays a warning beside +the printed URL. The profile's working directory is derived from the logical session name rather than shared or freshly made: `~/.xmd/plan/sessions/`, with the diff --git a/specs/plan-command-spec.md b/specs/plan-command-spec.md index 846ce3ffd..f03bffb6a 100644 --- a/specs/plan-command-spec.md +++ b/specs/plan-command-spec.md @@ -288,10 +288,12 @@ provider: document itself. The provider may use its own transport to perform the model turn; that does not -grant the Agent a native network tool. Nor does putting this build's own ACP -adapter on disk, which runs a command in the scope that invoked `xmd plan`: the -document is refused a command, and the host still installs the adapter it is -about to launch ([`xmd run` and `xmd plan`](./acp-client-spec.md)). +grant the Agent a native network tool. Nor do the two acts the command performs +as the host — putting this build's own ACP adapter on disk, and opening the +review form in a browser — each of which runs a command in the scope that invoked +`xmd plan`. The document is refused a command; the host still installs the +adapter it is about to launch and opens the form it is already serving +([`xmd run` and `xmd plan`](./acp-client-spec.md)). `--approve-all`, `--approve-reads` and `--deny-all` do not change this ceiling. They apply to the approved Plan later. A provider that cannot establish this From 1cdca2f215aabbb2d0f4b67e4f330ecebe115a6f Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:29:21 -0400 Subject: [PATCH 3/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20State=20the=20host's?= =?UTF-8?q?=20own=20acts=20once,=20and=20put=20the=20ceiling=20on=20the=20?= =?UTF-8?q?document=20alone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two fixes for `xmd plan`'s capability refusals were written at their call sites, one per act. This says the rule once instead. `src/host-acts.ts` owns it: a profile refuses its document ambiently, a call carries no mark saying who made it, and a provider's work happens inside the document execution that reached it — core constructs the root provider from `Execution.around({ document })` — so the scope has to come from the command, which is the one party that exists before the ceiling does. A host takes its scope and states which acts are its own; everything else stays refused. The adapter install is now stated where a host states its dependencies, `hostAcpDependencies(stack, host)`, so `xmd run` and `xmd plan` say the same thing and the plan profile stops special-casing it. `xmd run` has no ceiling, so there it changes nothing. `refuseDocumentCapabilities()` moves onto a scope holding the document execution and nothing else. No behavior depends on it — the acts that needed hoisting are inside that execution either way — but the ceiling now covers what it claims to. AE6 and PH1 are unchanged and still pass: they assert the outcome, so they hold the refactor to the behavior the two fixes established. Claude-Session: https://claude.ai/code/session_01TNJwcFmnt3kYSn9gGsx9u7 --- packages/cli/src/agent-stack.ts | 21 +++- packages/cli/src/authorship-profile.ts | 115 ++++++++-------------- packages/cli/src/host-acts.ts | 76 ++++++++++++++ packages/cli/tests/agent-adapters.test.ts | 12 ++- specs/acp-client-spec.md | 20 ++-- 5 files changed, 154 insertions(+), 90 deletions(-) create mode 100644 packages/cli/src/host-acts.ts diff --git a/packages/cli/src/agent-stack.ts b/packages/cli/src/agent-stack.ts index aabc2153d..bd1dce43e 100644 --- a/packages/cli/src/agent-stack.ts +++ b/packages/cli/src/agent-stack.ts @@ -32,11 +32,12 @@ import { } from "@executablemd/acp/embedded-adapters"; import type { EmbeddedAdapters } from "@executablemd/acp/embedded-adapters"; import { Err, Ok } from "effection"; -import type { Operation, Result } from "effection"; +import type { Operation, Result, Scope } from "effection"; import { homedir } from "node:os"; import { join } from "node:path"; import { resolveAgentConfig } from "./agent-config.ts"; +import { hostScope, inScope } from "./host-acts.ts"; import type { AgentFlags } from "./agent-config.ts"; import type { MachineSessionAssembly } from "./session-coordinator.ts"; @@ -112,9 +113,21 @@ export function* resolveAgentStack( * ones a document could replace are not ones. The two advertised sets are stated * by the host, not inherited. */ -export function hostAcpDependencies(stack: AgentStack): AcpxProviderDependencies { +export function hostAcpDependencies(stack: AgentStack, host: Scope): AcpxProviderDependencies { const { sessions } = stack; - const adapters = embeddedAdapterDependencies(stack.adapters); + const stated = embeddedAdapterDependencies(stack.adapters); + const prepare = stated.prepareAgent; + const adapters: AcpxProviderDependencies = { + ...stated, + // Installing this build's adapter is the host's act, so it runs in the + // host's scope. Under a profile that refuses its document a command, that + // is the difference between installing the adapter and being refused as + // though the document had asked (src/host-acts.ts); under a host with no + // ceiling it changes nothing. + ...(prepare === undefined + ? {} + : { prepareAgent: (agentName: string) => inScope(host, () => prepare(agentName)) }), + }; if (sessions === undefined) { return adapters; } @@ -138,7 +151,7 @@ export function hostAcpDependencies(stack: AgentStack): AcpxProviderDependencies * asks for no agent installs no adapter. */ export function* installRunAgentStack(stack: AgentStack): Operation { - const acpx = createAcpxProvider(hostAcpDependencies(stack)); + const acpx = createAcpxProvider(hostAcpDependencies(stack, yield* hostScope())); yield* registerAgentProvider("acpx", acpx); // The trusted host selects its own root provider by name. Document-level diff --git a/packages/cli/src/authorship-profile.ts b/packages/cli/src/authorship-profile.ts index 8c8643332..5ce4daeac 100644 --- a/packages/cli/src/authorship-profile.ts +++ b/packages/cli/src/authorship-profile.ts @@ -26,7 +26,7 @@ * network capability either. It decides what to write; it writes nothing. */ -import { ensure, Err, Ok, scoped, until, useScope } from "effection"; +import { ensure, Err, Ok, scoped, until } from "effection"; import type { Operation, Result, Scope } from "effection"; import { createHash } from "node:crypto"; import { mkdir, readdir, rmdir } from "node:fs/promises"; @@ -49,9 +49,9 @@ import { createAcpxProvider } from "@executablemd/acp"; import type { AcpxProviderDependencies } from "@executablemd/acp"; import { InMemoryStream } from "@executablemd/durable-streams"; import { API } from "@executablemd/runtime"; -import { FormOpener } from "@executablemd/web"; import { hostAcpDependencies } from "./agent-stack.ts"; +import { hostScope, installHostFormOpener } from "./host-acts.ts"; import type { AgentStack } from "./agent-stack.ts"; import { PLAN_COMMAND_DOCUMENT, readPackagedDocument } from "./packaged-document.ts"; @@ -150,7 +150,7 @@ export function* runPlanCommandDocument(profile: AuthorshipProfile): Operation> { // First, and before anything is built: this session's directory is claimed, @@ -164,8 +164,7 @@ export function* runPlanCommandDocument(profile: AuthorshipProfile): Operation`, `` or anything else. - includes: [], - props: { - request: profile.request, - syntax: profile.syntax, - session: profile.session, + // The refusals go on a scope holding the document and nothing else, so + // what the ceiling covers is what it says it covers. It cannot separate + // the host's own acts from the document's on its own — a provider's work + // happens inside this execution, and `API.Process.exec` looks the same + // whichever party reached it — which is why those acts are stated above + // and run in the host's scope (src/host-acts.ts). + const approved = yield* scoped(function* (): Operation { + yield* refuseDocumentCapabilities(); + return yield* collect( + yield* executeInstalled( + { + ...retainedSource(PLAN_COMMAND_IDENTITY, source), + // Invocation-owned and thrown away with the scope. Ordinary + // document and Prompt semantics need a durable stream; nothing + // about writing a Plan needs a durable one, and `--journal` + // belongs to the Plan you approved rather than to the + // conversation that wrote it. + stream: new InMemoryStream(), + // No repository component search. What the document may name is + // what this profile declares, so a file in the caller's tree + // cannot answer for ``, `` or anything else. + includes: [], + props: { + request: profile.request, + syntax: profile.syntax, + session: profile.session, + }, }, - }, - [{ components: [...agentIdentityComponents(), validator(profile)] }], - ), - ); + [{ components: [...agentIdentityComponents(), validator(profile)] }], + ), + ); + }); if (typeof approved !== "string") { return Err(new Error("the plan command document returned something that is not a Plan")); } @@ -273,10 +282,10 @@ function validator(profile: AuthorshipProfile): IdentityComponent { * through ACPX's published pins and could reach neither (#672). * * Putting one on disk runs `npm install`, which is the one thing this profile - * refuses to everything inside it. So preparation runs in `host` — the scope - * this command was called in, which the refusals below were never installed on. - * The distinction is the whole of it: the document decides what to write and may - * run nothing, while the host installs the adapter it was always going to launch. + * refuses to everything inside it, so the host states that act as its own and it + * runs in `host` (src/host-acts.ts). The distinction is the whole of it: the + * document decides what to write and may run nothing, while the host installs + * the adapter it was always going to launch. * * Exported for the suite that pins exactly that: what a provider is built from * is not observable through a provider, and a case that could only watch a turn @@ -287,13 +296,8 @@ export function authorshipCeiling( workdir: string, host: Scope, ): AcpxProviderDependencies { - const assembly = hostAcpDependencies(profile.stack); - const prepare = assembly.prepareAgent; return { - ...assembly, - ...(prepare === undefined - ? {} - : { prepareAgent: (agentName: string) => inScope(host, () => prepare(agentName)) }), + ...hostAcpDependencies(profile.stack, host), ...profile.acp, // deno-lint-ignore require-yield *agentCwd() { @@ -305,45 +309,6 @@ export function authorshipCeiling( }; } -/** - * Open this host's own review form the way this host opens anything. - * - * Showing a person the review means running `open`, `xdg-open` or `start`, and - * the profile refuses a command to everything inside it — so `xmd plan` printed - * its form's URL and then warned that it could not open it, naming the ceiling as - * the reason. The act belongs to the host: it is the host's provider asking the - * host's question, about a URL the host is serving, and no document, agent or - * authored element decides that it happens or what it opens. - * - * Only the opening moves. Everything a document could reach through the profile — - * a file, a command, the network, a service — is refused exactly as before, and a - * launch that fails is still a warning printed beside a URL that stands on its - * own. - */ -function openFormsThroughHost(host: Scope): Operation { - return FormOpener.around({ - *open([url], next): Operation { - yield* inScope(host, () => next(url)); - }, - }); -} - -/** - * Run one operation in a scope this one is nested inside, and wait for it there. - * - * The wait is what makes it this operation's: a task created in an outer scope - * outlives the caller by construction, so the halt is registered before the wait - * and an ended command takes the work with it rather than leaving an install - * running under a conversation that is over. - */ -function* inScope(scope: Scope, operation: () => Operation): Operation { - return yield* scoped(function* () { - const task = scope.run(operation); - yield* ensure(() => task.halt()); - return yield* task; - }); -} - /** * What the assistant session is told once, before it is asked anything. * diff --git a/packages/cli/src/host-acts.ts b/packages/cli/src/host-acts.ts new file mode 100644 index 000000000..d06940149 --- /dev/null +++ b/packages/cli/src/host-acts.ts @@ -0,0 +1,76 @@ +/** + * The acts a command performs as the host, and the scope they run in. + * + * A profile that refuses its document a capability refuses it *ambiently*: the + * middleware sits on a scope, and everything running under that scope is refused, + * including the host's own machinery. `xmd plan` is the profile that has any — + * the document it runs may write nothing and run nothing — and two of the things + * the command itself does run a command: it installs this build's ACP adapter, + * and it opens the review form in a browser. + * + * Neither is the document's act. Both were refused as though they were. + * + * ## Why a scope has to be carried + * + * There is no marker on a call that says who asked. `API.Process.exec` looks the + * same whether an `exec` fence reached it or this host did, so a refusal that + * covers a scope covers both, and the only thing that separates them is *where* + * the work runs. + * + * A provider cannot supply that place by itself. The root provider is + * constructed inside the document execution — core installs it from + * `Execution.around({ document })` — so the scope it could capture at + * construction is already under the ceiling, and so is every later call it + * makes. The place has to come from outside, from the command, which is the one + * party that exists before the ceiling does. + * + * So a host takes its scope before it installs a ceiling and states which of its + * acts belong to it. Everything else stays refused, and a host with no ceiling — + * `xmd run`, `xmd workflow` — states the same thing and changes nothing. + */ + +import { ensure, scoped, useScope } from "effection"; +import type { Operation, Scope } from "effection"; +import { FormOpener } from "@executablemd/web"; + +/** + * Run one operation in a scope this one is nested inside, and wait for it here. + * + * The waiting is what makes it this operation's work: a task created in an outer + * scope outlives its creator by construction, so the halt is registered before + * the wait and an ended command takes an unfinished act with it rather than + * leaving one running under a conversation that is over. + * + * `@effectionx/scope-eval` answers a different question. Its worker decouples + * the call from the work — the operation finishes even when the caller is gone, + * which is what `persist`, `daemon` and `service` want from it and the opposite + * of what a host act wants. + */ +export function* inScope(scope: Scope, operation: () => Operation): Operation { + return yield* scoped(function* () { + const task = scope.run(operation); + yield* ensure(() => task.halt()); + return yield* task; + }); +} + +/** The scope a command's own acts run in, taken before it installs a ceiling. */ +export function hostScope(): Operation { + return useScope(); +} + +/** + * Open a form the way the host opens anything, from inside a ceiling. + * + * Showing a person a form is the host's provider asking the host's question + * about a URL the host is serving; no document, agent or authored element + * decides that it happens or what it opens. A failed open stays what it was — a + * warning printed beside a URL that stands on its own. + */ +export function installHostFormOpener(host: Scope): Operation { + return FormOpener.around({ + *open([url], next): Operation { + yield* inScope(host, () => next(url)); + }, + }); +} diff --git a/packages/cli/tests/agent-adapters.test.ts b/packages/cli/tests/agent-adapters.test.ts index cc847f7d1..68532a406 100644 --- a/packages/cli/tests/agent-adapters.test.ts +++ b/packages/cli/tests/agent-adapters.test.ts @@ -98,7 +98,7 @@ describe("Tier AE — embedded adapters on the run and plan paths", () => { it("AE1: the run path resolves an embedded agent to this build's own adapter", function* () { const root = adapterRoot(); const adapters = createEmbeddedAdapters(root); - const registry = hostAcpDependencies(stackWith(adapters)).agentRegistry; + const registry = hostAcpDependencies(stackWith(adapters), yield* useScope()).agentRegistry; if (registry === undefined) { throw new Error("the run path handed its provider no agent registry"); } @@ -116,7 +116,10 @@ describe("Tier AE — embedded adapters on the run and plan paths", () => { it("AE2: an agent this build carries no snapshot for resolves as it always did", function* () { const root = adapterRoot(); - const registry = hostAcpDependencies(stackWith(createEmbeddedAdapters(root))).agentRegistry; + const registry = hostAcpDependencies( + stackWith(createEmbeddedAdapters(root)), + yield* useScope(), + ).agentRegistry; if (registry === undefined) { throw new Error("the run path handed its provider no agent registry"); } @@ -153,7 +156,10 @@ describe("Tier AE — embedded adapters on the run and plan paths", () => { it("AE4: preparing an agent this build carries nothing for writes nothing", function* () { const root = adapterRoot(); - const prepare = hostAcpDependencies(stackWith(createEmbeddedAdapters(root))).prepareAgent; + const prepare = hostAcpDependencies( + stackWith(createEmbeddedAdapters(root)), + yield* useScope(), + ).prepareAgent; if (prepare === undefined) { throw new Error("the run path handed its provider no preparation"); } diff --git a/specs/acp-client-spec.md b/specs/acp-client-spec.md index b8ffbd7dd..185446492 100644 --- a/specs/acp-client-spec.md +++ b/specs/acp-client-spec.md @@ -581,14 +581,18 @@ network capability, and the host decides for that whole execution that a failing `` ends it — so a turn that streamed text and then failed presents nothing. -That refusal covers the document. Two things the command itself does are not the -document's acts, and both run a command: installing this build's adapter, and -opening the review form in a browser. Each runs in the scope the invocation was -called in rather than inside the profile. Nothing the document, the assistant or -an authored element reaches can get there — the host prepares the adapter it was -always going to launch and opens the form it is already serving — and an ended -command takes an unfinished install with it. A failed open stays a warning beside -the printed URL. +Those refusals are installed on a scope holding the document execution and +nothing else, so what the ceiling covers is what it says it covers. + +It cannot tell the host's own acts from the document's on its own. A call carries +no mark saying who made it, and a provider's work happens inside the execution +that reached it — core constructs a root provider from within the document — so a +refusal over that scope covers both parties. Two things the command does are the +host's, and both run a command: installing this build's adapter, and opening the +review form in a browser. The command therefore takes its own scope before it +installs the ceiling and states those two acts as its own; they run there, and +everything else stays refused. An ended command takes an unfinished install with +it, and a failed open stays a warning beside the printed URL. The profile's working directory is derived from the logical session name rather than shared or freshly made: `~/.xmd/plan/sessions/`, with the From 2f3852b189b65e24f1d2ac016d15ac90d5c10182 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Sun, 30 Aug 2026 23:35:11 -0400 Subject: [PATCH 4/4] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Keep=20the=20host-act?= =?UTF-8?q?=20helpers=20beside=20the=20profile=20that=20uses=20them?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit mostly moved code. `inScope` and the form-opener middleware changed file; the adapter binding moved into `hostAcpDependencies`, which then needed a `Scope` every caller but one passes for nothing; and `hostScope()` was a function whose body was `useScope()`. A module for two helpers with a single caller is premature, and a shared signature is the wrong place for a concern only the ceiling has. Both helpers go back beside their caller, carrying the prose that split was written to hold: why an ambient refusal cannot tell the parties apart, and why the wait is bound to a halt rather than decoupled the way `scope-eval` decouples. What that commit actually changed stays: the ceiling is installed on a scope holding the document execution and nothing else, and the spec says what the refusal can and cannot distinguish. Claude-Session: https://claude.ai/code/session_01TNJwcFmnt3kYSn9gGsx9u7 --- packages/cli/src/agent-stack.ts | 21 ++----- packages/cli/src/authorship-profile.ts | 58 +++++++++++++++-- packages/cli/src/host-acts.ts | 76 ----------------------- packages/cli/tests/agent-adapters.test.ts | 12 +--- 4 files changed, 60 insertions(+), 107 deletions(-) delete mode 100644 packages/cli/src/host-acts.ts diff --git a/packages/cli/src/agent-stack.ts b/packages/cli/src/agent-stack.ts index bd1dce43e..aabc2153d 100644 --- a/packages/cli/src/agent-stack.ts +++ b/packages/cli/src/agent-stack.ts @@ -32,12 +32,11 @@ import { } from "@executablemd/acp/embedded-adapters"; import type { EmbeddedAdapters } from "@executablemd/acp/embedded-adapters"; import { Err, Ok } from "effection"; -import type { Operation, Result, Scope } from "effection"; +import type { Operation, Result } from "effection"; import { homedir } from "node:os"; import { join } from "node:path"; import { resolveAgentConfig } from "./agent-config.ts"; -import { hostScope, inScope } from "./host-acts.ts"; import type { AgentFlags } from "./agent-config.ts"; import type { MachineSessionAssembly } from "./session-coordinator.ts"; @@ -113,21 +112,9 @@ export function* resolveAgentStack( * ones a document could replace are not ones. The two advertised sets are stated * by the host, not inherited. */ -export function hostAcpDependencies(stack: AgentStack, host: Scope): AcpxProviderDependencies { +export function hostAcpDependencies(stack: AgentStack): AcpxProviderDependencies { const { sessions } = stack; - const stated = embeddedAdapterDependencies(stack.adapters); - const prepare = stated.prepareAgent; - const adapters: AcpxProviderDependencies = { - ...stated, - // Installing this build's adapter is the host's act, so it runs in the - // host's scope. Under a profile that refuses its document a command, that - // is the difference between installing the adapter and being refused as - // though the document had asked (src/host-acts.ts); under a host with no - // ceiling it changes nothing. - ...(prepare === undefined - ? {} - : { prepareAgent: (agentName: string) => inScope(host, () => prepare(agentName)) }), - }; + const adapters = embeddedAdapterDependencies(stack.adapters); if (sessions === undefined) { return adapters; } @@ -151,7 +138,7 @@ export function hostAcpDependencies(stack: AgentStack, host: Scope): AcpxProvide * asks for no agent installs no adapter. */ export function* installRunAgentStack(stack: AgentStack): Operation { - const acpx = createAcpxProvider(hostAcpDependencies(stack, yield* hostScope())); + const acpx = createAcpxProvider(hostAcpDependencies(stack)); yield* registerAgentProvider("acpx", acpx); // The trusted host selects its own root provider by name. Document-level diff --git a/packages/cli/src/authorship-profile.ts b/packages/cli/src/authorship-profile.ts index 5ce4daeac..dcdc7a0c9 100644 --- a/packages/cli/src/authorship-profile.ts +++ b/packages/cli/src/authorship-profile.ts @@ -26,7 +26,7 @@ * network capability either. It decides what to write; it writes nothing. */ -import { ensure, Err, Ok, scoped, until } from "effection"; +import { ensure, Err, Ok, scoped, until, useScope } from "effection"; import type { Operation, Result, Scope } from "effection"; import { createHash } from "node:crypto"; import { mkdir, readdir, rmdir } from "node:fs/promises"; @@ -49,9 +49,9 @@ import { createAcpxProvider } from "@executablemd/acp"; import type { AcpxProviderDependencies } from "@executablemd/acp"; import { InMemoryStream } from "@executablemd/durable-streams"; import { API } from "@executablemd/runtime"; +import { FormOpener } from "@executablemd/web"; import { hostAcpDependencies } from "./agent-stack.ts"; -import { hostScope, installHostFormOpener } from "./host-acts.ts"; import type { AgentStack } from "./agent-stack.ts"; import { PLAN_COMMAND_DOCUMENT, readPackagedDocument } from "./packaged-document.ts"; @@ -150,7 +150,7 @@ export function* runPlanCommandDocument(profile: AuthorshipProfile): Operation> { // First, and before anything is built: this session's directory is claimed, @@ -164,7 +164,7 @@ export function* runPlanCommandDocument(profile: AuthorshipProfile): Operation inScope(host, () => prepare(agentName)) }), ...profile.acp, // deno-lint-ignore require-yield *agentCwd() { @@ -309,6 +314,49 @@ export function authorshipCeiling( }; } +/** + * Open this host's own review form the way this host opens anything. + * + * A ceiling refuses *ambiently*: the middleware sits on a scope, and a call + * carries no mark saying who made it — `API.Process.exec` looks the same whether + * an `exec` fence reached it or this host did. So showing a person the review, + * which runs `open`, `xdg-open` or `start`, was refused as though the document + * had asked, and `xmd plan` printed its URL and warned that it could not open it. + * + * The act is the host's: its provider asking its question, about a URL it is + * serving, decided by no document, agent or authored element. Only the opening + * moves — a file, a command, the network and a service stay refused — and a + * failed launch is still a warning printed beside a URL that stands on its own. + */ +function openFormsThroughHost(host: Scope): Operation { + return FormOpener.around({ + *open([url], next): Operation { + yield* inScope(host, () => next(url)); + }, + }); +} + +/** + * Run one operation in a scope this one is nested inside, and wait for it here. + * + * The waiting is what makes it this operation's work: a task created in an outer + * scope outlives its creator by construction, so the halt is registered before + * the wait and an ended command takes an unfinished act with it rather than + * leaving one running under a conversation that is over. + * + * `@effectionx/scope-eval` answers a different question. Its worker decouples the + * call from the work — the operation finishes even when the caller is gone, which + * is what `persist`, `daemon` and `service` want from it and the opposite of what + * a host act wants. + */ +function* inScope(scope: Scope, operation: () => Operation): Operation { + return yield* scoped(function* () { + const task = scope.run(operation); + yield* ensure(() => task.halt()); + return yield* task; + }); +} + /** * What the assistant session is told once, before it is asked anything. * diff --git a/packages/cli/src/host-acts.ts b/packages/cli/src/host-acts.ts deleted file mode 100644 index d06940149..000000000 --- a/packages/cli/src/host-acts.ts +++ /dev/null @@ -1,76 +0,0 @@ -/** - * The acts a command performs as the host, and the scope they run in. - * - * A profile that refuses its document a capability refuses it *ambiently*: the - * middleware sits on a scope, and everything running under that scope is refused, - * including the host's own machinery. `xmd plan` is the profile that has any — - * the document it runs may write nothing and run nothing — and two of the things - * the command itself does run a command: it installs this build's ACP adapter, - * and it opens the review form in a browser. - * - * Neither is the document's act. Both were refused as though they were. - * - * ## Why a scope has to be carried - * - * There is no marker on a call that says who asked. `API.Process.exec` looks the - * same whether an `exec` fence reached it or this host did, so a refusal that - * covers a scope covers both, and the only thing that separates them is *where* - * the work runs. - * - * A provider cannot supply that place by itself. The root provider is - * constructed inside the document execution — core installs it from - * `Execution.around({ document })` — so the scope it could capture at - * construction is already under the ceiling, and so is every later call it - * makes. The place has to come from outside, from the command, which is the one - * party that exists before the ceiling does. - * - * So a host takes its scope before it installs a ceiling and states which of its - * acts belong to it. Everything else stays refused, and a host with no ceiling — - * `xmd run`, `xmd workflow` — states the same thing and changes nothing. - */ - -import { ensure, scoped, useScope } from "effection"; -import type { Operation, Scope } from "effection"; -import { FormOpener } from "@executablemd/web"; - -/** - * Run one operation in a scope this one is nested inside, and wait for it here. - * - * The waiting is what makes it this operation's work: a task created in an outer - * scope outlives its creator by construction, so the halt is registered before - * the wait and an ended command takes an unfinished act with it rather than - * leaving one running under a conversation that is over. - * - * `@effectionx/scope-eval` answers a different question. Its worker decouples - * the call from the work — the operation finishes even when the caller is gone, - * which is what `persist`, `daemon` and `service` want from it and the opposite - * of what a host act wants. - */ -export function* inScope(scope: Scope, operation: () => Operation): Operation { - return yield* scoped(function* () { - const task = scope.run(operation); - yield* ensure(() => task.halt()); - return yield* task; - }); -} - -/** The scope a command's own acts run in, taken before it installs a ceiling. */ -export function hostScope(): Operation { - return useScope(); -} - -/** - * Open a form the way the host opens anything, from inside a ceiling. - * - * Showing a person a form is the host's provider asking the host's question - * about a URL the host is serving; no document, agent or authored element - * decides that it happens or what it opens. A failed open stays what it was — a - * warning printed beside a URL that stands on its own. - */ -export function installHostFormOpener(host: Scope): Operation { - return FormOpener.around({ - *open([url], next): Operation { - yield* inScope(host, () => next(url)); - }, - }); -} diff --git a/packages/cli/tests/agent-adapters.test.ts b/packages/cli/tests/agent-adapters.test.ts index 68532a406..cc847f7d1 100644 --- a/packages/cli/tests/agent-adapters.test.ts +++ b/packages/cli/tests/agent-adapters.test.ts @@ -98,7 +98,7 @@ describe("Tier AE — embedded adapters on the run and plan paths", () => { it("AE1: the run path resolves an embedded agent to this build's own adapter", function* () { const root = adapterRoot(); const adapters = createEmbeddedAdapters(root); - const registry = hostAcpDependencies(stackWith(adapters), yield* useScope()).agentRegistry; + const registry = hostAcpDependencies(stackWith(adapters)).agentRegistry; if (registry === undefined) { throw new Error("the run path handed its provider no agent registry"); } @@ -116,10 +116,7 @@ describe("Tier AE — embedded adapters on the run and plan paths", () => { it("AE2: an agent this build carries no snapshot for resolves as it always did", function* () { const root = adapterRoot(); - const registry = hostAcpDependencies( - stackWith(createEmbeddedAdapters(root)), - yield* useScope(), - ).agentRegistry; + const registry = hostAcpDependencies(stackWith(createEmbeddedAdapters(root))).agentRegistry; if (registry === undefined) { throw new Error("the run path handed its provider no agent registry"); } @@ -156,10 +153,7 @@ describe("Tier AE — embedded adapters on the run and plan paths", () => { it("AE4: preparing an agent this build carries nothing for writes nothing", function* () { const root = adapterRoot(); - const prepare = hostAcpDependencies( - stackWith(createEmbeddedAdapters(root)), - yield* useScope(), - ).prepareAgent; + const prepare = hostAcpDependencies(stackWith(createEmbeddedAdapters(root))).prepareAgent; if (prepare === undefined) { throw new Error("the run path handed its provider no preparation"); }