pi-mono/apps/p2/plugins/ports/tool.ts
Mario Zechner 07c9e5b6fd
Some checks failed
CI / build-check (push) Failing after 14m3s
CI / test (coding-agent) (push) Has been cancelled
CI / test (durable) (push) Has been cancelled
CI / test (other) (push) Has been cancelled
CI / mcp-conformance (push) Has been cancelled
feat(p2): tools declare who may call them and where, turn origin from the run's inputs, agents give their own tools
- P2Tool: origin (any|owner) and scope (any|main|subagent) on the tool; the worker's
  Durable registry checks them before each call (guardTool), no tool checks itself.
  owner: location tools, reload_plugins, conversations, ports, MCP tools.
  main: subagent, rename_session, conversations, ports, reload_plugins. subagent: team.
- originOf reads pi.live.run.inputs through tx.submission: the owner's only if every
  input of the run is. Agent-written inputs record the writing turn's origin: subagent
  tasks and one-shot prompts, team messages, reports (answerOrigin), peer messages.
  No peer origin any more.
- Subagents and one-shot agents get their caller's tools (default all, tools narrows),
  except scope main; with codemode the script-only tools; subagents get team. One-shot
  agents keep codemode and agent. A send takes away what the caller can no longer give.
2026-11-04 23:28:14 +00:00

117 lines
5.6 KiB
TypeScript

import { defineExtension, section } from "@earendil-works/pi-durable";
import { bool, int, obj, opt, str, union } from "@earendil-works/pi-schema";
import type { Context } from "@p2/app/v1";
import { literal } from "@p2/app/v1";
import { defineP2Tool, isTopLevel, type P2Tool } from "@p2/app/v1/worker";
import { type ForwardView, MAX_FORWARD_NAME, type PortsReply, type PortsRequest } from "./protocol.ts";
/** What the agent learns: every forward, its addresses, who opened it, and the public endpoints spelled out. */
function describe(reply: PortsReply, self: string): string {
const label = (forward: ForwardView) =>
`port ${forward.port}${forward.name ? ` (${JSON.stringify(forward.name)})` : ""}`;
const opener = (forward: ForwardView) =>
forward.session === self
? "opened by this session"
: forward.session
? `opened by session ${JSON.stringify(forward.sessionTitle ?? forward.session)}`
: "added by the owner";
const lines = reply.forwards.map(
(forward) =>
`- ${label(forward)}, ${forward.public ? "PUBLIC" : "private"}, ${opener(forward)}: ${
forward.url ?? "address not known yet"
}${forward.publicUrl ? `, public ${forward.publicUrl}` : ""}`,
);
const publicOnes = reply.forwards.filter((forward) => forward.public);
return [
reply.forwards.length ? "Forwarded ports:" : "No ports are forwarded.",
...lines,
publicOnes.length
? `Public endpoints, open to anyone with the link without signing in: ${publicOnes
.map((forward) => `${label(forward)} ${forward.publicUrl ?? "(address not known yet)"}`)
.join("; ")}.`
: "No forward is public.",
...(reply.addressesKnown
? []
: [
"Addresses are not known yet: Pi has not been opened through Radius by its owner. They appear once it has; the owner also finds them under Envs → Ports.",
]),
].join("\n");
}
export function createPortsExtension(
queryServer: (query: PortsRequest, context: Context) => Promise<PortsReply>,
self: () => string,
) {
return defineExtension<P2Tool>({
name: "p2-ports",
sections: [
section("ports", async (input, context) =>
(await isTopLevel(input.read, input.conversationId, context))
? "The ports tool forwards a local web server (a dev server, say) through Radius so the owner can open it from other devices, and lists all forwards and public endpoints. Forwards are private by default: only the owner can open them, after signing in. Never make a port public, by opening it with public: true or switching an existing one, without asking the user first and getting a yes: a public port is reachable by anyone with the link, without signing in."
: undefined,
),
],
tools: [
defineP2Tool({
name: "ports",
description: [
"Forward web servers on this device through Radius, so the owner can open them from anywhere.",
"list: every forward with its addresses and who opened it, and all public endpoints.",
"open (port; optional name, public): forward a port and return its address. Private by default (owner only, after signing in). public: true also opens it to anyone with the link, without signing in; ask the user before doing that. Opening a port that is already forwarded, by anyone, keeps its address and updates its name and visibility.",
"close (port): stop forwarding a port, whoever opened it.",
"Only ports of this device: a session on a remote environment cannot forward its host's ports yet. A session's forwards close when it is archived.",
].join("\n"),
parameters: obj(
{
action: union([literal("list"), literal("open"), literal("close")]),
port: opt(int({ minimum: 1, maximum: 65535 })),
name: opt(str({ minLength: 1, maxLength: MAX_FORWARD_NAME })),
public: opt(bool()),
},
{ open: true },
),
// Every action is idempotent: opening twice returns the same forward, closing twice reports it closed.
replay: "safe",
origin: "owner",
scope: "main",
execute: async (args, _api, context) => {
const reply = (text: string, details: Record<string, unknown> = {}, isError = false) => ({
output: [{ type: "text" as const, text }],
details: { action: args.action, ...(args.port === undefined ? {} : { port: args.port }), ...details },
isError,
});
let request: PortsRequest;
if (args.action === "list") request = { action: "list" };
else if (args.port === undefined) return reply(`${args.action} needs a port.`, {}, true);
else if (args.action === "open")
request = {
action: "open",
port: args.port,
...(args.name === undefined ? {} : { name: args.name }),
...(args.public === undefined ? {} : { public: args.public }),
};
else request = { action: "close", port: args.port };
try {
const result = await queryServer(request, context);
const summary =
request.action === "open" && result.forward
? `Forwarded port ${result.forward.port}${result.forward.public ? ", public" : ", private"}: ${
result.forward.url ?? "address not known yet"
}${result.forward.publicUrl ? ` (public: ${result.forward.publicUrl})` : ""}.`
: request.action === "close"
? result.closed
? `Stopped forwarding port ${request.port}.`
: `Port ${request.port} was not forwarded.`
: undefined;
return reply([summary, describe(result, self())].filter(Boolean).join("\n\n"), {
...(result.forward ? { forward: result.forward } : {}),
});
} catch (error) {
if (context.abortSignal?.aborted) throw error;
return reply(error instanceof Error ? error.message : String(error), {}, true);
}
},
}),
],
});
}