Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions loopx/pi_goal_mode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 4 additions & 9 deletions loopx/pi_goal_mode/loopx-goal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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);
Expand Down
9 changes: 8 additions & 1 deletion tests/control_plane/test_pi_adapter_start.py
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand All @@ -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"
Expand Down Expand Up @@ -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":
Expand Down
155 changes: 155 additions & 0 deletions tests/pi_goal_adapter_authority.test.mjs
Original file line number Diff line number Diff line change
@@ -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");
});
7 changes: 7 additions & 0 deletions tests/test_pi_goal_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading