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
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,8 @@ Then invite the app to the channel and pair in that channel/thread with `relay p
|---|---|
| `/relay setup <telegram\|discord\|slack>` | open setup wizard or show headless setup guidance |
| `/relay connect <telegram\|discord\|slack> [name]` | create an expiring pairing flow for the current session |
| `/relay doctor` | diagnose configured relay channels, credentials, allow-lists, and config/state permissions |
| `/relay doctor` | diagnose configured relay channels, credentials, allow-lists, approval gates, and config/state permissions |
| `/relay approvals` | show recent non-secret approval-gate audit events for the current session |
| `/relay status` | show local relay status for the current session |
| `/relay send-file <telegram\|discord\|slack\|messenger:instance\|all> <relative-path> [caption]` | send an explicit safe workspace file/artifact to paired messenger chat(s) |
| `/relay trusted` | list locally trusted relay users |
Expand Down Expand Up @@ -276,6 +277,8 @@ Remote `/disconnect` is scoped to the requesting chat/conversation only: it revo

Remote `send-file` is requester-scoped: an authorized Telegram/Discord/Slack user may request a workspace-relative, validated path and PiRelay uploads it only back to that same conversation/thread. Targeted fan-out remains local-only via `/relay send-file <messenger|messenger:instance|all> <relative-path> [caption]`; remote forms must not include messenger targets such as `all` or `slack`.

Approval gates are optional remote confirmation guardrails for sensitive Pi tool calls such as `git push`, package publishing, destructive shell commands, or protected file writes. Configure `approvalGates.enabled` plus explicit rules; matching operations pause and ask the active authorized requester to Approve once, Approve for session, or Deny. Timeout, stale/revoked/paused bindings, offline sessions, or delivery failures block by default. See `docs/config.md` for examples.

Agent delegation is disabled by default and only applies in explicitly enabled shared rooms. Delegation task cards are visible room messages; bot-authored ordinary output remains inert, peer-bot trust is configured separately from human allow-lists, and claimed work is injected as a bounded delegated-task prompt with completion/failure reported back to the room.

## Prompt routing behavior
Expand Down
4 changes: 3 additions & 1 deletion docs/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ When Pi has an interactive TUI, `/relay setup <messenger>` opens a secret-safe s

Between adapters and relay core, PiRelay uses an interaction middleware pipeline for reusable messenger-neutral behavior. Middleware receives normalized relay events, runs in deterministic phases, and can produce prompts, channel-only responses, internal relay actions, blocked outcomes, or safe errors.

Authorization is an explicit pipeline boundary: middleware that downloads media, transcribes audio, extracts documents, invokes callbacks, or injects prompts must not run before the identity and route are authorized.
Authorization is an explicit pipeline boundary: middleware that downloads media, transcribes audio, extracts documents, invokes callbacks, approval decisions, or injects prompts must not run before the identity and route are authorized.

Approval gates use the same adapter-neutral button/fallback model. Telegram renders inline callbacks, Discord renders components when available, and Slack renders Block Kit buttons; all adapters also accept the documented `relay approval ...` text fallback. Adapters must treat approval buttons as transport only: the session-owning route revalidates pending approval id, requester identity, active binding, expiry, and grant scope before unblocking any tool call.

## Multi-machine shared bots

Expand Down
31 changes: 30 additions & 1 deletion docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,10 @@ Preferred secret style is `tokenEnv` / `signingSecretEnv` in the namespaced conf
- `PI_RELAY_DISCORD_APPLICATION_ID` (`PI_RELAY_DISCORD_CLIENT_ID` is accepted as an alias)
- `PI_RELAY_SLACK_BOT_TOKEN`
- `PI_RELAY_SLACK_SIGNING_SECRET`
- approval gate overrides: `PI_RELAY_APPROVAL_ENABLED`, `PI_RELAY_APPROVAL_TIMEOUT_MS`, `PI_RELAY_APPROVAL_SESSION_GRANTS`, `PI_RELAY_APPROVAL_REMOTE_PERSISTENT_GRANTS`, and `PI_RELAY_APPROVAL_RULES_JSON`
- legacy `PI_TELEGRAM_TUNNEL_*` variables during migration

Diagnostics never print token or signing-secret values.
Diagnostics never print token, signing-secret, or raw approval-rule secret values.

## Multi-machine shared bot setup

Expand Down Expand Up @@ -126,6 +127,34 @@ Supported autonomy levels are `off`, `propose-only`, `auto-claim-targeted`, and

See `docs/shared-room-parity.md` for the current parity matrix.

## Approval gates

Approval gates are explicit opt-in guardrails for remote turns. When enabled, matching Pi tool calls pause before execution and ask the active authorized requester to approve or deny the operation through Telegram, Discord, or Slack. Timeout, stale actions, revoked/paused bindings, offline sessions, or delivery failures block the operation; approval gates are not a sandbox.

Example:

```json
{
"approvalGates": {
"enabled": true,
"timeoutMs": 120000,
"sessionGrants": true,
"sessionGrantTtlMs": 3600000,
"allowRemotePersistentGrants": false,
"rules": [
{ "id": "git-push", "tools": ["bash"], "categories": ["git-remote"], "commandPatterns": ["git push"] },
{ "id": "publish", "tools": ["bash"], "categories": ["publish"], "commandPatterns": ["npm publish", "docker push"] },
{ "id": "destructive-shell", "tools": ["bash"], "categories": ["destructive"] },
{ "id": "protected-files", "tools": ["write", "edit"], "pathPatterns": ["package.json", ".github/workflows/"] }
]
}
}
```

Approval requests show bounded redacted summaries only. Buttons offer Approve once, Deny, and optionally Approve for session. Text fallback is `relay approval approve <id>`, `relay approval approve-session <id>`, or `relay approval deny <id>`. Remote persistent grants are hidden unless `allowRemotePersistentGrants` is explicitly true; keep that disabled unless you have a clear revocation/audit process.

`/relay doctor` reports whether approval gates are enabled, the number of rules, timeout, grant scopes, and risky settings without printing raw secrets or unredacted command data. `/relay approvals` shows recent bounded non-secret approval audit events for the current session.

## Migration from legacy Telegram tunnel config/state

Legacy files under `~/.pi/agent/telegram-tunnel` are read as migration input. Active non-secret Telegram bindings migrate to `messengers.telegram.default`; active pairing nonces are not copied, so create a fresh pairing with `/relay connect telegram` when needed.
Expand Down
1 change: 1 addition & 0 deletions docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ Check:
12. Verify input images are not echoed back by `/images` unless a tool emitted them separately as output.
13. From local Pi, run `/relay send-file telegram README.md`, `/relay send-file discord README.md`, `/relay send-file slack README.md`, and `/relay send-file all README.md` against paired chats; verify safe delivery summaries and that paused/missing bindings are skipped without raw ids in normal UX.
14. Try local `/relay send-file slack ../secret.md`, absolute paths, hidden files, symlink escapes, oversized files, and unsupported binaries; verify no messenger API upload is attempted.
15. With `approvalGates.enabled` and a rule for `git push` or `npm publish`, trigger a matching remote turn. Verify the messenger shows an approval request, Approve once allows exactly that operation, Approve for session allows a later matching operation only for the same requester/session, Deny/timeout blocks, and stale/revoked/paused bindings cannot approve.

## 6. Relay setup wizard, Discord, and Slack smoke checklist

Expand Down
38 changes: 38 additions & 0 deletions extensions/relay/adapters/discord/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { deliverWorkspaceFileToRequester, formatRequesterFileDeliveryResult, par
import { classifySharedRoomEvent, normalizeMachineSelector, parseSharedRoomSessionsArgs, parseSharedRoomToArgs, parseSharedRoomUseArgs, resolveSharedRoomMachineTarget, sharedRoomAddressingFromEvent, sharedRoomMachineIdentity, type SharedRoomAddressing, type SharedRoomMachineIdentity } from "../../core/shared-room.js";
import { buildDelegatedTaskPrompt, delegationCommandFromAction, delegationIngressEventKey, delegationRoomFromMessage, evaluateDelegationIngress, isPeerBotIdentity } from "../../core/agent-delegation-runtime.js";
import { transitionDelegationTask, type DelegationTaskRecord } from "../../core/agent-delegation.js";
import { parseApprovalActionData, parseApprovalTextCommand } from "../../core/approval-gates.js";

const DISCORD_CHANNEL = "discord" as const;
const IMAGE_PROMPT_FALLBACK = "Please inspect the attached image.";
Expand Down Expand Up @@ -205,6 +206,7 @@ export class DiscordRuntime {
if (!this.adapter || event.channel !== DISCORD_CHANNEL) return;
try {
if (event.kind === "action") {
if (await this.handleApprovalAction(event)) return;
if (await this.handleDelegationAction(event)) return;
await this.handleAction(event);
return;
Expand Down Expand Up @@ -390,6 +392,27 @@ export class DiscordRuntime {
return this.sharedRoomAddressing(message)?.kind === "local";
}

private async handleApprovalAction(action: ChannelInboundAction): Promise<boolean> {
const parsed = parseApprovalActionData(action.actionData);
if (!parsed) return false;
const binding = await this.findDiscordBinding(action);
const route = binding ? this.routes.get(binding.sessionKey) : undefined;
if (!binding || !route?.actions.resolveApprovalDecision) {
await this.adapter?.answerAction(action.actionId, { text: "Approval request is stale.", alert: true });
return true;
}
const result = await route.actions.resolveApprovalDecision({
approvalId: parsed.approvalId,
decision: parsed.decision,
channel: DISCORD_CHANNEL,
instanceId: this.instanceId,
conversationId: action.conversation.id,
userId: action.sender.userId,
});
await this.adapter?.answerAction(action.actionId, { text: result.message, alert: !result.ok });
return true;
}

private async handleDelegationAction(action: ChannelInboundAction): Promise<boolean> {
if (!this.adapter || action.conversation.kind === "private") return false;
const command = delegationCommandFromAction(action);
Expand Down Expand Up @@ -693,6 +716,16 @@ export class DiscordRuntime {
}

private async handleCommand(message: ChannelInboundMessage, binding: ChannelPersistedBindingRecord, route: SessionRoute, command: DiscordCommand): Promise<void> {
const approvalCommand = parseApprovalTextCommand(command.name, command.args);
if (approvalCommand) {
if (!route.actions.resolveApprovalDecision) {
await this.sendText(message, "Approval request is stale.");
return;
}
const result = await route.actions.resolveApprovalDecision({ approvalId: approvalCommand.approvalId, decision: approvalCommand.decision, channel: DISCORD_CHANNEL, instanceId: this.instanceId, conversationId: message.conversation.id, userId: message.sender.userId });
await this.sendText(message, result.message);
return;
}
switch (command.name) {
case "help":
await this.sendText(message, DISCORD_HELP_TEXT);
Expand Down Expand Up @@ -1182,6 +1215,11 @@ export class DiscordRuntime {
await this.adapter?.answerAction(action.actionId, { text: "Action received." });
}

async sendApprovalRequestToRequester(requester: RelayFileDeliveryRequester, text: string, buttons?: ChannelButtonLayout): Promise<void> {
if (!this.adapter) throw new Error("Discord adapter is not started.");
await this.adapter.sendText({ channel: DISCORD_CHANNEL, conversationId: requester.conversationId, userId: requester.userId }, text, { buttons });
}

private async sendText(message: ChannelInboundMessage, text: string, buttons?: ChannelButtonLayout): Promise<void> {
await this.adapter?.sendText({ channel: DISCORD_CHANNEL, conversationId: message.conversation.id, userId: message.sender.userId }, text, { buttons });
}
Expand Down
39 changes: 39 additions & 0 deletions extensions/relay/adapters/slack/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { SlackChannelAdapter, isSlackIdentityAllowed, slackEnvelopeToChannelEven
import { createSlackLiveOperations, type SlackMessageEventFromHistory } from "./live-client.js";
import { delegationCommandFromAction, delegationIngressEventKey, delegationRoomFromMessage, evaluateDelegationIngress, isPeerBotIdentity } from "../../core/agent-delegation-runtime.js";
import { transitionDelegationTask, type DelegationTaskRecord } from "../../core/agent-delegation.js";
import { parseApprovalActionData, parseApprovalTextCommand } from "../../core/approval-gates.js";

const SLACK_CHANNEL = "slack" as const;
const SLACK_HELP_TEXT = buildHelpText({
Expand Down Expand Up @@ -351,6 +352,7 @@ export class SlackRuntime {
if (!this.adapter || event.channel !== SLACK_CHANNEL) return;
try {
if (event.kind === "action") {
if (await this.handleApprovalAction(event)) return;
if (actionFromDelegationSurface(event)) {
await this.handleDelegationAction(event);
return;
Expand All @@ -367,6 +369,28 @@ export class SlackRuntime {
}
}

private async handleApprovalAction(action: ChannelInboundAction): Promise<boolean> {
const parsed = parseApprovalActionData(action.actionData);
if (!parsed) return false;
const binding = await this.findSlackBinding(action);
const route = binding ? this.routes.get(binding.sessionKey) : undefined;
if (!binding || !route?.actions.resolveApprovalDecision || !this.adapter) {
await this.adapter?.answerAction(action.actionId, { text: "Approval request is stale." });
return true;
}
const result = await route.actions.resolveApprovalDecision({
approvalId: parsed.approvalId,
decision: parsed.decision,
channel: SLACK_CHANNEL,
instanceId: this.instanceId,
conversationId: action.conversation.id,
userId: action.sender.userId,
threadId: typeof action.metadata?.threadTs === "string" ? action.metadata.threadTs : undefined,
});
await this.adapter.answerAction(action.actionId, { text: result.message });
return true;
}

private async handleAction(action: ChannelInboundAction): Promise<void> {
const slackConfig = this.configForInstance();
if (!this.adapter || !slackConfig) return;
Expand Down Expand Up @@ -721,6 +745,16 @@ export class SlackRuntime {
}

private async handleSlackCommand(message: ChannelInboundMessage, binding: ChannelPersistedBindingRecord, route: SessionRoute, command: SlackCommand): Promise<void> {
const approvalCommand = parseApprovalTextCommand(command.name, command.args);
if (approvalCommand) {
if (!route.actions.resolveApprovalDecision) {
await this.sendText(message, "Approval request is stale.");
return;
}
const result = await route.actions.resolveApprovalDecision({ approvalId: approvalCommand.approvalId, decision: approvalCommand.decision, channel: SLACK_CHANNEL, instanceId: this.instanceId, conversationId: message.conversation.id, userId: message.sender.userId, threadId: typeof message.metadata?.threadTs === "string" ? message.metadata.threadTs : undefined });
await this.sendText(message, result.message);
return;
}
if (binding.paused && !commandAllowsWhilePaused(command.name)) {
await this.sendText(message, "Remote delivery is paused for this Slack binding. Use `relay resume` first.");
return;
Expand Down Expand Up @@ -1160,6 +1194,11 @@ export class SlackRuntime {
return undefined;
}

async sendApprovalRequestToRequester(requester: RelayFileDeliveryRequester, text: string, buttons?: ChannelButtonLayout): Promise<void> {
if (!this.adapter) throw new Error("Slack adapter is not started.");
await this.adapter.sendText({ channel: SLACK_CHANNEL, conversationId: requester.conversationId, userId: requester.userId, ...(requester.threadId ? { threadTs: requester.threadId } : {}) } as ChannelRouteAddress, text, { buttons });
}

private async sendText(message: Pick<ChannelInboundMessage, "conversation" | "sender"> & { metadata?: Record<string, unknown> }, text: string, buttons?: ChannelButtonLayout): Promise<void> {
const responseUrl = typeof message.metadata?.responseUrl === "string" ? message.metadata.responseUrl : undefined;
if (responseUrl && this.operations?.postResponse && !this.wasResponseUrlConsumed(responseUrl)) {
Expand Down
35 changes: 35 additions & 0 deletions extensions/relay/adapters/telegram/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import { formatFullOutput, formatRelayStatusForRoute, formatSessionSelectorError
import { commandIntentFromPipeline, runTelegramIngressPipeline, telegramActionFromPipelineResult } from "./middleware.js";
import { delegationIngressEventKey, delegationRoomFromMessage, evaluateDelegationIngress } from "../../core/agent-delegation-runtime.js";
import { transitionDelegationTask, type DelegationTaskRecord } from "../../core/agent-delegation.js";
import { parseApprovalActionData, parseApprovalTextCommand } from "../../core/approval-gates.js";
import {
appendRecentActivity,
displayProgressMode,
Expand Down Expand Up @@ -826,6 +827,30 @@ export class InProcessTunnelRuntime implements TunnelRuntime {
}

private async processCallback(callback: TelegramInboundCallback): Promise<void> {
const approvalAction = parseApprovalActionData(callback.data);
if (approvalAction) {
const binding = await this.activeBindingForMessage(callback.chat.id, callback.user.id);
const route = binding ? this.routes.get(binding.sessionKey) : undefined;
if (!route?.actions.resolveApprovalDecision) {
await this.api.answerCallbackQuery(callback.callbackQueryId, "Approval request is stale.");
return;
}
if (!route.binding || !(await this.isAuthorized(route, callback.user))) {
await this.api.answerCallbackQuery(callback.callbackQueryId, "Unauthorized.");
return;
}
const result = await route.actions.resolveApprovalDecision({
approvalId: approvalAction.approvalId,
decision: approvalAction.decision,
channel: "telegram",
instanceId: "default",
conversationId: String(callback.chat.id),
userId: String(callback.user.id),
});
await this.api.answerCallbackQuery(callback.callbackQueryId, result.message);
return;
}

const delegationAction = parseDelegationActionId(callback.data);
if (delegationAction) {
if (!await this.isTelegramDelegationCallbackAuthorized(callback)) return;
Expand Down Expand Up @@ -1681,6 +1706,16 @@ export class InProcessTunnelRuntime implements TunnelRuntime {
command: string,
args: string,
): Promise<void> {
const approvalCommand = parseApprovalTextCommand(command, args);
if (approvalCommand && route?.actions.resolveApprovalDecision) {
const result = await route.actions.resolveApprovalDecision({ approvalId: approvalCommand.approvalId, decision: approvalCommand.decision, channel: "telegram", instanceId: "default", conversationId: String(message.chat.id), userId: String(message.user.id) });
await this.api.sendPlainText(message.chat.id, result.message);
return;
}
if (approvalCommand) {
await this.api.sendPlainText(message.chat.id, "Approval request is stale.");
return;
}
if (command === "help") {
await this.api.sendPlainText(message.chat.id, HELP_TEXT);
return;
Expand Down
Loading