From c9351fd9a86b30d6dc5b9ef8ecddc7bc89693b75 Mon Sep 17 00:00:00 2001 From: Jim-jimu <3230103159@zju.edu.cn> Date: Sun, 25 Oct 2026 16:16:42 +0800 Subject: [PATCH] fix(pi): keep status inspection from locking startup authority Signed-off-by: Jim-jimu <3230103159@zju.edu.cn> --- loopx/pi_goal_mode/README.md | 4 + loopx/pi_goal_mode/loopx-goal.ts | 13 +- tests/control_plane/test_pi_adapter_start.py | 9 +- tests/pi_goal_adapter_authority.test.mjs | 155 +++++++++++++++++++ tests/test_pi_goal_mode.py | 7 + 5 files changed, 178 insertions(+), 10 deletions(-) create mode 100644 tests/pi_goal_adapter_authority.test.mjs diff --git a/loopx/pi_goal_mode/README.md b/loopx/pi_goal_mode/README.md index d19b25cefc..b68f43db8e 100644 --- a/loopx/pi_goal_mode/README.md +++ b/loopx/pi_goal_mode/README.md @@ -16,6 +16,10 @@ into a LoopX-governed visible goal loop. binding can resume its registered lane; an unbound session retains the native identity-selection packet. A sole candidate is never automatic takeover authorization. Sessions without a file retain explicit identity selection. + Inspection (`/loopx`, `/loopx status`, `/loopx history`, `/loopx list`) never + creates or changes session authority. Only explicit guided startup issues the + activation token and locks its Goal, Agent, registry and capabilities, so + inspecting a project first cannot constrain a later startup. `/loopx resume` re-arms auto-continuation after a user-driven pause or an aborted run. - **`loopx_goal_activate`** — agent-callable tool. Binds the current session to diff --git a/loopx/pi_goal_mode/loopx-goal.ts b/loopx/pi_goal_mode/loopx-goal.ts index 69b1ea92d6..057365b5f5 100644 --- a/loopx/pi_goal_mode/loopx-goal.ts +++ b/loopx/pi_goal_mode/loopx-goal.ts @@ -312,20 +312,15 @@ export default function (pi: ExtensionAPI) { return candidate; }; - const inspectLoopx = async (key: string, ctx: ExtensionContext) => { + const inspectLoopx = async (ctx: ExtensionContext) => { try { const stdout = await runLoopxCli( ["--format", "json", "bootstrap-command-pack", "--project", "."], ctx.cwd, ); const packet = parseJsonObject(stdout); - if (packet) { - try { - establishSessionAuthority(key, packet); - } catch { - // A status-only packet may intentionally stop before host activation. - } - } + // Preview packets must not mint or lock session authority. Only explicit + // Pi startup verifies the identity and capabilities used for activation. const display = typeof packet?.message === "string" ? packet.message : stdout; // The packet preview is a long multi-section document. A widget is a // fixed-height area near the editor, so dumping the preview into it gets @@ -428,7 +423,7 @@ export default function (pi: ExtensionAPI) { await loop.resume(key); return; } - await inspectLoopx(key, ctx); + await inspectLoopx(ctx); return; } await startLoopx(trimmed, key, store, ctx); diff --git a/tests/control_plane/test_pi_adapter_start.py b/tests/control_plane/test_pi_adapter_start.py index 4e84f539b5..7d5f498aa0 100644 --- a/tests/control_plane/test_pi_adapter_start.py +++ b/tests/control_plane/test_pi_adapter_start.py @@ -50,6 +50,7 @@ sessionManager: { getSessionFile: () => process.argv[3] || undefined }, ui: { setWidget() {}, notify: (message, kind) => notices.push({message, kind}) } }; +if (process.argv[4]) await commands.get('loopx').handler(process.argv[4], ctx); await commands.get('loopx').handler('Continue the synthetic documentation task', ctx); // A model cannot activate with a guessed token, even after a legal startup. const rejected = await tools.get('loopx_goal_activate').execute('test', { @@ -67,9 +68,12 @@ def _thread_id(file: Path) -> str: return label + "-" + hashlib.sha256(str(file).encode()).hexdigest()[:16] -@pytest.mark.parametrize("case", ["bound", "sole", "multiple", "none", "ephemeral"]) +@pytest.mark.parametrize("case", ["bound", "bound_after_status", "sole", "multiple", "none", "ephemeral"]) @pytest.mark.parametrize("selected_goal", [False, True]) def test_pi_start_preserves_native_identity_authority(tmp_path, monkeypatch, case, selected_goal): + preview_command = "status" if case == "bound_after_status" else "" + if preview_command: + case = "bound" node = shutil.which("node") assert node, "Node.js >=22.22.3 is required for the shipped Pi adapter" project = tmp_path / "project" @@ -128,12 +132,15 @@ def test_pi_start_preserves_native_identity_authority(tmp_path, monkeypatch, cas node, "--input-type=module", "-e", HOST, str(REPO / "loopx" / "pi_goal_mode" / "loopx-goal.ts"), str(project), "" if case == "ephemeral" else str(session), + preview_command, ], capture_output=True, text=True, timeout=90, env=os.environ.copy()) assert completed.returncode == 0, completed.stderr result = json.loads(completed.stdout) assert len(result["packets"]) == 1, result["notices"] packet = result["packets"][0] argv = [json.loads(line) for line in calls.read_text().splitlines()] + if preview_command: + assert argv.pop(0) == ["--format", "json", "bootstrap-command-pack", "--project", "."] assert len(argv) == 1, "startup must not auto-bind/register or probe quota" assert argv[0][2] == "start-goal" if case == "ephemeral": diff --git a/tests/pi_goal_adapter_authority.test.mjs b/tests/pi_goal_adapter_authority.test.mjs new file mode 100644 index 0000000000..3885df1132 --- /dev/null +++ b/tests/pi_goal_adapter_authority.test.mjs @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import { createRequire, registerHooks, syncBuiltinESMExports } from "node:module"; +import test from "node:test"; + +// Exercise the shipped command handler. Only the Pi SDK and CLI transport are +// substituted; native CLI coverage lives in control_plane/test_pi_adapter_start.py. +let cliReply; +registerHooks({ + resolve(specifier, context, next) { + if (["typebox", "@earendil-works/pi-ai", "@earendil-works/pi-tui"].includes(specifier)) { + return { url: "pi-authority-test:sdk", shortCircuit: true }; + } + return next(specifier, context); + }, + load(url, context, next) { + if (url === "pi-authority-test:sdk") { + return { format: "module", shortCircuit: true, + source: `export const Type = new Proxy({}, {get: () => () => ({})}); + export const StringEnum = () => ({}); export class Text {}` }; + } + return next(url, context); + }, +}); +// promisify captures execFile's custom implementation when the adapter loads. +const childProcess = createRequire(import.meta.url)("node:child_process"); +childProcess.execFile = Object.assign(() => {}, { + [Symbol.for("nodejs.util.promisify.custom")]: async (_binary, args) => ({ + stdout: JSON.stringify(cliReply(args)), + }), +}); +syncBuiltinESMExports(); +const { default: extension } = await import("../loopx/pi_goal_mode/loopx-goal.ts"); + +function packet(capabilities = ["task_lease_v0"]) { + return { + goal_id: "synthetic-goal", project: "synthetic-project", + project_connection: { registry: "synthetic-registry", connection_state: "connected" }, + host_loop_activation: { + activation_allowed: true, agent_id: "synthetic-agent", + available_capabilities: capabilities, + }, + }; +} + +function harness(t, { preview = packet([]), startup = packet() } = {}) { + const commands = new Map(), tools = new Map(), events = new Map(); + const packets = [], notices = [], entries = [], widgets = [], calls = []; + cliReply = args => { + calls.push(args); + assert.ok(args.includes("bootstrap-command-pack") || args.includes("start-goal")); + return args.includes("bootstrap-command-pack") ? preview : startup; + }; + extension({ + registerCommand: (name, command) => commands.set(name, command), + registerTool: tool => tools.set(tool.name, tool), + registerEntryRenderer() {}, + appendEntry: (...args) => entries.push(args), + on: (name, handler) => events.set(name, handler), + sendUserMessage: text => packets.push(JSON.parse(text)), + }); + const ctx = { + cwd: process.cwd(), isIdle: () => true, + sessionManager: { getSessionFile: () => undefined }, + ui: { + setWidget: (...args) => widgets.push(args), + notify: (message, kind) => notices.push({ message, kind }), + }, + }; + t.after(() => events.get("session_shutdown")({}, ctx)); + return { + packets, notices, entries, widgets, calls, + command: text => commands.get("loopx").handler(text, ctx), + async activationError() { + const result = await tools.get("loopx_goal_activate").execute("test", { + activationToken: "unissued-token", objective: "synthetic task", + }, undefined, undefined, ctx); + return JSON.parse(result.content[0].text).error_code; + }, + }; +} + +test("direct startup issues authority with the advertised capability", async t => { + const h = harness(t); + await h.command("Continue the synthetic task"); + assert.equal(h.packets.length, 1); + assert.ok(h.packets[0].pi_session_authority.token); + assert.deepEqual(h.packets[0].pi_session_authority.available_capabilities, ["task_lease_v0"]); +}); + +for (const command of ["", "status", "history", "list"]) { + test(`inspection ${JSON.stringify(command)} leaves authority unbound and startup available`, async t => { + const h = harness(t); + await h.command(command); + await h.command(command); + assert.equal(h.packets.length, 0); + assert.equal(h.entries.length, 2); + assert.equal(h.widgets.length, 2); + const previewActivationError = await h.activationError(); + await h.command("Continue the synthetic task"); + assert.equal(h.packets.length, 1, JSON.stringify(h.notices)); + assert.equal(previewActivationError, "authority_not_bound"); + assert.deepEqual(h.packets[0].pi_session_authority.available_capabilities, ["task_lease_v0"]); + assert.ok(h.packets[0].pi_session_authority.token); + assert.equal(h.calls.length, 3); + assert.equal(h.notices.filter(n => n.kind === "error").length, 0); + }); +} + +test("previewed goal does not constrain the later explicit startup", async t => { + const preview = packet(); + preview.goal_id = "other-previewed-goal"; + const h = harness(t, { preview }); + await h.command("status"); + await h.command("Continue the synthetic task"); + assert.equal(h.packets.length, 1, JSON.stringify(h.notices)); + assert.equal(h.packets[0].pi_session_authority.goal_id, "synthetic-goal"); +}); + +test("inspection after startup preserves the issued token and capabilities", async t => { + const h = harness(t); + await h.command("Continue the synthetic task"); + const original = h.packets[0].pi_session_authority; + await h.command("status"); + await h.command("Continue the synthetic task"); + assert.equal(h.packets.length, 2, JSON.stringify(h.notices)); + assert.deepEqual(h.packets[1].pi_session_authority, original); + assert.equal(await h.activationError(), "authority_mismatch"); +}); + +for (const field of ["goal", "agent", "registry", "capabilities"]) { + test(`inspection cannot unlock an established ${field}`, async t => { + const startup = packet(); + const h = harness(t, { startup }); + await h.command("Continue the synthetic task"); + await h.command("status"); + if (field === "goal") startup.goal_id = "other-goal"; + if (field === "agent") startup.host_loop_activation.agent_id = "other-agent"; + if (field === "registry") startup.project_connection.registry = "other-registry"; + if (field === "capabilities") startup.host_loop_activation.available_capabilities = []; + await h.command("Continue the synthetic task"); + assert.equal(h.packets.length, 1); + assert.match(h.notices.find(n => n.kind === "error").message, /authority cannot change/); + }); +} + +test("an identity-selection packet still cannot issue authority after inspection", async t => { + const startup = packet(); + startup.host_loop_activation.activation_allowed = false; + const h = harness(t, { startup }); + await h.command("status"); + await h.command("Continue the synthetic task"); + assert.equal(h.packets.length, 1); + assert.equal(h.packets[0].pi_session_authority, undefined); + assert.equal(await h.activationError(), "authority_not_bound"); +}); diff --git a/tests/test_pi_goal_mode.py b/tests/test_pi_goal_mode.py index c2181d2deb..5a63d7ff2f 100644 --- a/tests/test_pi_goal_mode.py +++ b/tests/test_pi_goal_mode.py @@ -211,3 +211,10 @@ def test_pi_goal_loop_runtime_contract() -> None: pytest.skip("Node.js is required for the Pi goal loop runtime contract") test_file = Path(__file__).with_name("pi_goal_loop_runtime.test.mjs") subprocess.run([node, "--test", str(test_file)], check=True) + + +def test_pi_goal_adapter_authority_contract() -> None: + node = shutil.which("node") + assert node, "Node.js >=22.22.3 is required for the shipped Pi adapter" + test_file = Path(__file__).with_name("pi_goal_adapter_authority.test.mjs") + subprocess.run([node, "--test", str(test_file)], check=True)