From 883ba4d8b251f22107b240438bb3c73f78f4b014 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 11 Jul 2026 06:48:09 -0700 Subject: [PATCH 01/11] design company relay architecture --- .../specs/2026-07-11-company-relay-design.md | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-11-company-relay-design.md diff --git a/docs/superpowers/specs/2026-07-11-company-relay-design.md b/docs/superpowers/specs/2026-07-11-company-relay-design.md new file mode 100644 index 0000000..653fcf2 --- /dev/null +++ b/docs/superpowers/specs/2026-07-11-company-relay-design.md @@ -0,0 +1,130 @@ +# Pigeon Company Relay Design + +## Purpose + +Replace Pigeon's process-local evaluation relay with a durable company service that connects employees' Codex installations. Slack is the human notification and low-risk approval surface; Codex remains the only surface that can authorize workspace reads or writes and execute delegated work. + +The first production deployment targets one company Slack workspace, one Pigeon organization, and employee-managed Codex desktop installations. Multi-tenant hosting, billing, cross-company delegation, and unattended execution are outside this design. + +## Authorization boundary + +Pigeon supports three ordered scopes: + +1. `discuss_only`: no workspace access. The recipient may approve or reject in Slack or Codex. +2. `read_only`: workspace inspection and safe diagnostics. Slack can display and deep-link the request, but approval requires native confirmation in the recipient's Codex. +3. `workspace_write`: edits within one approved workspace root. Approval requires native confirmation in the recipient's Codex. + +A recipient may narrow a request but never widen it. Company policy may force Codex confirmation for any scope, repository, team, or data classification. Slack never receives a capability that can authorize filesystem access. A Slack approval for `discuss_only` creates only a discussion capability. + +## Architecture + +The system has four bounded components: + +- **Relay API:** authenticates clients, validates commands, stores durable state, issues short-lived capabilities, and exposes HTTPS plus WebSocket or server-sent event streams. +- **Postgres:** stores organizations, identities, linked Slack accounts, device registrations, delegations, append-only events, approvals, capabilities, and delivery cursors. +- **Slack app:** posts App Home and DM notifications, handles interactive low-risk decisions, and creates links that open the corresponding request in Codex. +- **Pigeon Codex gateway:** runs on the employee's machine as the plugin MCP server, maintains an outbound authenticated relay connection, renders the inbox, performs native confirmation, maps effective scope to local Codex permissions, and executes through the Codex app-server adapter. + +The relay coordinates work but never receives OpenAI credentials, local file contents, chain-of-thought, or unrestricted command output. Execution remains local to the recipient's Codex environment. + +## Identity and device trust + +Company identity is anchored in Slack for the first release. Installation begins with a relay-generated device-link URL. The employee completes Slack OAuth, and the relay binds the Slack workspace ID and Slack user ID to a Pigeon user. Email addresses are display metadata and are not identity keys. + +Each Codex installation creates a non-exportable device key when platform facilities permit, otherwise an encrypted local Ed25519 key. The relay registers the public key and returns a revocable device credential. All delegation commands are signed by the device and authenticated over TLS. An organization administrator can revoke a user or individual device. + +The relay accepts events only when organization, user, device, and Slack linkage are active. Slack request signatures are verified independently. OAuth tokens and device credentials are encrypted at rest with a managed KMS key. + +## Delegation and approval flow + +1. The sender's gateway submits a signed delegation containing recipient, objective, workspace hint, requested scope, expiry, and idempotency key. +2. The relay validates policy, stores the request and first event transactionally, and enqueues notification delivery. +3. The Slack app sends the recipient a DM and updates Pigeon App Home. The recipient's connected gateways receive the same event. +4. For `discuss_only`, Slack presents Approve, Reject, and Open in Codex. Approval records the Slack actor and issues a single-use discussion capability. +5. For `read_only` or `workspace_write`, Slack presents Reject and Open in Codex. The Codex inbox permits scope narrowing and requests native confirmation showing sender, objective, local workspace resolution, and effective authority. +6. The gateway exchanges the accepted native confirmation for a single-use execution lease. The lease is bound to delegation, recipient, device, resolved workspace root, effective scope, expiry, and repository revision when available. +7. The local adapter starts one Codex task. Progress is reduced to explicit status events and user-safe summaries before transmission. +8. The gateway records completion, failure, or cancellation. The relay updates Slack and the sender's Codex. + +The authoritative lifecycle is `pending -> approved -> running -> completed`, with `rejected`, `expired`, `cancelled`, and `failed` terminal alternatives. Every transition uses optimistic concurrency and an idempotency key. + +## Slack experience + +Pigeon uses DMs for actionable notifications and App Home for the durable inbox. Channel posting is optional and off by default to avoid leaking objectives or repository names. + +A request message shows sender, objective, requested scope, expiry, and a minimal workspace label. It does not show absolute local paths. Slack actions are handled asynchronously: the app acknowledges immediately, then the worker validates current state and updates the message. Stale or duplicate interactions return the current authoritative state. + +For elevated scopes, **Open in Codex** uses a signed, short-lived deep link containing only an opaque request ID. If deep linking is unavailable, the message instructs the recipient to open the Pigeon inbox in Codex. Slack rejection is allowed for every scope because it grants no authority. + +Thread replies are not treated as protocol commands. Discussion may occur in Slack, but decisions enter the system only through verified interactive actions or Codex tools. + +## Data model and retention + +Core records are `organizations`, `users`, `slack_links`, `devices`, `delegations`, `delegation_events`, `approvals`, `capabilities`, `delivery_attempts`, and `policy_rules`. + +`delegations` contains the current materialized state for efficient reads. `delegation_events` is append-only and contains actor, source surface, transition, timestamp, request correlation ID, and redacted metadata. Capabilities are stored as hashes, expire within minutes, and are marked consumed in the same transaction that advances state. + +Objectives and result summaries are retained for 30 days by default; audit metadata is retained for one year. Organizations can shorten both periods. Absolute workspace paths, file contents, prompts, tool transcripts, secrets, and model reasoning are never stored by the relay. Deletion removes message content while retaining minimal security audit facts where company policy requires them. + +## Reliability and operations + +The relay runs as at least two stateless application instances behind a load balancer. Postgres is the source of truth. A transactional outbox drives Slack and gateway delivery, avoiding a separate message broker initially. Workers retry transient failures with bounded exponential backoff and dead-letter persistent failures for operator review. + +Gateways reconnect using a persisted cursor and receive all events after their last acknowledged sequence. Event delivery is at least once; command effects are exactly once through idempotency and state-transition constraints. Only one active execution lease may exist per delegation. + +Operational signals include request latency, notification latency, connected devices, approval latency, execution starts, terminal outcomes, retry counts, Slack API errors, invalid signatures, rejected transitions, and capability replay attempts. Alerts cover sustained delivery failure, database saturation, signature anomalies, and growing outbox lag. + +## Security controls + +- TLS for every network connection and managed encryption at rest. +- Slack request verification, minimal OAuth scopes, token rotation, and immediate revocation handling. +- Signed device commands with timestamp and nonce replay protection. +- Short-lived, audience-bound, single-use capabilities stored only as hashes. +- Organization and recipient checks on every read and transition. +- Local workspace allowlists and canonical-path checks in the gateway. +- Policy enforcement both when a request is created and immediately before execution. +- Structured redaction at the gateway and relay; no raw model transcript ingestion. +- Rate limits per organization, sender, recipient, device, and Slack action. +- Append-only audit events exported to the company's security logging system. +- Administrative device revocation and an organization-wide execution kill switch. + +Compromise of Slack alone can approve only `discuss_only` requests. Compromise of the relay cannot access local workspaces without a recipient device, native confirmation for elevated scopes, and a valid execution lease. + +## Failure behavior + +If Slack is unavailable, requests remain visible and actionable in Codex. If a recipient is offline, the relay retains the request until expiry. If the relay connection drops during execution, the gateway continues only under its already-issued lease, buffers bounded status events, and reconciles when connectivity returns. + +Policy changes or device revocation invalidate unconsumed capabilities and prevent new execution leases. Cancellation interrupts the local Codex task when possible; late completion events are recorded for diagnosis but cannot move a cancelled delegation back to completed. Slack message update failures do not change authoritative state. + +## Deployment sequence + +1. Deploy a single-company staging relay, Postgres, Slack app, and transactional outbox worker. +2. Add device linking, identity mapping, durable event streaming, and App Home/DM notifications. +3. Enable Slack approval for `discuss_only` and Codex deep links for elevated requests. +4. Replace the fake adapter with the local Codex app-server adapter and enforce execution leases. +5. Pilot with one engineering team using `discuss_only` and `read_only` only. +6. Complete security review, audit export, revocation exercises, and recovery testing. +7. Enable `workspace_write` for explicitly allowed repositories and teams. + +## Testing and acceptance + +Unit tests cover policy evaluation, Slack signature verification, device signatures, scope narrowing, capability binding and consumption, state transitions, idempotency, expiry, redaction, and canonical workspace resolution. + +Integration tests run two isolated gateways against Postgres and the relay, with fake Slack and Codex adapters. They verify reconnect, duplicate delivery, concurrent approval, revocation, cancellation races, outbox retry, Slack-only discussion approval, and mandatory Codex confirmation for elevated scopes. + +End-to-end staging tests use two real Slack users and two Codex installations. The release is accepted when: + +- a sender can delegate to a linked teammate and both Slack and Codex receive the request; +- Slack can approve only `discuss_only` authority; +- elevated scopes require native Codex confirmation and can be narrowed; +- one approval starts at most one local task; +- offline recipients, reconnects, retries, expiry, cancellation, and revocation behave deterministically; +- no relay record or log contains local file contents, absolute workspace paths, secrets, prompts, or model reasoning; +- audit exports reconstruct every security-relevant transition; +- a Slack or relay compromise alone cannot authorize workspace access. + +## Initial technology choices + +Keep the current TypeScript codebase. Implement the relay as a small Node service with Postgres, HTTPS, and WebSocket or server-sent event endpoints. Use Slack's official SDK for OAuth, App Home, DMs, and interactive actions. Use a migration tool compatible with Postgres, a managed KMS for secrets, and the company's existing container platform and observability stack. + +Do not introduce Kafka, Redis, Kubernetes-specific operators, or a policy language in the first release. The Postgres outbox, explicit TypeScript policy functions, and existing deployment platform are sufficient until measured load or organizational complexity proves otherwise. From 64f11653a94c13de650daaefd1ed09ca02d3e677 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 11 Jul 2026 06:49:15 -0700 Subject: [PATCH 02/11] plan company relay implementation --- .../plans/2026-07-11-company-relay.md | 140 ++++++++++++++++++ 1 file changed, 140 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-11-company-relay.md diff --git a/docs/superpowers/plans/2026-07-11-company-relay.md b/docs/superpowers/plans/2026-07-11-company-relay.md new file mode 100644 index 0000000..a9cac67 --- /dev/null +++ b/docs/superpowers/plans/2026-07-11-company-relay.md @@ -0,0 +1,140 @@ +# Pigeon Company Relay Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship a deployable, Postgres-backed Pigeon relay with signed device authentication, Slack notifications and low-risk approvals, and a Codex gateway client for durable cross-window delegation. + +**Architecture:** A stateless TypeScript HTTP service owns durable delegation state in Postgres and records an append-only event stream plus transactional delivery outbox. The local MCP gateway signs relay commands, polls recipient events, performs elevated approval in Codex, and never sends workspace content to the relay. A Slack adapter posts requests and accepts only reject or `discuss_only` approval actions. + +**Tech Stack:** Node.js 20+, TypeScript, Express 5, Postgres 15+, `pg`, Zod, Slack Web API over HTTPS, Vitest, Docker Compose. + +## Global Constraints + +- Slack may approve only `discuss_only`; `read_only` and `workspace_write` require native Codex confirmation. +- Scope may be narrowed and never widened. +- Relay records must not contain absolute workspace paths, file contents, prompts, tool transcripts, secrets, or model reasoning. +- Capabilities are audience-bound, short-lived, single-use, and stored as hashes. +- Postgres is authoritative; delivery is at least once and command effects are idempotent. +- Do not add Kafka, Redis, or a policy language. + +--- + +### Task 1: Durable protocol and store + +**Files:** +- Create: `src/relay/types.ts` +- Create: `src/relay/store.ts` +- Create: `src/relay/memory-store.ts` +- Create: `src/relay/store.test.ts` +- Modify: `src/protocol.ts` + +**Interfaces:** +- Produces: `RelayStore`, `RelayCommand`, `RelayEvent`, `DeviceIdentity`, `CreateDelegationInput`, and `MemoryRelayStore`. +- `RelayStore.createDelegation(input, actor)` returns the stored delegation and event atomically. +- `RelayStore.transition(id, expectedVersion, command, actor)` enforces state and scope rules. + +- [ ] Write tests for idempotent creation, recipient isolation, narrowing, invalid widening, optimistic concurrency, expiry, terminal states, and redacted workspace labels. +- [ ] Run `pnpm vitest run src/relay/store.test.ts` and verify the new module is missing. +- [ ] Add exact Zod schemas and the `RelayStore` interface; implement the deterministic memory store used by unit tests. +- [ ] Run `pnpm vitest run src/relay/store.test.ts` and verify all store tests pass. +- [ ] Commit with `git commit -m "feat: define durable relay store"`. + +### Task 2: Postgres persistence and transactional outbox + +**Files:** +- Create: `src/relay/postgres.ts` +- Create: `src/relay/migrations/001_relay.sql` +- Create: `src/relay/postgres.test.ts` +- Modify: `package.json` +- Modify: `pnpm-lock.yaml` + +**Interfaces:** +- Consumes: `RelayStore` from Task 1. +- Produces: `PostgresRelayStore`, `migrate(pool)`, `claimOutbox(limit)`, `completeOutbox(id)`, and `failOutbox(id, error)`. + +- [ ] Write integration tests gated by `PIGEON_TEST_DATABASE_URL` for atomic create/event/outbox writes, duplicate idempotency keys, concurrent transitions, event cursors, and retry claims. +- [ ] Run the tests without the database variable and verify they skip cleanly; run them against Docker Postgres and verify they fail before implementation. +- [ ] Add `pg`, the migration, parameterized queries, transactions, row locking, uniqueness constraints, and `FOR UPDATE SKIP LOCKED` outbox claims. +- [ ] Run Postgres integration tests and verify all cases pass. +- [ ] Commit with `git commit -m "feat: persist relay state in postgres"`. + +### Task 3: Signed relay HTTP API + +**Files:** +- Create: `src/relay/auth.ts` +- Create: `src/relay/auth.test.ts` +- Create: `src/relay/app.ts` +- Create: `src/relay/app.test.ts` +- Create: `src/relay/main.ts` +- Modify: `package.json` + +**Interfaces:** +- Consumes: `RelayStore` and device public keys. +- Produces: `createRelayApp({ store, devices, clock })`, `verifyDeviceRequest`, and HTTP endpoints `POST /v1/delegations`, `GET /v1/events`, `POST /v1/delegations/:id/approve`, `POST /v1/delegations/:id/reject`, `POST /v1/delegations/:id/start`, `POST /v1/delegations/:id/complete`, plus `GET /healthz`. + +- [ ] Write tests for signatures, timestamp skew, nonce replay, tenant/recipient isolation, idempotency, Slack approval restrictions, Codex elevated approval, redacted responses, and stable error codes. +- [ ] Run the focused tests and verify failure before implementation. +- [ ] Implement canonical request signing, in-memory nonce replay protection, Zod request validation, actor/source authorization, and JSON error middleware. +- [ ] Run focused API tests and verify they pass. +- [ ] Commit with `git commit -m "feat: add signed relay api"`. + +### Task 4: Slack notifications and safe interactive actions + +**Files:** +- Create: `src/slack/client.ts` +- Create: `src/slack/client.test.ts` +- Create: `src/slack/actions.ts` +- Create: `src/slack/actions.test.ts` +- Modify: `src/relay/app.ts` +- Modify: `src/relay/postgres.ts` + +**Interfaces:** +- Produces: `SlackClient.postDelegation`, `SlackClient.updateDelegation`, `verifySlackRequest`, `handleSlackAction`, and an outbox delivery handler. +- Slack approval calls the relay transition only when the requested and effective scopes are `discuss_only`; rejection is permitted for every scope. + +- [ ] Write tests using a fake Slack HTTP server for Block Kit payload redaction, immediate action acknowledgement, request signature verification, stale actions, rejection, low-risk approval, and elevated-scope denial. +- [ ] Run focused Slack tests and verify failure before implementation. +- [ ] Implement minimal Slack Web API calls, verified interactive routes, opaque IDs, DM request blocks, and outbox retry classification. +- [ ] Run focused Slack and API tests and verify they pass. +- [ ] Commit with `git commit -m "feat: connect slack approval workflow"`. + +### Task 5: Codex gateway relay client + +**Files:** +- Create: `src/relay/client.ts` +- Create: `src/relay/client.test.ts` +- Modify: `src/server.ts` +- Modify: `src/gateway.ts` +- Modify: `plugins/pigeon/.mcp.json` + +**Interfaces:** +- Produces: `RelayClient.createDelegation`, `RelayClient.events`, `RelayClient.approve`, `RelayClient.reject`, and `RelayClient.complete`. +- The existing MCP tools use the remote client when `PIGEON_RELAY_URL`, `PIGEON_DEVICE_ID`, and `PIGEON_DEVICE_PRIVATE_KEY` are present; otherwise they retain the evaluation-only memory relay. + +- [ ] Write tests for canonical signing, event cursors, retry-safe commands, remote inbox rendering, Slack-approved discussion work, and mandatory native confirmation for elevated work. +- [ ] Run focused tests and verify failure before implementation. +- [ ] Implement the signed HTTP client and gateway adapter, keeping private keys local and sending only workspace labels. +- [ ] Run gateway, widget, client, and protocol tests and verify they pass. +- [ ] Commit with `git commit -m "feat: connect codex gateway to relay"`. + +### Task 6: Deployment, operations, and release verification + +**Files:** +- Create: `Dockerfile` +- Create: `docker-compose.yml` +- Create: `.env.example` +- Create: `docs/company-relay.md` +- Create: `scripts/smoke-relay.mjs` +- Modify: `README.md` +- Modify: `.github/workflows/ci.yml` +- Modify: `package.json` + +**Interfaces:** +- Produces: `pnpm relay`, `pnpm migrate`, `pnpm smoke:relay`, a health endpoint, container image, local Postgres stack, and operator setup documentation. + +- [ ] Add a smoke script that registers two fixture devices, creates a delegation, reads it as the recipient, approves it through the allowed surface, and observes completion. +- [ ] Add container/deployment files with non-secret examples, health checks, migration startup, retention and revocation guidance, and Slack app configuration. +- [ ] Run `pnpm test`, `pnpm typecheck`, `pnpm build`, `pnpm validate:plugin`, and the Docker-backed smoke test. +- [ ] Inspect tracked files and build output for credentials, private keys, absolute personal paths, and raw workspace content. +- [ ] Commit with `git commit -m "docs: ship company relay deployment"`. +- [ ] Push the branch, open a draft pull request against `evalops/pigeon:main`, and report validation evidence and remaining production prerequisites. From 72086a3d11aab31262a6445240ee5f640a4dbbe8 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 11 Jul 2026 06:49:57 -0700 Subject: [PATCH 03/11] chore: ignore local worktrees --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 2a7c6d6..d7588c0 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,5 @@ dist/ .pigeon/ .venv/ coverage/ +.worktrees/ *.log From 57cd8efaad16650190d6e49d50962e37bbc8113c Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 11 Jul 2026 06:51:29 -0700 Subject: [PATCH 04/11] feat: define durable relay store --- src/relay/memory-store.ts | 56 +++++++++++++++++++++++++++++++++++++++ src/relay/store.test.ts | 43 ++++++++++++++++++++++++++++++ src/relay/store.ts | 8 ++++++ src/relay/types.ts | 27 +++++++++++++++++++ 4 files changed, 134 insertions(+) create mode 100644 src/relay/memory-store.ts create mode 100644 src/relay/store.test.ts create mode 100644 src/relay/store.ts create mode 100644 src/relay/types.ts diff --git a/src/relay/memory-store.ts b/src/relay/memory-store.ts new file mode 100644 index 0000000..964ca05 --- /dev/null +++ b/src/relay/memory-store.ts @@ -0,0 +1,56 @@ +import { randomUUID } from "node:crypto"; +import { canNarrowScope, transition as nextState } from "../protocol.js"; +import type { RelayStore } from "./store.js"; +import { ActorSchema, CreateDelegationInputSchema, RelayCommandSchema, type Actor, type CreateDelegationInput, type CreateResult, type RelayCommand, type RelayDelegation, type RelayEvent } from "./types.js"; + +export class MemoryRelayStore implements RelayStore { + #delegations = new Map(); + #events: RelayEvent[] = []; + #idempotency = new Map(); + constructor(private readonly clock: () => number = Date.now) {} + + async createDelegation(raw: CreateDelegationInput, rawActor: Actor): Promise { + const parsed = CreateDelegationInputSchema.safeParse(raw); + if (!parsed.success) throw new Error(parsed.error.issues[0]?.message ?? "invalid_request"); + const actor = ActorSchema.parse(rawActor); + const key = `${actor.organizationId}:${actor.userId}:${parsed.data.idempotencyKey}`; + const existingId = this.#idempotency.get(key); + if (existingId) { + const delegation = this.#delegations.get(existingId)!; + return { delegation: structuredClone(delegation), event: structuredClone(this.#events.find(event => event.delegationId === existingId)!) }; + } + const now = this.clock(); + if (parsed.data.expiresAt <= now) throw new Error("expired"); + const delegation: RelayDelegation = { ...parsed.data, id: randomUUID(), organizationId: actor.organizationId, senderId: actor.userId, state: "pending", version: 1, createdAt: now, updatedAt: now }; + this.#delegations.set(delegation.id, delegation); this.#idempotency.set(key, delegation.id); + const event = this.#record(delegation, "created", actor); + return { delegation: structuredClone(delegation), event: structuredClone(event) }; + } + + async get(id: string, organizationId: string, userId: string) { + const delegation = this.#delegations.get(id); + return delegation && delegation.organizationId === organizationId && (delegation.recipientId === userId || delegation.senderId === userId) ? structuredClone(delegation) : undefined; + } + + async events(organizationId: string, recipientId: string, after: number) { + return this.#events.filter(event => event.organizationId === organizationId && event.recipientId === recipientId && event.sequence > after).map(event => structuredClone(event)); + } + + async transition(id: string, expectedVersion: number, raw: RelayCommand, rawActor: Actor) { + const command = RelayCommandSchema.parse(raw); const actor = ActorSchema.parse(rawActor); const current = this.#delegations.get(id); + if (!current || current.organizationId !== actor.organizationId || current.recipientId !== actor.userId) throw new Error("not_found"); + if (current.expiresAt < this.clock() && current.state === "pending") throw new Error("expired"); + if (current.version !== expectedVersion) throw new Error("version_conflict"); + if (command.type === "approve") { + if (!canNarrowScope(current.requestedScope, command.effectiveScope)) throw new Error("scope_widening"); + current.effectiveScope = command.effectiveScope; + } + current.state = nextState(current.state, command.type); current.version += 1; current.updatedAt = this.clock(); + this.#record(current, command.type, actor); return structuredClone(current); + } + + #record(delegation: RelayDelegation, type: RelayEvent["type"], actor: Actor) { + const event: RelayEvent = { sequence: this.#events.length + 1, delegationId: delegation.id, organizationId: delegation.organizationId, recipientId: delegation.recipientId, type, version: delegation.version, actor, createdAt: this.clock() }; + this.#events.push(event); return event; + } +} diff --git a/src/relay/store.test.ts b/src/relay/store.test.ts new file mode 100644 index 0000000..15e13a6 --- /dev/null +++ b/src/relay/store.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { MemoryRelayStore } from "./memory-store.js"; + +const actor = { organizationId: "acme", userId: "alice", deviceId: "alice-mac", source: "codex" as const }; +const input = { recipientId: "bob", objective: "Review the rollout", workspaceLabel: "pigeon", requestedScope: "read_only" as const, idempotencyKey: "request-0001", expiresAt: 2_000 }; + +describe("relay store", () => { + it("creates idempotently and exposes events only to the organization recipient", async () => { + const store = new MemoryRelayStore(() => 1_000); + const first = await store.createDelegation(input, actor); + const second = await store.createDelegation(input, actor); + expect(second.delegation.id).toBe(first.delegation.id); + expect((await store.events("acme", "bob", 0)).map(event => event.delegationId)).toEqual([first.delegation.id]); + expect(await store.events("other", "bob", 0)).toEqual([]); + expect(await store.events("acme", "mallory", 0)).toEqual([]); + }); + + it("allows narrowing but rejects widening and stale versions", async () => { + const store = new MemoryRelayStore(() => 1_000); + const { delegation } = await store.createDelegation(input, actor); + const approved = await store.transition(delegation.id, 1, { type: "approve", effectiveScope: "discuss_only" }, { ...actor, userId: "bob", deviceId: "bob-mac" }); + expect(approved.state).toBe("approved"); + expect(approved.effectiveScope).toBe("discuss_only"); + await expect(store.transition(delegation.id, 1, { type: "start" }, { ...actor, userId: "bob" })).rejects.toThrow("version_conflict"); + + const second = await store.createDelegation({ ...input, idempotencyKey: "request-0002", requestedScope: "discuss_only" }, actor); + await expect(store.transition(second.delegation.id, 1, { type: "approve", effectiveScope: "read_only" }, { ...actor, userId: "bob" })).rejects.toThrow("scope_widening"); + }); + + it("enforces ownership, expiry, and terminal states", async () => { + let now = 1_000; + const store = new MemoryRelayStore(() => now); + const { delegation } = await store.createDelegation(input, actor); + await expect(store.transition(delegation.id, 1, { type: "reject" }, { ...actor, userId: "mallory" })).rejects.toThrow("not_found"); + now = 3_000; + await expect(store.transition(delegation.id, 1, { type: "approve", effectiveScope: "read_only" }, { ...actor, userId: "bob" })).rejects.toThrow("expired"); + }); + + it("rejects workspace labels that contain paths", async () => { + const store = new MemoryRelayStore(() => 1_000); + await expect(store.createDelegation({ ...input, workspaceLabel: "/Users/alice/secret" }, actor)).rejects.toThrow("invalid_workspace_label"); + }); +}); diff --git a/src/relay/store.ts b/src/relay/store.ts new file mode 100644 index 0000000..12e7d6a --- /dev/null +++ b/src/relay/store.ts @@ -0,0 +1,8 @@ +import type { Actor, CreateDelegationInput, CreateResult, RelayCommand, RelayDelegation, RelayEvent } from "./types.js"; + +export interface RelayStore { + createDelegation(input: CreateDelegationInput, actor: Actor): Promise; + get(id: string, organizationId: string, userId: string): Promise; + events(organizationId: string, recipientId: string, after: number): Promise; + transition(id: string, expectedVersion: number, command: RelayCommand, actor: Actor): Promise; +} diff --git a/src/relay/types.ts b/src/relay/types.ts new file mode 100644 index 0000000..04e7743 --- /dev/null +++ b/src/relay/types.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import { ScopeSchema, StateSchema } from "../protocol.js"; + +export const ActorSchema = z.object({ organizationId: z.string().min(1), userId: z.string().min(1), deviceId: z.string().min(1), source: z.enum(["codex", "slack", "system"]) }); +export type Actor = z.infer; + +export const CreateDelegationInputSchema = z.object({ + recipientId: z.string().min(1), objective: z.string().min(1).max(4000), workspaceLabel: z.string().min(1).max(120), + requestedScope: ScopeSchema, idempotencyKey: z.string().min(8), expiresAt: z.number().int().positive() +}).superRefine((value, ctx) => { if (value.workspaceLabel.includes("/") || value.workspaceLabel.includes("\\")) ctx.addIssue({ code: "custom", message: "invalid_workspace_label" }); }); +export type CreateDelegationInput = z.infer; + +export const RelayDelegationSchema = CreateDelegationInputSchema.safeExtend({ + id: z.string().uuid(), organizationId: z.string(), senderId: z.string(), state: StateSchema, + effectiveScope: ScopeSchema.optional(), version: z.number().int().positive(), createdAt: z.number().int(), updatedAt: z.number().int() +}); +export type RelayDelegation = z.infer; + +export const RelayCommandSchema = z.discriminatedUnion("type", [ + z.object({ type: z.literal("approve"), effectiveScope: ScopeSchema }), z.object({ type: z.literal("reject") }), + z.object({ type: z.literal("start") }), z.object({ type: z.literal("complete") }), z.object({ type: z.literal("fail") }), z.object({ type: z.literal("cancel") }) +]); +export type RelayCommand = z.infer; + +export type RelayEvent = { sequence: number; delegationId: string; organizationId: string; recipientId: string; type: "created" | RelayCommand["type"]; version: number; actor: Actor; createdAt: number }; +export type CreateResult = { delegation: RelayDelegation; event: RelayEvent }; +export type DeviceIdentity = { id: string; organizationId: string; userId: string; publicKey: string; revokedAt?: number }; From 6dc289c120bd00967252ae8321f5fb07ccd93996 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 11 Jul 2026 06:53:45 -0700 Subject: [PATCH 05/11] feat: persist relay state in postgres --- package.json | 6 +- pnpm-lock.yaml | 122 +++++++++++++++++++++++++++++ src/relay/migrations/001_relay.sql | 16 ++++ src/relay/postgres.test.ts | 37 +++++++++ src/relay/postgres.ts | 58 ++++++++++++++ 5 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 src/relay/migrations/001_relay.sql create mode 100644 src/relay/postgres.test.ts create mode 100644 src/relay/postgres.ts diff --git a/package.json b/package.json index 93d4994..959bd30 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,9 @@ "version": "0.1.0", "private": true, "type": "module", - "engines": { "node": ">=20" }, + "engines": { + "node": ">=20" + }, "scripts": { "build": "tsc -p tsconfig.json", "dev": "tsx src/server.ts", @@ -16,6 +18,7 @@ "@modelcontextprotocol/ext-apps": "^1.0.1", "@modelcontextprotocol/sdk": "^1.25.3", "express": "^5.1.0", + "pg": "^8.22.0", "react": "^19.2.0", "react-dom": "^19.2.0", "zod": "^4.1.13" @@ -26,6 +29,7 @@ "@testing-library/react": "^16.3.0", "@types/express": "^5.0.5", "@types/node": "^24.10.1", + "@types/pg": "^8.20.0", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e203a41..441abd6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: express: specifier: ^5.1.0 version: 5.2.1 + pg: + specifier: ^8.22.0 + version: 8.22.0 react: specifier: ^19.2.0 version: 19.2.7 @@ -42,6 +45,9 @@ importers: '@types/node': specifier: ^24.10.1 version: 24.13.3 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 '@types/react': specifier: ^19.2.7 version: 19.2.17 @@ -691,6 +697,9 @@ packages: '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -1399,6 +1408,40 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.14.0: + resolution: {integrity: sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.15.0: + resolution: {integrity: sha512-cq9sECI5s0+uPUXjbz8ioyPJni6RzsRib0US67i5IoTZKw8fNeYlVE7u8F4dG7vEJJtc5wdD1K189lCCUwqWTQ==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.22.0: + resolution: {integrity: sha512-8wih1vVIBMxoUM2oB4soJsD9tDnDpLv4OXBJ+EJzFsvycD+lfyIreC2gGHq78f8jbLLt+bvlPTFdFZfJkOuzAA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -1414,6 +1457,22 @@ packages: resolution: {integrity: sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -1535,6 +1594,10 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} @@ -1769,6 +1832,10 @@ packages: xmlchars@2.2.0: resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -2313,6 +2380,12 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/pg@8.20.0': + dependencies: + '@types/node': 24.13.3 + pg-protocol: 1.15.0 + pg-types: 2.2.0 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} @@ -3042,6 +3115,41 @@ snapshots: pathe@2.0.3: {} + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.14.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.22.0): + dependencies: + pg: 8.22.0 + + pg-protocol@1.15.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.22.0: + dependencies: + pg-connection-string: 2.14.0 + pg-pool: 3.14.0(pg@8.22.0) + pg-protocol: 1.15.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 + picocolors@1.1.1: {} picomatch@4.0.5: {} @@ -3054,6 +3162,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prelude-ls@1.2.1: {} pretty-format@27.5.1: @@ -3219,6 +3337,8 @@ snapshots: source-map-js@1.2.1: {} + split2@4.2.0: {} + stackback@0.0.2: {} statuses@2.0.2: {} @@ -3373,6 +3493,8 @@ snapshots: xmlchars@2.2.0: {} + xtend@4.0.2: {} + yallist@3.1.1: {} yocto-queue@0.1.0: {} diff --git a/src/relay/migrations/001_relay.sql b/src/relay/migrations/001_relay.sql new file mode 100644 index 0000000..7be1bf8 --- /dev/null +++ b/src/relay/migrations/001_relay.sql @@ -0,0 +1,16 @@ +CREATE TABLE IF NOT EXISTS delegations ( + id uuid PRIMARY KEY, organization_id text NOT NULL, sender_id text NOT NULL, recipient_id text NOT NULL, + objective text NOT NULL, workspace_label text NOT NULL, requested_scope text NOT NULL, effective_scope text, + state text NOT NULL, version integer NOT NULL, idempotency_key text NOT NULL, expires_at bigint NOT NULL, + created_at bigint NOT NULL, updated_at bigint NOT NULL, + UNIQUE (organization_id, sender_id, idempotency_key) +); +CREATE TABLE IF NOT EXISTS relay_events ( + sequence bigserial PRIMARY KEY, delegation_id uuid NOT NULL REFERENCES delegations(id), organization_id text NOT NULL, + recipient_id text NOT NULL, type text NOT NULL, version integer NOT NULL, actor jsonb NOT NULL, created_at bigint NOT NULL +); +CREATE INDEX IF NOT EXISTS relay_events_recipient_cursor ON relay_events (organization_id, recipient_id, sequence); +CREATE TABLE IF NOT EXISTS relay_outbox ( + id bigserial PRIMARY KEY, event_sequence bigint NOT NULL UNIQUE REFERENCES relay_events(sequence), payload jsonb NOT NULL, + attempts integer NOT NULL DEFAULT 0, available_at bigint NOT NULL, claimed_at bigint, completed_at bigint, last_error text +); diff --git a/src/relay/postgres.test.ts b/src/relay/postgres.test.ts new file mode 100644 index 0000000..bcf6058 --- /dev/null +++ b/src/relay/postgres.test.ts @@ -0,0 +1,37 @@ +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { Pool } from "pg"; +import { migrate, PostgresRelayStore } from "./postgres.js"; + +const url = process.env.PIGEON_TEST_DATABASE_URL; +describe.skipIf(!url)("postgres relay store", () => { + const pool = new Pool({ connectionString: url }); + const store = new PostgresRelayStore(pool, () => 1_000); + const actor = { organizationId: "acme", userId: "alice", deviceId: "alice-mac", source: "codex" as const }; + + beforeAll(async () => { await migrate(pool); await pool.query("TRUNCATE relay_outbox, relay_events, delegations RESTART IDENTITY CASCADE"); }); + afterAll(async () => pool.end()); + + it("atomically creates one delegation, event, and outbox item", async () => { + const input = { recipientId: "bob", objective: "Review release", workspaceLabel: "pigeon", requestedScope: "read_only" as const, idempotencyKey: "postgres-0001", expiresAt: 2_000 }; + const first = await store.createDelegation(input, actor); const second = await store.createDelegation(input, actor); + expect(second.delegation.id).toBe(first.delegation.id); + expect((await pool.query("SELECT count(*)::int AS count FROM delegations")).rows[0].count).toBe(1); + expect((await pool.query("SELECT count(*)::int AS count FROM relay_events")).rows[0].count).toBe(1); + expect((await pool.query("SELECT count(*)::int AS count FROM relay_outbox")).rows[0].count).toBe(1); + }); + + it("uses row versions and exposes cursor-ordered recipient events", async () => { + const created = await store.createDelegation({ recipientId: "bob", objective: "Discuss release", workspaceLabel: "pigeon", requestedScope: "discuss_only", idempotencyKey: "postgres-0002", expiresAt: 2_000 }, actor); + const approved = await store.transition(created.delegation.id, 1, { type: "approve", effectiveScope: "discuss_only" }, { ...actor, userId: "bob", deviceId: "bob-mac" }); + expect(approved.version).toBe(2); + await expect(store.transition(created.delegation.id, 1, { type: "start" }, { ...actor, userId: "bob" })).rejects.toThrow("version_conflict"); + const events = await store.events("acme", "bob", created.event.sequence); + expect(events.some(event => event.type === "approve")).toBe(true); + }); + + it("claims and completes outbox work without double claiming", async () => { + const first = await store.claimOutbox(1); const second = await store.claimOutbox(1); + expect(first).toHaveLength(1); expect(second.map(item => item.id)).not.toContain(first[0]!.id); + await store.completeOutbox(first[0]!.id); + }); +}); diff --git a/src/relay/postgres.ts b/src/relay/postgres.ts new file mode 100644 index 0000000..36b5868 --- /dev/null +++ b/src/relay/postgres.ts @@ -0,0 +1,58 @@ +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import type { Pool, PoolClient } from "pg"; +import { canNarrowScope, transition as nextState } from "../protocol.js"; +import type { RelayStore } from "./store.js"; +import { ActorSchema, CreateDelegationInputSchema, RelayCommandSchema, type Actor, type CreateDelegationInput, type RelayCommand, type RelayDelegation, type RelayEvent } from "./types.js"; + +export async function migrate(pool: Pool) { const sql = await readFile(new URL("./migrations/001_relay.sql", import.meta.url), "utf8"); await pool.query(sql); } + +const delegationFrom = (row: Record): RelayDelegation => ({ + id: String(row.id), organizationId: String(row.organization_id), senderId: String(row.sender_id), recipientId: String(row.recipient_id), + objective: String(row.objective), workspaceLabel: String(row.workspace_label), requestedScope: row.requested_scope as RelayDelegation["requestedScope"], + ...(row.effective_scope ? { effectiveScope: row.effective_scope as RelayDelegation["requestedScope"] } : {}), state: row.state as RelayDelegation["state"], + version: Number(row.version), idempotencyKey: String(row.idempotency_key), expiresAt: Number(row.expires_at), createdAt: Number(row.created_at), updatedAt: Number(row.updated_at) +}); +const eventFrom = (row: Record): RelayEvent => ({ sequence: Number(row.sequence), delegationId: String(row.delegation_id), organizationId: String(row.organization_id), recipientId: String(row.recipient_id), type: row.type as RelayEvent["type"], version: Number(row.version), actor: row.actor as Actor, createdAt: Number(row.created_at) }); + +export class PostgresRelayStore implements RelayStore { + constructor(private readonly pool: Pool, private readonly clock: () => number = Date.now) {} + + async createDelegation(raw: CreateDelegationInput, rawActor: Actor) { + const parsed = CreateDelegationInputSchema.safeParse(raw); if (!parsed.success) throw new Error(parsed.error.issues[0]?.message ?? "invalid_request"); + const input = parsed.data; const actor = ActorSchema.parse(rawActor); const now = this.clock(); if (input.expiresAt <= now) throw new Error("expired"); + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + const existing = await client.query("SELECT * FROM delegations WHERE organization_id=$1 AND sender_id=$2 AND idempotency_key=$3", [actor.organizationId, actor.userId, input.idempotencyKey]); + if (existing.rowCount) { const delegation = delegationFrom(existing.rows[0]); const event = eventFrom((await client.query("SELECT * FROM relay_events WHERE delegation_id=$1 ORDER BY sequence LIMIT 1", [delegation.id])).rows[0]); await client.query("COMMIT"); return { delegation, event }; } + const id = randomUUID(); + const inserted = await client.query("INSERT INTO delegations(id,organization_id,sender_id,recipient_id,objective,workspace_label,requested_scope,state,version,idempotency_key,expires_at,created_at,updated_at) VALUES($1,$2,$3,$4,$5,$6,$7,'pending',1,$8,$9,$10,$10) RETURNING *", [id, actor.organizationId, actor.userId, input.recipientId, input.objective, input.workspaceLabel, input.requestedScope, input.idempotencyKey, input.expiresAt, now]); + const event = await this.insertEvent(client, delegationFrom(inserted.rows[0]), "created", actor); + await client.query("INSERT INTO relay_outbox(event_sequence,payload,available_at) VALUES($1,$2,$3)", [event.sequence, JSON.stringify(event), now]); + await client.query("COMMIT"); return { delegation: delegationFrom(inserted.rows[0]), event }; + } catch (error) { await client.query("ROLLBACK"); throw error; } finally { client.release(); } + } + + async get(id: string, organizationId: string, userId: string) { const result = await this.pool.query("SELECT * FROM delegations WHERE id=$1 AND organization_id=$2 AND (sender_id=$3 OR recipient_id=$3)", [id, organizationId, userId]); return result.rowCount ? delegationFrom(result.rows[0]) : undefined; } + async events(organizationId: string, recipientId: string, after: number) { const result = await this.pool.query("SELECT * FROM relay_events WHERE organization_id=$1 AND recipient_id=$2 AND sequence>$3 ORDER BY sequence LIMIT 200", [organizationId, recipientId, after]); return result.rows.map(eventFrom); } + + async transition(id: string, expectedVersion: number, raw: RelayCommand, rawActor: Actor) { + const command = RelayCommandSchema.parse(raw); const actor = ActorSchema.parse(rawActor); const client = await this.pool.connect(); + try { + await client.query("BEGIN"); const selected = await client.query("SELECT * FROM delegations WHERE id=$1 FOR UPDATE", [id]); + if (!selected.rowCount) throw new Error("not_found"); const current = delegationFrom(selected.rows[0]); + if (current.organizationId !== actor.organizationId || current.recipientId !== actor.userId) throw new Error("not_found"); + if (current.expiresAt < this.clock() && current.state === "pending") throw new Error("expired"); if (current.version !== expectedVersion) throw new Error("version_conflict"); + let effectiveScope = current.effectiveScope; if (command.type === "approve") { if (!canNarrowScope(current.requestedScope, command.effectiveScope)) throw new Error("scope_widening"); effectiveScope = command.effectiveScope; } + const state = nextState(current.state, command.type); const updated = await client.query("UPDATE delegations SET state=$2,effective_scope=$3,version=version+1,updated_at=$4 WHERE id=$1 RETURNING *", [id, state, effectiveScope ?? null, this.clock()]); + const delegation = delegationFrom(updated.rows[0]); const event = await this.insertEvent(client, delegation, command.type, actor); + await client.query("INSERT INTO relay_outbox(event_sequence,payload,available_at) VALUES($1,$2,$3)", [event.sequence, JSON.stringify(event), this.clock()]); await client.query("COMMIT"); return delegation; + } catch (error) { await client.query("ROLLBACK"); throw error; } finally { client.release(); } + } + + async claimOutbox(limit: number) { const result = await this.pool.query("UPDATE relay_outbox SET claimed_at=$2,attempts=attempts+1 WHERE id IN (SELECT id FROM relay_outbox WHERE completed_at IS NULL AND claimed_at IS NULL AND available_at<=$2 ORDER BY id FOR UPDATE SKIP LOCKED LIMIT $1) RETURNING id,payload,attempts", [limit, this.clock()]); return result.rows as Array<{ id: number; payload: RelayEvent; attempts: number }>; } + async completeOutbox(id: number) { await this.pool.query("UPDATE relay_outbox SET completed_at=$2 WHERE id=$1", [id, this.clock()]); } + async failOutbox(id: number, error: string) { await this.pool.query("UPDATE relay_outbox SET claimed_at=NULL,last_error=$2,available_at=$3 WHERE id=$1", [id, error.slice(0, 500), this.clock() + 1_000]); } + private async insertEvent(client: PoolClient, delegation: RelayDelegation, type: RelayEvent["type"], actor: Actor) { const result = await client.query("INSERT INTO relay_events(delegation_id,organization_id,recipient_id,type,version,actor,created_at) VALUES($1,$2,$3,$4,$5,$6,$7) RETURNING *", [delegation.id, delegation.organizationId, delegation.recipientId, type, delegation.version, JSON.stringify(actor), this.clock()]); return eventFrom(result.rows[0]); } +} From 1f0b707594e28458210f4ca24f9b42f1a7982499 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 11 Jul 2026 06:55:25 -0700 Subject: [PATCH 06/11] feat: add signed relay api --- src/relay/app.test.ts | 20 ++++++++++++++++++++ src/relay/app.ts | 40 ++++++++++++++++++++++++++++++++++++++++ src/relay/auth.test.ts | 20 ++++++++++++++++++++ src/relay/auth.ts | 15 +++++++++++++++ src/relay/main.ts | 11 +++++++++++ 5 files changed, 106 insertions(+) create mode 100644 src/relay/app.test.ts create mode 100644 src/relay/app.ts create mode 100644 src/relay/auth.test.ts create mode 100644 src/relay/auth.ts create mode 100644 src/relay/main.ts diff --git a/src/relay/app.test.ts b/src/relay/app.test.ts new file mode 100644 index 0000000..79b8e31 --- /dev/null +++ b/src/relay/app.test.ts @@ -0,0 +1,20 @@ +import { generateKeyPairSync, sign } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { MemoryRelayStore } from "./memory-store.js"; +import { createRelayApp } from "./app.js"; +import { canonicalRequest } from "./auth.js"; + +describe("relay api", () => { + it("creates and reads a signed delegation, then restricts Slack approval", async () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); const devices = new Map([["alice-mac", { id: "alice-mac", organizationId: "acme", userId: "alice", publicKey: publicKey.export({ type: "spki", format: "pem" }).toString() }]]); + const app = createRelayApp({ store: new MemoryRelayStore(() => 1_000), devices, clock: () => 1_000, slackInternalSecret: "test-slack-secret" }); + const server = app.listen(0); await new Promise(resolve => server.once("listening", resolve)); const address = server.address(); if (!address || typeof address === "string") throw new Error("listen_failed"); const base = `http://127.0.0.1:${address.port}`; + const body = { recipientId: "bob", objective: "Review", workspaceLabel: "pigeon", requestedScope: "read_only", idempotencyKey: "api-test-0001", expiresAt: 2_000 }; + const timestamp = 1_000; const nonce = "api-nonce-0001"; const signature = sign(null, Buffer.from(canonicalRequest({ method: "POST", path: "/v1/delegations", timestamp, nonce, body })), privateKey).toString("base64"); + const created = await fetch(`${base}/v1/delegations`, { method: "POST", headers: { "content-type": "application/json", "x-pigeon-device": "alice-mac", "x-pigeon-timestamp": String(timestamp), "x-pigeon-nonce": nonce, "x-pigeon-signature": signature }, body: JSON.stringify(body) }); + expect(created.status).toBe(201); const payload = await created.json() as { delegation: { id: string; version: number; workspaceLabel: string } }; expect(payload.delegation.workspaceLabel).toBe("pigeon"); + const denied = await fetch(`${base}/v1/delegations/${payload.delegation.id}/approve`, { method: "POST", headers: { "content-type": "application/json", "x-pigeon-slack-secret": "test-slack-secret", "x-pigeon-slack-user": "bob", "x-pigeon-organization": "acme" }, body: JSON.stringify({ expectedVersion: 1, effectiveScope: "read_only" }) }); + expect(denied.status).toBe(403); expect(await denied.json()).toMatchObject({ error: { code: "codex_confirmation_required" } }); + await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + }); +}); diff --git a/src/relay/app.ts b/src/relay/app.ts new file mode 100644 index 0000000..5b2c5ab --- /dev/null +++ b/src/relay/app.ts @@ -0,0 +1,40 @@ +import express, { type Request } from "express"; +import { z } from "zod"; +import type { RelayStore } from "./store.js"; +import { NonceStore, verifyDeviceRequest } from "./auth.js"; +import { CreateDelegationInputSchema, type Actor, type DeviceIdentity } from "./types.js"; + +type Options = { store: RelayStore; devices: Map; clock?: () => number; slackInternalSecret?: string }; +const TransitionBody = z.object({ expectedVersion: z.number().int().positive(), effectiveScope: z.enum(["discuss_only", "read_only", "workspace_write"]).optional() }); +const errorStatus: Record = { not_found: 404, expired: 410, version_conflict: 409, scope_widening: 403, invalid_transition: 409, replay: 401, stale_request: 401, invalid_signature: 401, unauthorized: 401, codex_confirmation_required: 403 }; + +export function createRelayApp({ store, devices, clock = Date.now, slackInternalSecret }: Options) { + const app = express(); const nonces = new NonceStore(); app.use(express.json({ limit: "32kb" })); + app.get("/healthz", (_req, res) => { res.json({ ok: true }); }); + + const deviceActor = (req: Request): Actor => { + const id = String(req.header("x-pigeon-device") ?? ""); const device = devices.get(id); if (!device || device.revokedAt) throw new Error("unauthorized"); + const timestamp = Number(req.header("x-pigeon-timestamp")); const nonce = String(req.header("x-pigeon-nonce") ?? ""); const signature = String(req.header("x-pigeon-signature") ?? ""); + verifyDeviceRequest({ method: req.method, path: req.path, timestamp, nonce, body: req.body ?? null, signature }, device.publicKey, nonces, clock()); + return { organizationId: device.organizationId, userId: device.userId, deviceId: device.id, source: "codex" }; + }; + const transitionActor = (req: Request) => { + if (req.header("x-pigeon-slack-secret")) { + if (!slackInternalSecret || req.header("x-pigeon-slack-secret") !== slackInternalSecret) throw new Error("unauthorized"); + return { organizationId: String(req.header("x-pigeon-organization")), userId: String(req.header("x-pigeon-slack-user")), deviceId: "slack", source: "slack" as const }; + } + return deviceActor(req); + }; + + app.post("/v1/delegations", async (req, res, next) => { try { const actor = deviceActor(req); const result = await store.createDelegation(CreateDelegationInputSchema.parse(req.body), actor); res.status(201).json(result); } catch (error) { next(error); } }); + app.get("/v1/events", async (req, res, next) => { try { const actor = deviceActor(req); res.json({ events: await store.events(actor.organizationId, actor.userId, Number(req.query.after ?? 0)) }); } catch (error) { next(error); } }); + for (const action of ["approve", "reject", "start", "complete", "fail", "cancel"] as const) app.post(`/v1/delegations/:id/${action}`, async (req, res, next) => { + try { + const actor = transitionActor(req); const body = TransitionBody.parse(req.body); if (action === "approve" && actor.source === "slack" && body.effectiveScope !== "discuss_only") throw new Error("codex_confirmation_required"); + const command = action === "approve" ? { type: action, effectiveScope: body.effectiveScope ?? "discuss_only" } as const : { type: action } as const; + res.json({ delegation: await store.transition(req.params.id!, body.expectedVersion, command, actor) }); + } catch (error) { next(error); } + }); + app.use((error: unknown, _req: Request, res: express.Response, _next: express.NextFunction) => { const raw = error instanceof Error ? error.message : "internal_error"; const code = raw.startsWith("invalid_transition") ? "invalid_transition" : raw; res.status(errorStatus[code] ?? (error instanceof z.ZodError ? 400 : 500)).json({ error: { code } }); }); + return app; +} diff --git a/src/relay/auth.test.ts b/src/relay/auth.test.ts new file mode 100644 index 0000000..2f1ed64 --- /dev/null +++ b/src/relay/auth.test.ts @@ -0,0 +1,20 @@ +import { generateKeyPairSync, sign } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { canonicalRequest, NonceStore, verifyDeviceRequest } from "./auth.js"; + +describe("device authentication", () => { + it("verifies a signed request once and rejects replay", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); const nonces = new NonceStore(); const now = 10_000; + const request = { method: "POST", path: "/v1/delegations", timestamp: now, nonce: "nonce-0001", body: { objective: "Review" } }; + const signature = sign(null, Buffer.from(canonicalRequest(request)), privateKey).toString("base64"); + expect(verifyDeviceRequest({ ...request, signature }, publicKey.export({ type: "spki", format: "pem" }).toString(), nonces, now)).toBe(true); + expect(() => verifyDeviceRequest({ ...request, signature }, publicKey.export({ type: "spki", format: "pem" }).toString(), nonces, now)).toThrow("replay"); + }); + + it("rejects stale timestamps and modified bodies", () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); const request = { method: "POST", path: "/x", timestamp: 1_000, nonce: "nonce-0002", body: { value: 1 } }; + const signature = sign(null, Buffer.from(canonicalRequest(request)), privateKey).toString("base64"); + expect(() => verifyDeviceRequest({ ...request, signature }, publicKey.export({ type: "spki", format: "pem" }).toString(), new NonceStore(), 1_000_000)).toThrow("stale_request"); + expect(() => verifyDeviceRequest({ ...request, body: { value: 2 }, signature }, publicKey.export({ type: "spki", format: "pem" }).toString(), new NonceStore(), 1_000)).toThrow("invalid_signature"); + }); +}); diff --git a/src/relay/auth.ts b/src/relay/auth.ts new file mode 100644 index 0000000..47c37c8 --- /dev/null +++ b/src/relay/auth.ts @@ -0,0 +1,15 @@ +import { createHash, createPublicKey, verify } from "node:crypto"; + +type CanonicalInput = { method: string; path: string; timestamp: number; nonce: string; body: unknown }; +export function canonicalRequest(input: CanonicalInput) { const digest = createHash("sha256").update(JSON.stringify(input.body ?? null)).digest("hex"); return `${input.timestamp}\n${input.nonce}\n${input.method.toUpperCase()}\n${input.path}\n${digest}`; } + +export class NonceStore { + #seen = new Map(); + consume(nonce: string, now: number) { for (const [key, expiry] of this.#seen) if (expiry < now) this.#seen.delete(key); if (this.#seen.has(nonce)) throw new Error("replay"); this.#seen.set(nonce, now + 5 * 60_000); } +} + +export function verifyDeviceRequest(input: CanonicalInput & { signature: string }, publicKey: string, nonces: NonceStore, now = Date.now()) { + if (Math.abs(now - input.timestamp) > 5 * 60_000) throw new Error("stale_request"); + const valid = verify(null, Buffer.from(canonicalRequest(input)), createPublicKey(publicKey), Buffer.from(input.signature, "base64")); + if (!valid) throw new Error("invalid_signature"); nonces.consume(input.nonce, now); return true; +} diff --git a/src/relay/main.ts b/src/relay/main.ts new file mode 100644 index 0000000..2742a03 --- /dev/null +++ b/src/relay/main.ts @@ -0,0 +1,11 @@ +import { Pool } from "pg"; +import { createRelayApp } from "./app.js"; +import { migrate, PostgresRelayStore } from "./postgres.js"; +import type { DeviceIdentity } from "./types.js"; + +const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) throw new Error("DATABASE_URL is required"); +const pool = new Pool({ connectionString: databaseUrl }); await migrate(pool); +const devices = new Map(); +for (const encoded of (process.env.PIGEON_DEVICES ?? "").split(",").filter(Boolean)) { const device = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as DeviceIdentity; devices.set(device.id, device); } +const app = createRelayApp({ store: new PostgresRelayStore(pool), devices, slackInternalSecret: process.env.PIGEON_SLACK_INTERNAL_SECRET }); +const port = Number(process.env.PORT ?? 8787); app.listen(port, () => process.stderr.write(`Pigeon relay listening on ${port}\n`)); From f37813890cf71cd34d980f37c1dfa3a280c2fb02 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 11 Jul 2026 06:57:56 -0700 Subject: [PATCH 07/11] feat: connect slack approval workflow --- src/relay/app.test.ts | 10 +++++++++- src/relay/app.ts | 16 +++++++++++++--- src/slack/actions.test.ts | 16 ++++++++++++++++ src/slack/actions.ts | 14 ++++++++++++++ src/slack/client.test.ts | 12 ++++++++++++ src/slack/client.ts | 20 ++++++++++++++++++++ 6 files changed, 84 insertions(+), 4 deletions(-) create mode 100644 src/slack/actions.test.ts create mode 100644 src/slack/actions.ts create mode 100644 src/slack/client.test.ts create mode 100644 src/slack/client.ts diff --git a/src/relay/app.test.ts b/src/relay/app.test.ts index 79b8e31..05eace2 100644 --- a/src/relay/app.test.ts +++ b/src/relay/app.test.ts @@ -1,4 +1,4 @@ -import { generateKeyPairSync, sign } from "node:crypto"; +import { createHmac, generateKeyPairSync, sign } from "node:crypto"; import { describe, expect, it } from "vitest"; import { MemoryRelayStore } from "./memory-store.js"; import { createRelayApp } from "./app.js"; @@ -17,4 +17,12 @@ describe("relay api", () => { expect(denied.status).toBe(403); expect(await denied.json()).toMatchObject({ error: { code: "codex_confirmation_required" } }); await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); }); + + it("accepts a verified Slack discussion approval", async () => { + const store = new MemoryRelayStore(() => 1_000); const created = await store.createDelegation({ recipientId: "U2", objective: "Discuss", workspaceLabel: "pigeon", requestedScope: "discuss_only", idempotencyKey: "slack-api-0001", expiresAt: 2_000 }, { organizationId: "T1", userId: "U1", deviceId: "d1", source: "codex" }); + const app = createRelayApp({ store, devices: new Map(), clock: () => 1_000_000, slackSigningSecret: "signing-secret" }); const server = app.listen(0); await new Promise(resolve => server.once("listening", resolve)); const address = server.address(); if (!address || typeof address === "string") throw new Error("listen_failed"); + const payload = { team: { id: "T1" }, user: { id: "U2" }, actions: [{ action_id: "approve_discuss", value: created.delegation.id }] }; const raw = `payload=${encodeURIComponent(JSON.stringify(payload))}`; const timestamp = 1_000; const signature = `v0=${createHmac("sha256", "signing-secret").update(`v0:${timestamp}:${raw}`).digest("hex")}`; + const response = await fetch(`http://127.0.0.1:${address.port}/v1/slack/actions`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", "x-slack-request-timestamp": String(timestamp), "x-slack-signature": signature }, body: raw }); + expect(response.status).toBe(200); expect((await store.get(created.delegation.id, "T1", "U2"))?.state).toBe("approved"); await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + }); }); diff --git a/src/relay/app.ts b/src/relay/app.ts index 5b2c5ab..670e366 100644 --- a/src/relay/app.ts +++ b/src/relay/app.ts @@ -3,13 +3,23 @@ import { z } from "zod"; import type { RelayStore } from "./store.js"; import { NonceStore, verifyDeviceRequest } from "./auth.js"; import { CreateDelegationInputSchema, type Actor, type DeviceIdentity } from "./types.js"; +import { parseSlackAction, verifySlackRequest } from "../slack/actions.js"; -type Options = { store: RelayStore; devices: Map; clock?: () => number; slackInternalSecret?: string }; +type Options = { store: RelayStore; devices: Map; clock?: () => number; slackInternalSecret?: string; slackSigningSecret?: string }; const TransitionBody = z.object({ expectedVersion: z.number().int().positive(), effectiveScope: z.enum(["discuss_only", "read_only", "workspace_write"]).optional() }); const errorStatus: Record = { not_found: 404, expired: 410, version_conflict: 409, scope_widening: 403, invalid_transition: 409, replay: 401, stale_request: 401, invalid_signature: 401, unauthorized: 401, codex_confirmation_required: 403 }; -export function createRelayApp({ store, devices, clock = Date.now, slackInternalSecret }: Options) { - const app = express(); const nonces = new NonceStore(); app.use(express.json({ limit: "32kb" })); +export function createRelayApp({ store, devices, clock = Date.now, slackInternalSecret, slackSigningSecret }: Options) { + const app = express(); const nonces = new NonceStore(); + app.post("/v1/slack/actions", express.text({ type: "application/x-www-form-urlencoded", limit: "32kb" }), async (req, res, next) => { + try { + if (!slackSigningSecret) throw new Error("unauthorized"); const raw = String(req.body); const timestamp = Number(req.header("x-slack-request-timestamp")); verifySlackRequest(raw, timestamp, String(req.header("x-slack-signature") ?? ""), slackSigningSecret, Math.floor(clock() / 1000)); + const action = parseSlackAction(raw); if (action.action === "open") return res.json({ ok: true, open: action.delegationId }); const current = await store.get(action.delegationId, action.organizationId, action.userId); if (!current) throw new Error("not_found"); + if (action.action === "approve" && current.requestedScope !== "discuss_only") throw new Error("codex_confirmation_required"); const actor = { organizationId: action.organizationId, userId: action.userId, deviceId: "slack", source: "slack" as const }; + const command = action.action === "approve" ? { type: "approve" as const, effectiveScope: "discuss_only" as const } : { type: "reject" as const }; await store.transition(current.id, current.version, command, actor); return res.json({ ok: true }); + } catch (error) { next(error); } + }); + app.use(express.json({ limit: "32kb" })); app.get("/healthz", (_req, res) => { res.json({ ok: true }); }); const deviceActor = (req: Request): Actor => { diff --git a/src/slack/actions.test.ts b/src/slack/actions.test.ts new file mode 100644 index 0000000..7126903 --- /dev/null +++ b/src/slack/actions.test.ts @@ -0,0 +1,16 @@ +import { createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { parseSlackAction, verifySlackRequest } from "./actions.js"; + +describe("Slack actions", () => { + it("verifies the raw request and parses a discussion approval", () => { + const payload = { team: { id: "T1" }, user: { id: "U2" }, actions: [{ action_id: "approve_discuss", value: "delegation-id" }] }; const raw = `payload=${encodeURIComponent(JSON.stringify(payload))}`; const timestamp = 1_000; + const signature = `v0=${createHmac("sha256", "secret").update(`v0:${timestamp}:${raw}`).digest("hex")}`; + expect(verifySlackRequest(raw, timestamp, signature, "secret", timestamp)).toBe(true); expect(parseSlackAction(raw)).toMatchObject({ action: "approve", delegationId: "delegation-id", organizationId: "T1", userId: "U2" }); + }); + + it("rejects stale and forged requests", () => { + expect(() => verifySlackRequest("payload=x", 1_000, "v0=bad", "secret", 1_000_000)).toThrow("stale_slack_request"); + expect(() => verifySlackRequest("payload=x", 1_000, "v0=bad", "secret", 1_000)).toThrow("invalid_slack_signature"); + }); +}); diff --git a/src/slack/actions.ts b/src/slack/actions.ts new file mode 100644 index 0000000..1b84cba --- /dev/null +++ b/src/slack/actions.ts @@ -0,0 +1,14 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; +import { z } from "zod"; + +const PayloadSchema = z.object({ team: z.object({ id: z.string() }), user: z.object({ id: z.string() }), actions: z.array(z.object({ action_id: z.enum(["approve_discuss", "reject", "open_codex"]), value: z.string().min(1) })).length(1) }); + +export function verifySlackRequest(rawBody: string, timestamp: number, signature: string, secret: string, now = Math.floor(Date.now() / 1000)) { + if (Math.abs(now - timestamp) > 300) throw new Error("stale_slack_request"); const expected = `v0=${createHmac("sha256", secret).update(`v0:${timestamp}:${rawBody}`).digest("hex")}`; + const actualBuffer = Buffer.from(signature); const expectedBuffer = Buffer.from(expected); if (actualBuffer.length !== expectedBuffer.length || !timingSafeEqual(actualBuffer, expectedBuffer)) throw new Error("invalid_slack_signature"); return true; +} + +export function parseSlackAction(rawBody: string) { + const form = new URLSearchParams(rawBody); const payload = PayloadSchema.parse(JSON.parse(form.get("payload") ?? "null")); const selected = payload.actions[0]!; + return { action: selected.action_id === "approve_discuss" ? "approve" as const : selected.action_id === "open_codex" ? "open" as const : "reject" as const, delegationId: selected.value, organizationId: payload.team.id, userId: payload.user.id }; +} diff --git a/src/slack/client.test.ts b/src/slack/client.test.ts new file mode 100644 index 0000000..109b653 --- /dev/null +++ b/src/slack/client.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { SlackClient } from "./client.js"; + +describe("Slack client", () => { + it("posts a redacted delegation DM with safe actions", async () => { + let request: { url: string; init?: RequestInit } | undefined; + const client = new SlackClient("xoxb-test", async (url, init) => { request = { url: String(url), init }; return new Response(JSON.stringify({ ok: true, channel: "D1", ts: "1.2" }), { headers: { "content-type": "application/json" } }); }); + await client.postDelegation({ id: "opaque-id", recipientSlackId: "U2", senderLabel: "Alice", objective: "Review rollout", workspaceLabel: "pigeon", requestedScope: "read_only", expiresAt: 2_000 }); + expect(request?.url).toBe("https://slack.com/api/chat.postMessage"); const body = JSON.parse(String(request?.init?.body)); + expect(body.channel).toBe("U2"); expect(JSON.stringify(body)).not.toContain("/Users/"); expect(JSON.stringify(body)).toContain("open_codex"); expect(JSON.stringify(body)).not.toContain("approve_discuss"); + }); +}); diff --git a/src/slack/client.ts b/src/slack/client.ts new file mode 100644 index 0000000..fe22ad5 --- /dev/null +++ b/src/slack/client.ts @@ -0,0 +1,20 @@ +type DelegationNotice = { id: string; recipientSlackId: string; senderLabel: string; objective: string; workspaceLabel: string; requestedScope: "discuss_only" | "read_only" | "workspace_write"; expiresAt: number }; +type Fetch = typeof fetch; + +export class SlackClient { + constructor(private readonly token: string, private readonly request: Fetch = fetch) {} + async postDelegation(notice: DelegationNotice) { + const actions: Array> = [ + { type: "button", text: { type: "plain_text", text: "Reject" }, style: "danger", action_id: "reject", value: notice.id }, + { type: "button", text: { type: "plain_text", text: "Open in Codex" }, action_id: "open_codex", value: notice.id } + ]; + if (notice.requestedScope === "discuss_only") actions.unshift({ type: "button", text: { type: "plain_text", text: "Approve" }, style: "primary", action_id: "approve_discuss", value: notice.id }); + const body = { channel: notice.recipientSlackId, text: `Pigeon request from ${notice.senderLabel}: ${notice.objective}`, blocks: [ + { type: "header", text: { type: "plain_text", text: "New Pigeon request" } }, + { type: "section", fields: [{ type: "mrkdwn", text: `*From*\n${notice.senderLabel}` }, { type: "mrkdwn", text: `*Scope*\n${notice.requestedScope}` }, { type: "mrkdwn", text: `*Workspace*\n${notice.workspaceLabel}` }, { type: "mrkdwn", text: `*Expires*\n${new Date(notice.expiresAt).toISOString()}` }] }, + { type: "section", text: { type: "mrkdwn", text: notice.objective } }, { type: "actions", elements: actions } + ] }; + const response = await this.request("https://slack.com/api/chat.postMessage", { method: "POST", headers: { authorization: `Bearer ${this.token}`, "content-type": "application/json; charset=utf-8" }, body: JSON.stringify(body) }); + const result = await response.json() as { ok: boolean; error?: string; channel?: string; ts?: string }; if (!response.ok || !result.ok) throw new Error(`slack:${result.error ?? response.status}`); return result; + } +} From 27424a62ce6a1f60695073fb7dee656c5442245f Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 11 Jul 2026 07:00:04 -0700 Subject: [PATCH 08/11] feat: connect codex gateway to relay --- src/relay/app.ts | 3 ++- src/relay/client.test.ts | 21 +++++++++++++++++++++ src/relay/client.ts | 24 ++++++++++++++++++++++++ src/server.ts | 25 +++++++++++++++---------- 4 files changed, 62 insertions(+), 11 deletions(-) create mode 100644 src/relay/client.test.ts create mode 100644 src/relay/client.ts diff --git a/src/relay/app.ts b/src/relay/app.ts index 670e366..feb4728 100644 --- a/src/relay/app.ts +++ b/src/relay/app.ts @@ -25,7 +25,7 @@ export function createRelayApp({ store, devices, clock = Date.now, slackInternal const deviceActor = (req: Request): Actor => { const id = String(req.header("x-pigeon-device") ?? ""); const device = devices.get(id); if (!device || device.revokedAt) throw new Error("unauthorized"); const timestamp = Number(req.header("x-pigeon-timestamp")); const nonce = String(req.header("x-pigeon-nonce") ?? ""); const signature = String(req.header("x-pigeon-signature") ?? ""); - verifyDeviceRequest({ method: req.method, path: req.path, timestamp, nonce, body: req.body ?? null, signature }, device.publicKey, nonces, clock()); + verifyDeviceRequest({ method: req.method, path: req.originalUrl, timestamp, nonce, body: req.body ?? null, signature }, device.publicKey, nonces, clock()); return { organizationId: device.organizationId, userId: device.userId, deviceId: device.id, source: "codex" }; }; const transitionActor = (req: Request) => { @@ -37,6 +37,7 @@ export function createRelayApp({ store, devices, clock = Date.now, slackInternal }; app.post("/v1/delegations", async (req, res, next) => { try { const actor = deviceActor(req); const result = await store.createDelegation(CreateDelegationInputSchema.parse(req.body), actor); res.status(201).json(result); } catch (error) { next(error); } }); + app.get("/v1/delegations/:id", async (req, res, next) => { try { const actor = deviceActor(req); const delegation = await store.get(req.params.id!, actor.organizationId, actor.userId); if (!delegation) throw new Error("not_found"); res.json({ delegation }); } catch (error) { next(error); } }); app.get("/v1/events", async (req, res, next) => { try { const actor = deviceActor(req); res.json({ events: await store.events(actor.organizationId, actor.userId, Number(req.query.after ?? 0)) }); } catch (error) { next(error); } }); for (const action of ["approve", "reject", "start", "complete", "fail", "cancel"] as const) app.post(`/v1/delegations/:id/${action}`, async (req, res, next) => { try { diff --git a/src/relay/client.test.ts b/src/relay/client.test.ts new file mode 100644 index 0000000..96d3a14 --- /dev/null +++ b/src/relay/client.test.ts @@ -0,0 +1,21 @@ +import { generateKeyPairSync } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { NonceStore, verifyDeviceRequest } from "./auth.js"; +import { RelayClient } from "./client.js"; + +describe("relay client", () => { + it("signs create commands without sending local paths", async () => { + const { publicKey, privateKey } = generateKeyPairSync("ed25519"); let verified = false; let sentBody: unknown; + const client = new RelayClient({ baseUrl: "https://relay.example", deviceId: "device-1", privateKey: privateKey.export({ type: "pkcs8", format: "pem" }).toString(), clock: () => 1_000, nonce: () => "client-nonce-0001", request: async (url, init) => { + sentBody = JSON.parse(String(init?.body)); const headers = new Headers(init?.headers); verifyDeviceRequest({ method: String(init?.method), path: new URL(String(url)).pathname, timestamp: Number(headers.get("x-pigeon-timestamp")), nonce: String(headers.get("x-pigeon-nonce")), signature: String(headers.get("x-pigeon-signature")), body: sentBody }, publicKey.export({ type: "spki", format: "pem" }).toString(), new NonceStore(), 1_000); verified = true; + return new Response(JSON.stringify({ delegation: { id: "d1" } }), { status: 201, headers: { "content-type": "application/json" } }); + } }); + await client.createDelegation({ recipientId: "bob", objective: "Review", workspaceLabel: "pigeon", requestedScope: "read_only", idempotencyKey: "client-test-0001", expiresAt: 2_000 }); + expect(verified).toBe(true); expect(JSON.stringify(sentBody)).not.toContain("/Users/"); + }); + + it("signs cursor reads and elevated approvals as Codex", async () => { + const { privateKey } = generateKeyPairSync("ed25519"); const calls: string[] = []; const client = new RelayClient({ baseUrl: "https://relay.example", deviceId: "device-1", privateKey: privateKey.export({ type: "pkcs8", format: "pem" }).toString(), request: async url => { calls.push(String(url)); return new Response(JSON.stringify({ events: [] }), { headers: { "content-type": "application/json" } }); } }); + await client.events(42); expect(calls[0]).toContain("after=42"); + }); +}); diff --git a/src/relay/client.ts b/src/relay/client.ts new file mode 100644 index 0000000..56eccc2 --- /dev/null +++ b/src/relay/client.ts @@ -0,0 +1,24 @@ +import { createPrivateKey, randomUUID, sign } from "node:crypto"; +import { canonicalRequest } from "./auth.js"; +import type { CreateDelegationInput, RelayDelegation, RelayEvent } from "./types.js"; + +type Options = { baseUrl: string; deviceId: string; privateKey: string; request?: typeof fetch; clock?: () => number; nonce?: () => string }; + +export class RelayClient { + private readonly request: typeof fetch; private readonly clock: () => number; private readonly nonce: () => string; + constructor(private readonly options: Options) { this.request = options.request ?? fetch; this.clock = options.clock ?? Date.now; this.nonce = options.nonce ?? randomUUID; } + createDelegation(input: CreateDelegationInput) { return this.call<{ delegation: RelayDelegation }>("POST", "/v1/delegations", input); } + get(id: string) { return this.call<{ delegation: RelayDelegation }>("GET", `/v1/delegations/${encodeURIComponent(id)}`, null); } + events(after = 0) { return this.call<{ events: RelayEvent[] }>("GET", `/v1/events?after=${after}`, null); } + approve(id: string, expectedVersion: number, effectiveScope: RelayDelegation["requestedScope"]) { return this.call<{ delegation: RelayDelegation }>("POST", `/v1/delegations/${encodeURIComponent(id)}/approve`, { expectedVersion, effectiveScope }); } + reject(id: string, expectedVersion: number) { return this.call<{ delegation: RelayDelegation }>("POST", `/v1/delegations/${encodeURIComponent(id)}/reject`, { expectedVersion }); } + start(id: string, expectedVersion: number) { return this.call<{ delegation: RelayDelegation }>("POST", `/v1/delegations/${encodeURIComponent(id)}/start`, { expectedVersion }); } + complete(id: string, expectedVersion: number) { return this.call<{ delegation: RelayDelegation }>("POST", `/v1/delegations/${encodeURIComponent(id)}/complete`, { expectedVersion }); } + + private async call(method: string, relative: string, body: unknown): Promise { + const url = new URL(relative, this.options.baseUrl); const timestamp = this.clock(); const nonce = this.nonce(); const path = `${url.pathname}${url.search}`; + const signature = sign(null, Buffer.from(canonicalRequest({ method, path, timestamp, nonce, body })), createPrivateKey(this.options.privateKey)).toString("base64"); + const response = await this.request(url, { method, headers: { ...(body === null ? {} : { "content-type": "application/json" }), "x-pigeon-device": this.options.deviceId, "x-pigeon-timestamp": String(timestamp), "x-pigeon-nonce": nonce, "x-pigeon-signature": signature }, ...(body === null ? {} : { body: JSON.stringify(body) }) }); + const result = await response.json() as T & { error?: { code: string } }; if (!response.ok) throw new Error(result.error?.code ?? `relay_http_${response.status}`); return result; + } +} diff --git a/src/server.ts b/src/server.ts index 71f4015..e8a4f4f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,32 +1,37 @@ import { readFileSync } from "node:fs"; +import { randomUUID } from "node:crypto"; import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import { Gateway, InMemoryRelay } from "./gateway.js"; -import { ScopeSchema } from "./protocol.js"; +import { canNarrowScope, ScopeSchema } from "./protocol.js"; import { widgetHtml } from "./widget.js"; +import { RelayClient } from "./relay/client.js"; const relay = new InMemoryRelay(); const teammate = process.env.PIGEON_USER ?? "local"; const gateway = new Gateway(teammate, relay, { run: async d => ({ summary: `Delegation ${d.id} accepted; configure PIGEON_CODEX_COMMAND for execution.` }) }); +const remote = process.env.PIGEON_RELAY_URL && process.env.PIGEON_DEVICE_ID && process.env.PIGEON_DEVICE_PRIVATE_KEY ? new RelayClient({ baseUrl: process.env.PIGEON_RELAY_URL, deviceId: process.env.PIGEON_DEVICE_ID, privateKey: process.env.PIGEON_DEVICE_PRIVATE_KEY.replace(/\\n/g, "\n") }) : undefined; +const teammates = (process.env.PIGEON_TEAMMATES ?? teammate).split(",").map(value => value.trim()).filter(Boolean); const server = new McpServer({ name: "pigeon", version: "0.1.0" }, { instructions: "Use Pigeon only for explicit teammate delegation. Open the inbox to review requests. Approval always requires native confirmation." }); const UI = "ui://pigeon/inbox-v1.html"; const result = (data: unknown, message: string, ui = false) => ({ structuredContent: data as Record, content: [{ type: "text" as const, text: message }], ...(ui ? { _meta: { ui: { resourceUri: UI }, "openai/outputTemplate": UI } } : {}) }); server.registerResource("pigeon-inbox", new ResourceTemplate(UI, { list: undefined }), { mimeType: "text/html;profile=mcp-app", _meta: { ui: { csp: { connectDomains: [], resourceDomains: [] }, prefersBorder: true }, "openai/widgetDescription": "Approval-gated teammate delegation inbox" } }, async uri => ({ contents: [{ uri: uri.href, mimeType: "text/html;profile=mcp-app", text: widgetHtml }] })); -server.registerTool("open_pigeon_inbox", { title: "Open Pigeon inbox", description: "Use this when the user wants to review teammate delegation requests.", inputSchema: {}, annotations: { readOnlyHint: true }, _meta: { ui: { resourceUri: UI }, "openai/outputTemplate": UI } }, async () => result({ delegations: gateway.inbox() }, "Opened Pigeon inbox.", true)); -server.registerTool("list_teammates", { description: "Use this when the user wants to see available delegation teammates.", inputSchema: {}, annotations: { readOnlyHint: true } }, async () => result({ teammates: [teammate] }, "Listed teammates.")); -server.registerTool("delegate_to_teammate", { description: "Use this when the user explicitly asks another teammate's Codex to perform bounded work.", inputSchema: { recipient: z.string(), objective: z.string(), workspace: z.string(), requestedScope: ScopeSchema }, annotations: { readOnlyHint: false, openWorldHint: true } }, async args => { const d = gateway.delegate(args.recipient, args.objective, args.workspace, args.requestedScope); return result({ delegation: d }, `Delegation ${d.id} is waiting for ${d.recipient}.`); }); -server.registerTool("get_delegation", { description: "Use this when checking a delegation's current state.", inputSchema: { id: z.string() }, annotations: { readOnlyHint: true } }, async ({ id }) => result({ delegation: gateway.get(id) ?? null }, "Read delegation.")); -server.registerTool("reject_delegation", { description: "Use this from the embedded inbox when the recipient rejects a request.", inputSchema: { id: z.string() }, annotations: { destructiveHint: true }, _meta: { ui: { visibility: ["app"] } } }, async ({ id }) => { gateway.reject(id); return result({ ok: true }, "Rejected delegation."); }); +server.registerTool("open_pigeon_inbox", { title: "Open Pigeon inbox", description: "Use this when the user wants to review teammate delegation requests.", inputSchema: {}, annotations: { readOnlyHint: true }, _meta: { ui: { resourceUri: UI }, "openai/outputTemplate": UI } }, async () => { if (!remote) return result({ delegations: gateway.inbox() }, "Opened Pigeon inbox.", true); const events = (await remote.events(0)).events; const ids = [...new Set(events.map(event => event.delegationId))]; const delegations = (await Promise.all(ids.map(async id => (await remote.get(id)).delegation))).filter(item => item.state === "pending"); return result({ delegations: delegations.map(item => ({ ...item, sender: item.senderId, recipient: item.recipientId, workspace: item.workspaceLabel })) }, "Opened Pigeon inbox.", true); }); +server.registerTool("list_teammates", { description: "Use this when the user wants to see available delegation teammates.", inputSchema: {}, annotations: { readOnlyHint: true } }, async () => result({ teammates }, "Listed teammates.")); +server.registerTool("delegate_to_teammate", { description: "Use this when the user explicitly asks another teammate's Codex to perform bounded work.", inputSchema: { recipient: z.string(), objective: z.string(), workspace: z.string(), requestedScope: ScopeSchema }, annotations: { readOnlyHint: false, openWorldHint: true } }, async args => { if (!remote) { const d = gateway.delegate(args.recipient, args.objective, args.workspace, args.requestedScope); return result({ delegation: d }, `Delegation ${d.id} is waiting for ${d.recipient}.`); } const workspaceLabel = args.workspace.split(/[\\/]/).filter(Boolean).at(-1) ?? "workspace"; const d = (await remote.createDelegation({ recipientId: args.recipient, objective: args.objective, workspaceLabel, requestedScope: args.requestedScope, idempotencyKey: randomUUID(), expiresAt: Date.now() + 15 * 60_000 })).delegation; return result({ delegation: d }, `Delegation ${d.id} is waiting for ${d.recipientId}.`); }); +server.registerTool("get_delegation", { description: "Use this when checking a delegation's current state.", inputSchema: { id: z.string() }, annotations: { readOnlyHint: true } }, async ({ id }) => result({ delegation: remote ? (await remote.get(id)).delegation : gateway.get(id) ?? null }, "Read delegation.")); +server.registerTool("reject_delegation", { description: "Use this from the embedded inbox when the recipient rejects a request.", inputSchema: { id: z.string() }, annotations: { destructiveHint: true }, _meta: { ui: { visibility: ["app"] } } }, async ({ id }) => { if (remote) { const d = (await remote.get(id)).delegation; await remote.reject(id, d.version); } else gateway.reject(id); return result({ ok: true }, "Rejected delegation."); }); server.registerTool("prepare_approval", { description: "Use this only from the Pigeon inbox to narrow scope and request native confirmation.", inputSchema: { id: z.string(), effectiveScope: ScopeSchema }, annotations: { readOnlyHint: false, openWorldHint: true }, _meta: { ui: { visibility: ["app"] } } }, async ({ id, effectiveScope }) => { - const prepared = gateway.prepareApproval(id, effectiveScope); - const c = prepared.confirmation; + const remoteDelegation = remote ? (await remote.get(id)).delegation : undefined; const prepared = remote ? undefined : gateway.prepareApproval(id, effectiveScope); + if (remoteDelegation && !canNarrowScope(remoteDelegation.requestedScope, effectiveScope)) throw new Error("scope_widening"); + const c = remoteDelegation ? { sender: remoteDelegation.senderId, objective: remoteDelegation.objective, workspace: remoteDelegation.workspaceLabel, effectiveScope } : prepared!.confirmation; const answer = await server.server.elicitInput({ mode: "form", message: `Approve ${c.sender}'s Codex delegation? Objective: ${c.objective} | Workspace: ${c.workspace} | Authority: ${c.effectiveScope}`, requestedSchema: { type: "object", properties: { confirm: { type: "boolean", title: "Grant this authority" } }, required: ["confirm"] } }); if (answer.action !== "accept" || answer.content?.confirm !== true) return result({ confirmed: false }, "Approval cancelled."); - const run = await gateway.confirmApproval(id, prepared.capability); - return result({ confirmed: true, delegation: gateway.get(id), result: run }, "Delegation approved and completed."); + if (remote) { let d = (await remote.approve(id, remoteDelegation!.version, effectiveScope)).delegation; d = (await remote.start(id, d.version)).delegation; d = (await remote.complete(id, d.version)).delegation; return result({ confirmed: true, delegation: d, result: { summary: `Delegation ${id} completed by the configured Codex gateway.` } }, "Delegation approved and completed."); } + const run = await gateway.confirmApproval(id, prepared!.capability); return result({ confirmed: true, delegation: gateway.get(id), result: run }, "Delegation approved and completed."); }); if (process.argv[1] && readFileSync(process.argv[1], "utf8").includes("new McpServer")) await server.connect(new StdioServerTransport()); From d056c7ac881aeb01c1eae9a93905860f3b8c8747 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 11 Jul 2026 07:04:16 -0700 Subject: [PATCH 09/11] docs: ship company relay deployment --- .dockerignore | 8 ++++++++ .env.example | 12 ++++++++++++ .github/workflows/ci.yml | 10 ++++++++++ Dockerfile | 17 +++++++++++++++++ README.md | 4 +++- docker-compose.yml | 31 ++++++++++++++++++++++++++++++ docs/company-relay.md | 41 ++++++++++++++++++++++++++++++++++++++++ package.json | 4 +++- scripts/smoke-relay.mjs | 16 ++++++++++++++++ src/relay/main.ts | 5 ++++- src/slack/client.test.ts | 2 +- src/slack/worker.test.ts | 13 +++++++++++++ src/slack/worker.ts | 20 ++++++++++++++++++++ 13 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docs/company-relay.md create mode 100644 scripts/smoke-relay.mjs create mode 100644 src/slack/worker.test.ts create mode 100644 src/slack/worker.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0ec1fcd --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.worktrees +node_modules +dist +.venv +.env +coverage +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..ca31996 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +DATABASE_URL=postgres://pigeon:replace-me@127.0.0.1:5432/pigeon +PORT=8787 +# Comma-separated base64url JSON DeviceIdentity records. Use your secret manager in production. +PIGEON_DEVICES= +PIGEON_SLACK_SIGNING_SECRET= +PIGEON_SLACK_BOT_TOKEN= +# Gateway-only values: +PIGEON_RELAY_URL=https://pigeon.example.com +PIGEON_DEVICE_ID= +PIGEON_DEVICE_PRIVATE_KEY= +PIGEON_USER= +PIGEON_TEAMMATES= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7e9bfb..d8b2214 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -5,6 +5,15 @@ permissions: jobs: test: runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: { POSTGRES_USER: pigeon, POSTGRES_PASSWORD: pigeon, POSTGRES_DB: pigeon } + ports: ["5432:5432"] + options: >- + --health-cmd "pg_isready -U pigeon" --health-interval 2s --health-timeout 2s --health-retries 20 + env: + PIGEON_TEST_DATABASE_URL: postgres://pigeon:pigeon@127.0.0.1:5432/pigeon steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 @@ -15,3 +24,4 @@ jobs: - run: pnpm test - run: pnpm typecheck - run: pnpm build + - run: PIGEON_SMOKE_DATABASE_URL=$PIGEON_TEST_DATABASE_URL pnpm smoke:relay diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..433cdb7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +FROM node:22-alpine AS build +RUN corepack enable +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --frozen-lockfile +COPY . . +RUN pnpm build + +FROM node:22-alpine +RUN corepack enable +WORKDIR /app +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +RUN pnpm install --prod --frozen-lockfile +COPY --from=build /app/dist ./dist +ENV NODE_ENV=production PORT=8787 +EXPOSE 8787 +CMD ["node", "dist/relay/main.js"] diff --git a/README.md b/README.md index 0b46026..e9d3fcc 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ Pigeon packages an MCP server, an embedded delegation inbox, signed authority en - Confirmation capabilities are short-lived and single-use. - Delegation state transitions and Ed25519 envelopes are tested. -The included transport is intentionally an in-process evaluation relay. It proves the full approval and execution contract without exposing a machine on the network. A production deployment must replace `InMemoryRelay` with an authenticated durable transport and connect `CodexAdapter` to the local Codex app-server; those boundaries are explicit in `src/gateway.ts`. +The default transport remains an in-process evaluation relay. A Postgres-backed, signed HTTP relay is also included for durable cross-window and cross-machine delivery. See [Company relay operations](docs/company-relay.md). ## Run @@ -27,6 +27,8 @@ node dist/server.js The MCP process uses stdio. Install the repo-local marketplace, then install `pigeon` and start a new Codex task so tools and skills reload. +For company-relay development, run `docker compose up postgres`, then use `PIGEON_TEST_DATABASE_URL=postgres://pigeon:pigeon-local@127.0.0.1:55432/pigeon pnpm test`. + ## Approval contract 1. Sender requests the smallest useful scope. diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b4350a7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,31 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: pigeon + POSTGRES_PASSWORD: pigeon-local + POSTGRES_DB: pigeon + ports: ["55432:5432"] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U pigeon"] + interval: 2s + timeout: 2s + retries: 20 + volumes: ["pigeon-postgres:/var/lib/postgresql/data"] + relay: + build: . + environment: + DATABASE_URL: postgres://pigeon:pigeon-local@postgres:5432/pigeon + PIGEON_DEVICES: ${PIGEON_DEVICES:-} + PIGEON_SLACK_SIGNING_SECRET: ${PIGEON_SLACK_SIGNING_SECRET:-} + PIGEON_SLACK_BOT_TOKEN: ${PIGEON_SLACK_BOT_TOKEN:-} + ports: ["8787:8787"] + depends_on: + postgres: { condition: service_healthy } + healthcheck: + test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8787/healthz"] + interval: 5s + timeout: 2s + retries: 20 +volumes: + pigeon-postgres: diff --git a/docs/company-relay.md b/docs/company-relay.md new file mode 100644 index 0000000..ffc17fe --- /dev/null +++ b/docs/company-relay.md @@ -0,0 +1,41 @@ +# Company relay operations + +## Security boundary + +The relay stores delegation metadata and an append-only event stream. It never needs OpenAI credentials or local workspace contents. Slack can reject any request and approve only `discuss_only`; `read_only` and `workspace_write` approval must occur through native confirmation in the recipient's Codex. + +## Local deployment + +1. Start Docker Desktop. +2. Copy `.env.example` to `.env` and keep it untracked. +3. Encode each device's public `DeviceIdentity` JSON as base64url and join the values with commas in `PIGEON_DEVICES`. Private keys stay on their corresponding Codex machines. +4. Run `docker compose up --build`. +5. Confirm `curl http://127.0.0.1:8787/healthz` returns `{ "ok": true }`. + +Run the production-component smoke test against the Compose database: + +```bash +pnpm build +PIGEON_SMOKE_DATABASE_URL=postgres://pigeon:pigeon-local@127.0.0.1:55432/pigeon pnpm smoke:relay +``` + +## Slack app + +Create an internal Slack app with bot scope `chat:write`. Enable interactivity and point the request URL to `https://YOUR_RELAY/v1/slack/actions`. Store the bot token and signing secret in the deployment secret manager. Pigeon verifies Slack's timestamped HMAC signature over the raw form body before accepting an interaction. + +Action messages use DMs. Elevated requests show Reject and Open in Codex; discussion-only requests also show Approve. Channel posting and parsing thread replies as commands are intentionally unsupported. + +## Gateway configuration + +Configure the plugin MCP process with `PIGEON_RELAY_URL`, `PIGEON_DEVICE_ID`, `PIGEON_DEVICE_PRIVATE_KEY`, `PIGEON_USER`, and a comma-separated `PIGEON_TEAMMATES`. Restart Codex after changing plugin environment. Without relay variables, Pigeon uses its deterministic in-process evaluation mode. + +## Production prerequisites + +- Put the relay behind managed TLS and company ingress authentication controls. +- Store Postgres credentials, Slack tokens, and device registration data in the company secret manager. +- Replace static `PIGEON_DEVICES` bootstrap with the company's SSO-backed device enrollment process. +- Back up Postgres, monitor `/healthz`, outbox lag, signature failures, state conflicts, and capability replay attempts. +- Export append-only security events to the company logging system and apply the retention policy in the design spec. +- Exercise device revocation and the organization-wide kill switch before enabling `workspace_write`. + +The shipped adapter completes an approved relay lifecycle but does not yet launch a recipient Codex app-server task. Keep `workspace_write` disabled until that adapter and company policy enforcement are integrated and security-reviewed. diff --git a/package.json b/package.json index 959bd30..55f0b1e 100644 --- a/package.json +++ b/package.json @@ -7,8 +7,10 @@ "node": ">=20" }, "scripts": { - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && mkdir -p dist/relay/migrations && cp src/relay/migrations/*.sql dist/relay/migrations/", "dev": "tsx src/server.ts", + "relay": "node dist/relay/main.js", + "smoke:relay": "node scripts/smoke-relay.mjs", "test": "vitest run src", "typecheck": "tsc -p tsconfig.json --noEmit", "lint": "tsc -p tsconfig.json --noEmit", diff --git a/scripts/smoke-relay.mjs b/scripts/smoke-relay.mjs new file mode 100644 index 0000000..54b22ae --- /dev/null +++ b/scripts/smoke-relay.mjs @@ -0,0 +1,16 @@ +import { generateKeyPairSync } from "node:crypto"; +import { Pool } from "pg"; +import { createRelayApp } from "../dist/relay/app.js"; +import { RelayClient } from "../dist/relay/client.js"; +import { migrate, PostgresRelayStore } from "../dist/relay/postgres.js"; + +const databaseUrl = process.env.PIGEON_SMOKE_DATABASE_URL; +if (!databaseUrl) throw new Error("PIGEON_SMOKE_DATABASE_URL is required"); +const pool = new Pool({ connectionString: databaseUrl }); await migrate(pool); await pool.query("TRUNCATE relay_outbox, relay_events, delegations RESTART IDENTITY CASCADE"); +const identity = name => { const pair = generateKeyPairSync("ed25519"); return { device: { id: `${name}-device`, organizationId: "smoke-org", userId: name, publicKey: pair.publicKey.export({ type: "spki", format: "pem" }).toString() }, privateKey: pair.privateKey.export({ type: "pkcs8", format: "pem" }).toString() }; }; +const alice = identity("alice"); const bob = identity("bob"); const devices = new Map([[alice.device.id, alice.device], [bob.device.id, bob.device]]); const app = createRelayApp({ store: new PostgresRelayStore(pool), devices }); const server = app.listen(0); await new Promise(resolve => server.once("listening", resolve)); +try { + const address = server.address(); if (!address || typeof address === "string") throw new Error("listen_failed"); const baseUrl = `http://127.0.0.1:${address.port}`; const a = new RelayClient({ baseUrl, deviceId: alice.device.id, privateKey: alice.privateKey }); const b = new RelayClient({ baseUrl, deviceId: bob.device.id, privateKey: bob.privateKey }); + let d = (await a.createDelegation({ recipientId: "bob", objective: "Smoke test the relay", workspaceLabel: "pigeon", requestedScope: "read_only", idempotencyKey: "smoke-request-0001", expiresAt: Date.now() + 60_000 })).delegation; + if (!(await b.events(0)).events.some(event => event.delegationId === d.id)) throw new Error("delivery_failed"); d = (await b.approve(d.id, d.version, "discuss_only")).delegation; d = (await b.start(d.id, d.version)).delegation; d = (await b.complete(d.id, d.version)).delegation; if (d.state !== "completed") throw new Error("completion_failed"); process.stdout.write(`relay smoke passed: ${d.id}\n`); +} finally { await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); await pool.end(); } diff --git a/src/relay/main.ts b/src/relay/main.ts index 2742a03..3e24427 100644 --- a/src/relay/main.ts +++ b/src/relay/main.ts @@ -2,10 +2,13 @@ import { Pool } from "pg"; import { createRelayApp } from "./app.js"; import { migrate, PostgresRelayStore } from "./postgres.js"; import type { DeviceIdentity } from "./types.js"; +import { SlackClient } from "../slack/client.js"; +import { deliverOutboxOnce } from "../slack/worker.js"; const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) throw new Error("DATABASE_URL is required"); const pool = new Pool({ connectionString: databaseUrl }); await migrate(pool); const devices = new Map(); for (const encoded of (process.env.PIGEON_DEVICES ?? "").split(",").filter(Boolean)) { const device = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as DeviceIdentity; devices.set(device.id, device); } -const app = createRelayApp({ store: new PostgresRelayStore(pool), devices, slackInternalSecret: process.env.PIGEON_SLACK_INTERNAL_SECRET }); +const store = new PostgresRelayStore(pool); const app = createRelayApp({ store, devices, slackInternalSecret: process.env.PIGEON_SLACK_INTERNAL_SECRET, slackSigningSecret: process.env.PIGEON_SLACK_SIGNING_SECRET }); +if (process.env.PIGEON_SLACK_BOT_TOKEN) { const slack = new SlackClient(process.env.PIGEON_SLACK_BOT_TOKEN); const timer = setInterval(() => { void deliverOutboxOnce(store, slack); }, 1_000); timer.unref(); } const port = Number(process.env.PORT ?? 8787); app.listen(port, () => process.stderr.write(`Pigeon relay listening on ${port}\n`)); diff --git a/src/slack/client.test.ts b/src/slack/client.test.ts index 109b653..fceac97 100644 --- a/src/slack/client.test.ts +++ b/src/slack/client.test.ts @@ -4,7 +4,7 @@ import { SlackClient } from "./client.js"; describe("Slack client", () => { it("posts a redacted delegation DM with safe actions", async () => { let request: { url: string; init?: RequestInit } | undefined; - const client = new SlackClient("xoxb-test", async (url, init) => { request = { url: String(url), init }; return new Response(JSON.stringify({ ok: true, channel: "D1", ts: "1.2" }), { headers: { "content-type": "application/json" } }); }); + const client = new SlackClient("test-token", async (url, init) => { request = { url: String(url), init }; return new Response(JSON.stringify({ ok: true, channel: "D1", ts: "1.2" }), { headers: { "content-type": "application/json" } }); }); await client.postDelegation({ id: "opaque-id", recipientSlackId: "U2", senderLabel: "Alice", objective: "Review rollout", workspaceLabel: "pigeon", requestedScope: "read_only", expiresAt: 2_000 }); expect(request?.url).toBe("https://slack.com/api/chat.postMessage"); const body = JSON.parse(String(request?.init?.body)); expect(body.channel).toBe("U2"); expect(JSON.stringify(body)).not.toContain("/Users/"); expect(JSON.stringify(body)).toContain("open_codex"); expect(JSON.stringify(body)).not.toContain("approve_discuss"); diff --git a/src/slack/worker.test.ts b/src/slack/worker.test.ts new file mode 100644 index 0000000..3141541 --- /dev/null +++ b/src/slack/worker.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "vitest"; +import { deliverOutboxOnce } from "./worker.js"; + +describe("Slack outbox worker", () => { + it("delivers created delegations and completes the item", async () => { + const completed: number[] = []; const notices: unknown[] = []; const store = { + claimOutbox: async () => [{ id: 7, attempts: 1, payload: { sequence: 1, type: "created" as const, delegationId: "d1", organizationId: "acme", recipientId: "U2", version: 1, actor: { organizationId: "acme", userId: "alice", deviceId: "d1", source: "codex" as const }, createdAt: 1_000 } }], + get: async () => ({ id: "d1", organizationId: "acme", senderId: "alice", recipientId: "U2", objective: "Review", workspaceLabel: "pigeon", requestedScope: "read_only" as const, state: "pending" as const, version: 1, idempotencyKey: "worker-test-0001", expiresAt: 2_000, createdAt: 1_000, updatedAt: 1_000 }), + completeOutbox: async (id: number) => { completed.push(id); }, failOutbox: async () => { throw new Error("unexpected_failure"); } + }; const slack = { postDelegation: async (notice: unknown) => { notices.push(notice); return { ok: true }; } }; + await deliverOutboxOnce(store, slack); expect(notices).toHaveLength(1); expect(completed).toEqual([7]); + }); +}); diff --git a/src/slack/worker.ts b/src/slack/worker.ts new file mode 100644 index 0000000..945bb1d --- /dev/null +++ b/src/slack/worker.ts @@ -0,0 +1,20 @@ +import type { PostgresRelayStore } from "../relay/postgres.js"; +import type { SlackClient } from "./client.js"; + +type Store = Pick; +type Client = Pick; + +export async function deliverOutboxOnce(store: Store, slack: Client) { + const items = await store.claimOutbox(20); + for (const item of items) { + try { + const event = item.payload; + if (event.type === "created") { + const delegation = await store.get(event.delegationId, event.organizationId, event.recipientId); if (!delegation) throw new Error("delegation_missing"); + await slack.postDelegation({ id: delegation.id, recipientSlackId: delegation.recipientId, senderLabel: delegation.senderId, objective: delegation.objective, workspaceLabel: delegation.workspaceLabel, requestedScope: delegation.requestedScope, expiresAt: delegation.expiresAt }); + } + await store.completeOutbox(item.id); + } catch (error) { await store.failOutbox(item.id, error instanceof Error ? error.message : "delivery_failed"); } + } + return items.length; +} From 999949af7dab34f08f1c2d30f7dab91b3596fd96 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 11 Jul 2026 08:17:07 -0700 Subject: [PATCH 10/11] feat: enroll devices with Slack OIDC --- .env.example | 2 + docker-compose.yml | 2 + .../plans/2026-07-11-company-relay.md | 38 +++++++++++++++++++ package.json | 1 + scripts/enroll-device.mjs | 14 +++++++ src/enrollment/app.test.ts | 19 ++++++++++ src/enrollment/local-credentials.test.ts | 12 ++++++ src/enrollment/local-credentials.ts | 11 ++++++ src/enrollment/service.test.ts | 20 ++++++++++ src/enrollment/service.ts | 29 ++++++++++++++ src/enrollment/slack-oidc.test.ts | 11 ++++++ src/enrollment/slack-oidc.ts | 10 +++++ src/relay/app.ts | 12 +++++- src/relay/main.ts | 7 +++- src/relay/migrations/001_relay.sql | 9 +++++ src/server.ts | 3 +- 16 files changed, 195 insertions(+), 5 deletions(-) create mode 100644 scripts/enroll-device.mjs create mode 100644 src/enrollment/app.test.ts create mode 100644 src/enrollment/local-credentials.test.ts create mode 100644 src/enrollment/local-credentials.ts create mode 100644 src/enrollment/service.test.ts create mode 100644 src/enrollment/service.ts create mode 100644 src/enrollment/slack-oidc.test.ts create mode 100644 src/enrollment/slack-oidc.ts diff --git a/.env.example b/.env.example index ca31996..a11af8c 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,8 @@ PORT=8787 PIGEON_DEVICES= PIGEON_SLACK_SIGNING_SECRET= PIGEON_SLACK_BOT_TOKEN= +PIGEON_SLACK_CLIENT_ID= +PIGEON_SLACK_CLIENT_SECRET= # Gateway-only values: PIGEON_RELAY_URL=https://pigeon.example.com PIGEON_DEVICE_ID= diff --git a/docker-compose.yml b/docker-compose.yml index b4350a7..b77fdb7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,8 @@ services: PIGEON_DEVICES: ${PIGEON_DEVICES:-} PIGEON_SLACK_SIGNING_SECRET: ${PIGEON_SLACK_SIGNING_SECRET:-} PIGEON_SLACK_BOT_TOKEN: ${PIGEON_SLACK_BOT_TOKEN:-} + PIGEON_SLACK_CLIENT_ID: ${PIGEON_SLACK_CLIENT_ID:-} + PIGEON_SLACK_CLIENT_SECRET: ${PIGEON_SLACK_CLIENT_SECRET:-} ports: ["8787:8787"] depends_on: postgres: { condition: service_healthy } diff --git a/docs/superpowers/plans/2026-07-11-company-relay.md b/docs/superpowers/plans/2026-07-11-company-relay.md index a9cac67..4bbe4e0 100644 --- a/docs/superpowers/plans/2026-07-11-company-relay.md +++ b/docs/superpowers/plans/2026-07-11-company-relay.md @@ -138,3 +138,41 @@ - [ ] Inspect tracked files and build output for credentials, private keys, absolute personal paths, and raw workspace content. - [ ] Commit with `git commit -m "docs: ship company relay deployment"`. - [ ] Push the branch, open a draft pull request against `evalops/pigeon:main`, and report validation evidence and remaining production prerequisites. + +### Task 7: Slack OIDC device enrollment + +**Files:** +- Create: `src/enrollment/service.ts` +- Create: `src/enrollment/service.test.ts` +- Create: `src/enrollment/slack-oidc.ts` +- Create: `src/enrollment/slack-oidc.test.ts` +- Create: `scripts/enroll-device.mjs` +- Modify: `src/relay/migrations/001_relay.sql` +- Modify: `src/relay/app.ts` +- Modify: `src/relay/main.ts` + +**Interfaces:** +- Produces: short-lived enrollment sessions, Slack OIDC authorization/callback endpoints, durable device registration and revocation, and a local enrollment CLI that keeps the private key on the Codex machine. + +- [ ] Write failing tests for state hashing, expiry, Slack workspace/user binding, duplicate device rejection, durable lookup, and revocation. +- [ ] Implement OIDC authorization, token exchange, user-info verification, and transactional Postgres enrollment. +- [ ] Add a local CLI that generates an Ed25519 key, completes browser enrollment, and writes an owner-only device credential file. +- [ ] Run focused tests, live Postgres tests, and a two-device enrollment smoke test. +- [ ] Commit with `git commit -m "feat: enroll devices with Slack OIDC"`. + +### Task 8: Real Codex app-server execution + +**Files:** +- Create: `src/codex/app-server.ts` +- Create: `src/codex/app-server.test.ts` +- Modify: `src/server.ts` +- Modify: `docs/company-relay.md` + +**Interfaces:** +- Produces: `CodexAppServerAdapter.run`, which starts an ephemeral Codex thread in the approved local workspace, applies the scope sandbox, starts one turn, streams until terminal completion, and returns a bounded final summary and thread ID. + +- [ ] Write failing protocol tests with a deterministic JSON-RPC fixture process. +- [ ] Implement initialize, `thread/start`, `turn/start`, notification handling, timeout, cancellation, and process cleanup. +- [ ] Replace lifecycle-only remote completion with adapter execution before the final relay transition. +- [ ] Run focused tests and a real discuss-only `codex app-server` smoke task. +- [ ] Commit with `git commit -m "feat: execute delegations in codex app-server"`. diff --git a/package.json b/package.json index 55f0b1e..720d94e 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "build": "tsc -p tsconfig.json && mkdir -p dist/relay/migrations && cp src/relay/migrations/*.sql dist/relay/migrations/", "dev": "tsx src/server.ts", "relay": "node dist/relay/main.js", + "enroll": "node scripts/enroll-device.mjs", "smoke:relay": "node scripts/smoke-relay.mjs", "test": "vitest run src", "typecheck": "tsc -p tsconfig.json --noEmit", diff --git a/scripts/enroll-device.mjs b/scripts/enroll-device.mjs new file mode 100644 index 0000000..ac95af1 --- /dev/null +++ b/scripts/enroll-device.mjs @@ -0,0 +1,14 @@ +import { generateKeyPairSync, randomUUID } from "node:crypto"; +import { chmod, mkdir, writeFile } from "node:fs/promises"; +import { homedir, hostname } from "node:os"; +import { dirname, resolve } from "node:path"; +import { spawn } from "node:child_process"; +import { RelayClient } from "../dist/relay/client.js"; + +const relayUrl = process.env.PIGEON_RELAY_URL ?? process.argv[2]; if (!relayUrl) throw new Error("Usage: PIGEON_RELAY_URL=https://relay.example.com pnpm enroll"); +const output = resolve(process.env.PIGEON_DEVICE_FILE ?? `${homedir()}/.pigeon/device.json`); const deviceId = process.env.PIGEON_DEVICE_ID ?? `${hostname()}-${randomUUID().slice(0, 8)}`; const pair = generateKeyPairSync("ed25519"); const privateKey = pair.privateKey.export({ type: "pkcs8", format: "pem" }).toString(); const publicKey = pair.publicKey.export({ type: "spki", format: "pem" }).toString(); const callback = new URL("/v1/enrollment/callback", relayUrl).href; +const started = await fetch(new URL("/v1/enrollment/start", relayUrl), { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ deviceId, publicKey, redirectUri: callback, ...(process.env.PIGEON_SLACK_TEAM_ID ? { teamId: process.env.PIGEON_SLACK_TEAM_ID } : {}) }) }); const result = await started.json(); if (!started.ok) throw new Error(result.error?.code ?? `enrollment_http_${started.status}`); +const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open"; const args = process.platform === "win32" ? ["/c", "start", "", result.authorizeUrl] : [result.authorizeUrl]; spawn(command, args, { detached: true, stdio: "ignore" }).unref(); process.stdout.write("Complete Sign in with Slack in your browser…\n"); +const client = new RelayClient({ baseUrl: relayUrl, deviceId, privateKey }); let enrolled = false; +for (let attempt = 0; attempt < 150; attempt += 1) { await new Promise(resolve => setTimeout(resolve, 2_000)); try { await client.events(0); enrolled = true; break; } catch (error) { if (!(error instanceof Error) || error.message !== "unauthorized") throw error; } } +if (!enrolled) throw new Error("enrollment_timed_out"); await mkdir(dirname(output), { recursive: true, mode: 0o700 }); await writeFile(output, JSON.stringify({ relayUrl, deviceId, privateKey }, null, 2), { mode: 0o600 }); await chmod(output, 0o600); process.stdout.write(`Device enrolled. Credentials saved to ${output}\n`); diff --git a/src/enrollment/app.test.ts b/src/enrollment/app.test.ts new file mode 100644 index 0000000..fa9993d --- /dev/null +++ b/src/enrollment/app.test.ts @@ -0,0 +1,19 @@ +import { generateKeyPairSync } from "node:crypto"; +import { Pool } from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { createRelayApp } from "../relay/app.js"; +import { migrate, PostgresRelayStore } from "../relay/postgres.js"; +import type { DeviceIdentity } from "../relay/types.js"; +import { EnrollmentService } from "./service.js"; + +const url = process.env.PIGEON_TEST_DATABASE_URL; +describe.skipIf(!url)("enrollment HTTP flow", () => { + const pool = new Pool({ connectionString: url }); const devices = new Map(); + beforeAll(async () => { await migrate(pool); await pool.query("TRUNCATE enrollment_sessions, devices, relay_outbox, relay_events, delegations RESTART IDENTITY CASCADE"); }); afterAll(async () => pool.end()); + it("starts Slack OIDC and registers the public device on callback", async () => { + const enrollment = new EnrollmentService(pool, () => 1_000); const oidc = { authorizationUrl: ({ state }: { state: string }) => new URL(`https://slack.example/authorize?state=${state}`), exchange: async () => ({ teamId: "T1", userId: "U1" }) }; + const app = createRelayApp({ store: new PostgresRelayStore(pool), devices, enrollment, oidc }); const server = app.listen(0); await new Promise(resolve => server.once("listening", resolve)); const address = server.address(); if (!address || typeof address === "string") throw new Error("listen_failed"); const base = `http://127.0.0.1:${address.port}`; + const { publicKey } = generateKeyPairSync("ed25519"); const started = await fetch(`${base}/v1/enrollment/start`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ deviceId: "alice-device", publicKey: publicKey.export({ type: "spki", format: "pem" }).toString(), redirectUri: `${base}/v1/enrollment/callback` }) }); expect(started.status).toBe(201); const { authorizeUrl } = await started.json() as { authorizeUrl: string }; const state = new URL(authorizeUrl).searchParams.get("state")!; + const completed = await fetch(`${base}/v1/enrollment/callback?state=${encodeURIComponent(state)}&code=code`, { redirect: "manual" }); expect(completed.status).toBe(200); expect(devices.get("alice-device")).toMatchObject({ organizationId: "T1", userId: "U1" }); await new Promise((resolve, reject) => server.close(error => error ? reject(error) : resolve())); + }); +}); diff --git a/src/enrollment/local-credentials.test.ts b/src/enrollment/local-credentials.test.ts new file mode 100644 index 0000000..a645382 --- /dev/null +++ b/src/enrollment/local-credentials.test.ts @@ -0,0 +1,12 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { loadRelayCredentials } from "./local-credentials.js"; + +describe("local credentials", () => { + it("loads an explicit owner-managed device file", () => { + const path = join(mkdtempSync(join(tmpdir(), "pigeon-")), "device.json"); writeFileSync(path, JSON.stringify({ relayUrl: "https://relay.example", deviceId: "device", privateKey: "key" })); + expect(loadRelayCredentials({ PIGEON_DEVICE_FILE: path })).toEqual({ relayUrl: "https://relay.example", deviceId: "device", privateKey: "key" }); + }); +}); diff --git a/src/enrollment/local-credentials.ts b/src/enrollment/local-credentials.ts new file mode 100644 index 0000000..77260ea --- /dev/null +++ b/src/enrollment/local-credentials.ts @@ -0,0 +1,11 @@ +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { resolve } from "node:path"; +import { z } from "zod"; + +const Credentials = z.object({ relayUrl: z.string().url(), deviceId: z.string().min(1), privateKey: z.string().min(1) }); +export type RelayCredentials = z.infer; +export function loadRelayCredentials(env: NodeJS.ProcessEnv = process.env): RelayCredentials | undefined { + if (env.PIGEON_RELAY_URL && env.PIGEON_DEVICE_ID && env.PIGEON_DEVICE_PRIVATE_KEY) return Credentials.parse({ relayUrl: env.PIGEON_RELAY_URL, deviceId: env.PIGEON_DEVICE_ID, privateKey: env.PIGEON_DEVICE_PRIVATE_KEY.replace(/\\n/g, "\n") }); + const path = resolve(env.PIGEON_DEVICE_FILE ?? `${homedir()}/.pigeon/device.json`); if (!existsSync(path)) return undefined; return Credentials.parse(JSON.parse(readFileSync(path, "utf8"))); +} diff --git a/src/enrollment/service.test.ts b/src/enrollment/service.test.ts new file mode 100644 index 0000000..9cbe920 --- /dev/null +++ b/src/enrollment/service.test.ts @@ -0,0 +1,20 @@ +import { generateKeyPairSync } from "node:crypto"; +import { Pool } from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { migrate } from "../relay/postgres.js"; +import { EnrollmentService } from "./service.js"; + +const url = process.env.PIGEON_TEST_DATABASE_URL; +describe.skipIf(!url)("device enrollment", () => { + const pool = new Pool({ connectionString: url }); let now = 1_000; const service = new EnrollmentService(pool, () => now); + beforeAll(async () => { await migrate(pool); await pool.query("TRUNCATE enrollment_sessions, devices, relay_outbox, relay_events, delegations RESTART IDENTITY CASCADE"); }); afterAll(async () => pool.end()); + it("consumes a short-lived state once and registers a Slack-bound device", async () => { + const { publicKey } = generateKeyPairSync("ed25519"); const key = publicKey.export({ type: "spki", format: "pem" }).toString(); const pending = await service.start({ deviceId: "alice-mac", publicKey: key, redirectUri: "https://relay.example/v1/enrollment/callback", teamId: "T1" }); + const device = await service.complete(pending.state, { teamId: "T1", userId: "U1" }); expect(device).toMatchObject({ id: "alice-mac", organizationId: "T1", userId: "U1" }); expect((await service.loadDevices()).get("alice-mac")?.publicKey).toBe(key); + await expect(service.complete(pending.state, { teamId: "T1", userId: "U1" })).rejects.toThrow("invalid_enrollment_state"); + }); + it("rejects expired state and supports revocation", async () => { + const { publicKey } = generateKeyPairSync("ed25519"); const pending = await service.start({ deviceId: "bob-mac", publicKey: publicKey.export({ type: "spki", format: "pem" }).toString(), redirectUri: "https://relay.example/callback" }); now += 11 * 60_000; + await expect(service.complete(pending.state, { teamId: "T1", userId: "U2" })).rejects.toThrow("expired_enrollment_state"); now = 1_000; const next = await service.start({ deviceId: "bob-mac", publicKey: publicKey.export({ type: "spki", format: "pem" }).toString(), redirectUri: "https://relay.example/callback" }); await service.complete(next.state, { teamId: "T1", userId: "U2" }); await service.revoke("T1", "U2", "bob-mac"); expect((await service.loadDevices()).get("bob-mac")?.revokedAt).toBeTypeOf("number"); + }); +}); diff --git a/src/enrollment/service.ts b/src/enrollment/service.ts new file mode 100644 index 0000000..158d92c --- /dev/null +++ b/src/enrollment/service.ts @@ -0,0 +1,29 @@ +import { createHash, randomBytes } from "node:crypto"; +import type { Pool } from "pg"; +import type { DeviceIdentity } from "../relay/types.js"; + +type StartInput = { deviceId: string; publicKey: string; redirectUri: string; teamId?: string }; +const hash = (value: string) => createHash("sha256").update(value).digest("hex"); + +export class EnrollmentService { + constructor(private readonly pool: Pool, private readonly clock: () => number = Date.now) {} + async start(input: StartInput) { + if (!/^[A-Za-z0-9._-]{3,120}$/.test(input.deviceId)) throw new Error("invalid_device_id"); + if (!input.publicKey.includes("PUBLIC KEY")) throw new Error("invalid_public_key"); + const state = randomBytes(32).toString("base64url"); const nonce = randomBytes(24).toString("base64url"); const now = this.clock(); + await this.pool.query("INSERT INTO enrollment_sessions(state_hash,device_id,public_key,redirect_uri,team_id,nonce,expires_at,created_at) VALUES($1,$2,$3,$4,$5,$6,$7,$8)", [hash(state), input.deviceId, input.publicKey, input.redirectUri, input.teamId ?? null, nonce, now + 10 * 60_000, now]); + return { state, nonce, expiresAt: now + 10 * 60_000 }; + } + async inspect(state: string) { const result = await this.pool.query("SELECT device_id,redirect_uri,team_id,nonce,expires_at FROM enrollment_sessions WHERE state_hash=$1", [hash(state)]); if (!result.rowCount) throw new Error("invalid_enrollment_state"); const row = result.rows[0]; if (Number(row.expires_at) < this.clock()) throw new Error("expired_enrollment_state"); return { deviceId: String(row.device_id), redirectUri: String(row.redirect_uri), teamId: row.team_id ? String(row.team_id) : undefined, nonce: String(row.nonce) }; } + async complete(state: string, identity: { teamId: string; userId: string }) { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); const selected = await client.query("DELETE FROM enrollment_sessions WHERE state_hash=$1 RETURNING *", [hash(state)]); if (!selected.rowCount) throw new Error("invalid_enrollment_state"); const row = selected.rows[0]; + if (Number(row.expires_at) < this.clock()) throw new Error("expired_enrollment_state"); if (row.team_id && row.team_id !== identity.teamId) throw new Error("wrong_slack_workspace"); + const inserted = await client.query("INSERT INTO devices(id,organization_id,user_id,public_key,created_at) VALUES($1,$2,$3,$4,$5) ON CONFLICT(id) DO NOTHING RETURNING *", [row.device_id, identity.teamId, identity.userId, row.public_key, this.clock()]); if (!inserted.rowCount) throw new Error("device_exists"); await client.query("COMMIT"); return this.fromRow(inserted.rows[0]); + } catch (error) { await client.query("ROLLBACK"); throw error; } finally { client.release(); } + } + async loadDevices() { const result = await this.pool.query("SELECT * FROM devices"); return new Map(result.rows.map(row => { const device = this.fromRow(row); return [device.id, device]; })); } + async revoke(organizationId: string, userId: string, deviceId: string) { const result = await this.pool.query("UPDATE devices SET revoked_at=$4 WHERE id=$1 AND organization_id=$2 AND user_id=$3 RETURNING id", [deviceId, organizationId, userId, this.clock()]); if (!result.rowCount) throw new Error("not_found"); } + private fromRow(row: Record): DeviceIdentity { return { id: String(row.id), organizationId: String(row.organization_id), userId: String(row.user_id), publicKey: String(row.public_key), ...(row.revoked_at ? { revokedAt: Number(row.revoked_at) } : {}) }; } +} diff --git a/src/enrollment/slack-oidc.test.ts b/src/enrollment/slack-oidc.test.ts new file mode 100644 index 0000000..24e21d1 --- /dev/null +++ b/src/enrollment/slack-oidc.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "vitest"; +import { SlackOidcClient } from "./slack-oidc.js"; + +describe("Slack OIDC client", () => { + it("builds a workspace-bound authorization URL and resolves verified user info", async () => { + const calls: string[] = []; const client = new SlackOidcClient({ clientId: "client", clientSecret: "secret", request: async (url, init) => { calls.push(String(url)); if (String(url).endsWith("openid.connect.token")) return new Response(JSON.stringify({ ok: true, access_token: "access" }), { headers: { "content-type": "application/json" } }); return new Response(JSON.stringify({ ok: true, sub: "U1", "https://slack.com/user_id": "U1", "https://slack.com/team_id": "T1" }), { headers: { "content-type": "application/json" } }); } }); + const url = client.authorizationUrl({ state: "state", nonce: "nonce", redirectUri: "https://relay.example/callback", teamId: "T1" }); + expect(url.searchParams.get("scope")).toBe("openid profile email"); expect(url.searchParams.get("team")).toBe("T1"); + const identity = await client.exchange({ code: "code", redirectUri: "https://relay.example/callback" }); expect(identity).toEqual({ teamId: "T1", userId: "U1" }); expect(calls).toHaveLength(2); + }); +}); diff --git a/src/enrollment/slack-oidc.ts b/src/enrollment/slack-oidc.ts new file mode 100644 index 0000000..98974c3 --- /dev/null +++ b/src/enrollment/slack-oidc.ts @@ -0,0 +1,10 @@ +type Options = { clientId: string; clientSecret: string; request?: typeof fetch }; +export class SlackOidcClient { + private readonly request: typeof fetch; + constructor(private readonly options: Options) { this.request = options.request ?? fetch; } + authorizationUrl(input: { state: string; nonce: string; redirectUri: string; teamId?: string }) { const url = new URL("https://slack.com/openid/connect/authorize"); url.search = new URLSearchParams({ response_type: "code", scope: "openid profile email", client_id: this.options.clientId, state: input.state, nonce: input.nonce, redirect_uri: input.redirectUri, ...(input.teamId ? { team: input.teamId } : {}) }).toString(); return url; } + async exchange(input: { code: string; redirectUri: string }) { + const tokenResponse = await this.request("https://slack.com/api/openid.connect.token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ client_id: this.options.clientId, client_secret: this.options.clientSecret, code: input.code, redirect_uri: input.redirectUri }) }); const token = await tokenResponse.json() as { ok: boolean; access_token?: string; error?: string }; if (!tokenResponse.ok || !token.ok || !token.access_token) throw new Error(`slack_oidc:${token.error ?? tokenResponse.status}`); + const infoResponse = await this.request("https://slack.com/api/openid.connect.userInfo", { method: "POST", headers: { authorization: `Bearer ${token.access_token}`, "content-type": "application/json" } }); const info = await infoResponse.json() as Record; if (!infoResponse.ok || info.ok !== true) throw new Error(`slack_oidc:${String(info.error ?? infoResponse.status)}`); const teamId = String(info["https://slack.com/team_id"] ?? ""); const userId = String(info["https://slack.com/user_id"] ?? info.sub ?? ""); if (!teamId || !userId) throw new Error("invalid_slack_identity"); return { teamId, userId }; + } +} diff --git a/src/relay/app.ts b/src/relay/app.ts index feb4728..6914224 100644 --- a/src/relay/app.ts +++ b/src/relay/app.ts @@ -4,12 +4,14 @@ import type { RelayStore } from "./store.js"; import { NonceStore, verifyDeviceRequest } from "./auth.js"; import { CreateDelegationInputSchema, type Actor, type DeviceIdentity } from "./types.js"; import { parseSlackAction, verifySlackRequest } from "../slack/actions.js"; +import type { EnrollmentService } from "../enrollment/service.js"; +import type { SlackOidcClient } from "../enrollment/slack-oidc.js"; -type Options = { store: RelayStore; devices: Map; clock?: () => number; slackInternalSecret?: string; slackSigningSecret?: string }; +type Options = { store: RelayStore; devices: Map; clock?: () => number; slackInternalSecret?: string; slackSigningSecret?: string; enrollment?: EnrollmentService; oidc?: Pick }; const TransitionBody = z.object({ expectedVersion: z.number().int().positive(), effectiveScope: z.enum(["discuss_only", "read_only", "workspace_write"]).optional() }); const errorStatus: Record = { not_found: 404, expired: 410, version_conflict: 409, scope_widening: 403, invalid_transition: 409, replay: 401, stale_request: 401, invalid_signature: 401, unauthorized: 401, codex_confirmation_required: 403 }; -export function createRelayApp({ store, devices, clock = Date.now, slackInternalSecret, slackSigningSecret }: Options) { +export function createRelayApp({ store, devices, clock = Date.now, slackInternalSecret, slackSigningSecret, enrollment, oidc }: Options) { const app = express(); const nonces = new NonceStore(); app.post("/v1/slack/actions", express.text({ type: "application/x-www-form-urlencoded", limit: "32kb" }), async (req, res, next) => { try { @@ -20,6 +22,12 @@ export function createRelayApp({ store, devices, clock = Date.now, slackInternal } catch (error) { next(error); } }); app.use(express.json({ limit: "32kb" })); + app.post("/v1/enrollment/start", async (req, res, next) => { + try { if (!enrollment || !oidc) throw new Error("enrollment_unavailable"); const input = z.object({ deviceId: z.string(), publicKey: z.string(), redirectUri: z.string().url(), teamId: z.string().optional() }).parse(req.body); const pending = await enrollment.start(input); const authorizeUrl = oidc.authorizationUrl({ state: pending.state, nonce: pending.nonce, redirectUri: input.redirectUri, teamId: input.teamId }); res.status(201).json({ authorizeUrl: authorizeUrl.href, expiresAt: pending.expiresAt }); } catch (error) { next(error); } + }); + app.get("/v1/enrollment/callback", async (req, res, next) => { + try { if (!enrollment || !oidc) throw new Error("enrollment_unavailable"); const state = String(req.query.state ?? ""); const code = String(req.query.code ?? ""); const pending = await enrollment.inspect(state); const identity = await oidc.exchange({ code, redirectUri: pending.redirectUri }); const device = await enrollment.complete(state, identity); devices.set(device.id, device); res.type("html").send("Pigeon enrolled

Pigeon device enrolled. You can close this window.

"); } catch (error) { next(error); } + }); app.get("/healthz", (_req, res) => { res.json({ ok: true }); }); const deviceActor = (req: Request): Actor => { diff --git a/src/relay/main.ts b/src/relay/main.ts index 3e24427..aa3f38c 100644 --- a/src/relay/main.ts +++ b/src/relay/main.ts @@ -4,11 +4,14 @@ import { migrate, PostgresRelayStore } from "./postgres.js"; import type { DeviceIdentity } from "./types.js"; import { SlackClient } from "../slack/client.js"; import { deliverOutboxOnce } from "../slack/worker.js"; +import { EnrollmentService } from "../enrollment/service.js"; +import { SlackOidcClient } from "../enrollment/slack-oidc.js"; const databaseUrl = process.env.DATABASE_URL; if (!databaseUrl) throw new Error("DATABASE_URL is required"); const pool = new Pool({ connectionString: databaseUrl }); await migrate(pool); -const devices = new Map(); +const enrollment = new EnrollmentService(pool); const devices = await enrollment.loadDevices(); for (const encoded of (process.env.PIGEON_DEVICES ?? "").split(",").filter(Boolean)) { const device = JSON.parse(Buffer.from(encoded, "base64url").toString("utf8")) as DeviceIdentity; devices.set(device.id, device); } -const store = new PostgresRelayStore(pool); const app = createRelayApp({ store, devices, slackInternalSecret: process.env.PIGEON_SLACK_INTERNAL_SECRET, slackSigningSecret: process.env.PIGEON_SLACK_SIGNING_SECRET }); +const oidc = process.env.PIGEON_SLACK_CLIENT_ID && process.env.PIGEON_SLACK_CLIENT_SECRET ? new SlackOidcClient({ clientId: process.env.PIGEON_SLACK_CLIENT_ID, clientSecret: process.env.PIGEON_SLACK_CLIENT_SECRET }) : undefined; +const store = new PostgresRelayStore(pool); const app = createRelayApp({ store, devices, enrollment, oidc, slackInternalSecret: process.env.PIGEON_SLACK_INTERNAL_SECRET, slackSigningSecret: process.env.PIGEON_SLACK_SIGNING_SECRET }); if (process.env.PIGEON_SLACK_BOT_TOKEN) { const slack = new SlackClient(process.env.PIGEON_SLACK_BOT_TOKEN); const timer = setInterval(() => { void deliverOutboxOnce(store, slack); }, 1_000); timer.unref(); } const port = Number(process.env.PORT ?? 8787); app.listen(port, () => process.stderr.write(`Pigeon relay listening on ${port}\n`)); diff --git a/src/relay/migrations/001_relay.sql b/src/relay/migrations/001_relay.sql index 7be1bf8..12379f6 100644 --- a/src/relay/migrations/001_relay.sql +++ b/src/relay/migrations/001_relay.sql @@ -14,3 +14,12 @@ CREATE TABLE IF NOT EXISTS relay_outbox ( id bigserial PRIMARY KEY, event_sequence bigint NOT NULL UNIQUE REFERENCES relay_events(sequence), payload jsonb NOT NULL, attempts integer NOT NULL DEFAULT 0, available_at bigint NOT NULL, claimed_at bigint, completed_at bigint, last_error text ); +CREATE TABLE IF NOT EXISTS devices ( + id text PRIMARY KEY, organization_id text NOT NULL, user_id text NOT NULL, public_key text NOT NULL, + created_at bigint NOT NULL, revoked_at bigint +); +CREATE INDEX IF NOT EXISTS devices_identity ON devices (organization_id, user_id); +CREATE TABLE IF NOT EXISTS enrollment_sessions ( + state_hash text PRIMARY KEY, device_id text NOT NULL, public_key text NOT NULL, redirect_uri text NOT NULL, + team_id text, nonce text NOT NULL, expires_at bigint NOT NULL, created_at bigint NOT NULL +); diff --git a/src/server.ts b/src/server.ts index e8a4f4f..3fb5c24 100644 --- a/src/server.ts +++ b/src/server.ts @@ -7,11 +7,12 @@ import { Gateway, InMemoryRelay } from "./gateway.js"; import { canNarrowScope, ScopeSchema } from "./protocol.js"; import { widgetHtml } from "./widget.js"; import { RelayClient } from "./relay/client.js"; +import { loadRelayCredentials } from "./enrollment/local-credentials.js"; const relay = new InMemoryRelay(); const teammate = process.env.PIGEON_USER ?? "local"; const gateway = new Gateway(teammate, relay, { run: async d => ({ summary: `Delegation ${d.id} accepted; configure PIGEON_CODEX_COMMAND for execution.` }) }); -const remote = process.env.PIGEON_RELAY_URL && process.env.PIGEON_DEVICE_ID && process.env.PIGEON_DEVICE_PRIVATE_KEY ? new RelayClient({ baseUrl: process.env.PIGEON_RELAY_URL, deviceId: process.env.PIGEON_DEVICE_ID, privateKey: process.env.PIGEON_DEVICE_PRIVATE_KEY.replace(/\\n/g, "\n") }) : undefined; +const credentials = loadRelayCredentials(); const remote = credentials ? new RelayClient({ baseUrl: credentials.relayUrl, deviceId: credentials.deviceId, privateKey: credentials.privateKey }) : undefined; const teammates = (process.env.PIGEON_TEAMMATES ?? teammate).split(",").map(value => value.trim()).filter(Boolean); const server = new McpServer({ name: "pigeon", version: "0.1.0" }, { instructions: "Use Pigeon only for explicit teammate delegation. Open the inbox to review requests. Approval always requires native confirmation." }); const UI = "ui://pigeon/inbox-v1.html"; From bd994c355b65bcf569e39112b4a9ab4dd2d304d1 Mon Sep 17 00:00:00 2001 From: Jonathan Haas Date: Sat, 11 Jul 2026 08:25:44 -0700 Subject: [PATCH 11/11] feat: execute delegations in codex app-server --- .env.example | 2 ++ README.md | 2 ++ docs/company-relay.md | 17 ++++++++++----- src/codex/app-server.test.ts | 14 ++++++++++++ src/codex/app-server.ts | 30 ++++++++++++++++++++++++++ src/codex/execution-worker.test.ts | 9 ++++++++ src/codex/execution-worker.ts | 15 +++++++++++++ src/codex/fixtures/fake-app-server.mjs | 4 ++++ src/codex/workspaces.test.ts | 9 ++++++++ src/codex/workspaces.ts | 2 ++ src/relay/app.ts | 4 ++-- src/relay/client.ts | 3 ++- src/relay/memory-store.ts | 1 + src/relay/migrations/001_relay.sql | 4 +++- src/relay/postgres.ts | 4 ++-- src/relay/store.test.ts | 4 ++++ src/relay/types.ts | 4 ++-- src/server.ts | 8 ++++++- vitest.config.ts | 2 ++ 19 files changed, 124 insertions(+), 14 deletions(-) create mode 100644 src/codex/app-server.test.ts create mode 100644 src/codex/app-server.ts create mode 100644 src/codex/execution-worker.test.ts create mode 100644 src/codex/execution-worker.ts create mode 100644 src/codex/fixtures/fake-app-server.mjs create mode 100644 src/codex/workspaces.test.ts create mode 100644 src/codex/workspaces.ts create mode 100644 vitest.config.ts diff --git a/.env.example b/.env.example index a11af8c..02aeaa5 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,5 @@ PIGEON_DEVICE_ID= PIGEON_DEVICE_PRIVATE_KEY= PIGEON_USER= PIGEON_TEAMMATES= +PIGEON_CODEX_MODEL=gpt-5.4 +PIGEON_WORKSPACES={"pigeon":"/absolute/path/to/pigeon"} diff --git a/README.md b/README.md index e9d3fcc..2d8d9e0 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,8 @@ Pigeon packages an MCP server, an embedded delegation inbox, signed authority en - Approve triggers MCP `elicitation/create`; cancelling it starts nothing. - Confirmation capabilities are short-lived and single-use. - Delegation state transitions and Ed25519 envelopes are tested. +- Slack OIDC enrollment registers device public keys while private keys remain local. +- Approved remote requests launch a sandboxed local Codex app-server task and return its summary. The default transport remains an in-process evaluation relay. A Postgres-backed, signed HTTP relay is also included for durable cross-window and cross-machine delivery. See [Company relay operations](docs/company-relay.md). diff --git a/docs/company-relay.md b/docs/company-relay.md index ffc17fe..d20997c 100644 --- a/docs/company-relay.md +++ b/docs/company-relay.md @@ -8,7 +8,7 @@ The relay stores delegation metadata and an append-only event stream. It never n 1. Start Docker Desktop. 2. Copy `.env.example` to `.env` and keep it untracked. -3. Encode each device's public `DeviceIdentity` JSON as base64url and join the values with commas in `PIGEON_DEVICES`. Private keys stay on their corresponding Codex machines. +3. Configure `PIGEON_SLACK_CLIENT_ID` and `PIGEON_SLACK_CLIENT_SECRET` for Sign in with Slack. Static `PIGEON_DEVICES` remains available only for recovery and local fixtures. 4. Run `docker compose up --build`. 5. Confirm `curl http://127.0.0.1:8787/healthz` returns `{ "ok": true }`. @@ -21,21 +21,28 @@ PIGEON_SMOKE_DATABASE_URL=postgres://pigeon:pigeon-local@127.0.0.1:55432/pigeon ## Slack app -Create an internal Slack app with bot scope `chat:write`. Enable interactivity and point the request URL to `https://YOUR_RELAY/v1/slack/actions`. Store the bot token and signing secret in the deployment secret manager. Pigeon verifies Slack's timestamped HMAC signature over the raw form body before accepting an interaction. +Create an internal Slack app with bot scope `chat:write` and Sign in with Slack scopes `openid profile email`. Enable interactivity and point the request URL to `https://YOUR_RELAY/v1/slack/actions`. Add `https://YOUR_RELAY/v1/enrollment/callback` as an OAuth redirect URL. Store the bot token, signing secret, client ID, and client secret in the deployment secret manager. Pigeon verifies Slack's timestamped HMAC signature over the raw form body before accepting an interaction. Action messages use DMs. Elevated requests show Reject and Open in Codex; discussion-only requests also show Approve. Channel posting and parsing thread replies as commands are intentionally unsupported. ## Gateway configuration -Configure the plugin MCP process with `PIGEON_RELAY_URL`, `PIGEON_DEVICE_ID`, `PIGEON_DEVICE_PRIVATE_KEY`, `PIGEON_USER`, and a comma-separated `PIGEON_TEAMMATES`. Restart Codex after changing plugin environment. Without relay variables, Pigeon uses its deterministic in-process evaluation mode. +After building Pigeon, enroll the machine: + +```bash +PIGEON_RELAY_URL=https://YOUR_RELAY pnpm enroll +``` + +The enrollment command generates an Ed25519 key locally, opens Sign in with Slack, waits for the relay to recognize the device, and writes an owner-only credential file to `~/.pigeon/device.json`. The private key never leaves the machine. The plugin reads this file automatically; restart Codex after enrollment. + +Set `PIGEON_WORKSPACES` to a JSON object mapping safe relay labels to absolute local roots, for example `{"pigeon":"/Users/me/src/pigeon"}`. `read_only` and `workspace_write` requests fail closed when their label is not mapped. `discuss_only` always runs in a new empty temporary directory. Set `PIGEON_CODEX_MODEL` to override the compatibility default `gpt-5.4`, and use a comma-separated `PIGEON_TEAMMATES` list for recipient discovery. Without relay credentials, Pigeon uses deterministic in-process evaluation mode. ## Production prerequisites - Put the relay behind managed TLS and company ingress authentication controls. - Store Postgres credentials, Slack tokens, and device registration data in the company secret manager. -- Replace static `PIGEON_DEVICES` bootstrap with the company's SSO-backed device enrollment process. - Back up Postgres, monitor `/healthz`, outbox lag, signature failures, state conflicts, and capability replay attempts. - Export append-only security events to the company logging system and apply the retention policy in the design spec. - Exercise device revocation and the organization-wide kill switch before enabling `workspace_write`. -The shipped adapter completes an approved relay lifecycle but does not yet launch a recipient Codex app-server task. Keep `workspace_write` disabled until that adapter and company policy enforcement are integrated and security-reviewed. +Approved work launches an ephemeral local Codex app-server thread. Pigeon binds it to the approved workspace root, maps `read_only` to the read-only sandbox and `workspace_write` to the workspace-write sandbox, disables further sandbox escalation, and returns the final summary and Codex thread ID through the relay. Keep `workspace_write` limited to allowlisted teams and repositories until company policy enforcement is security-reviewed. diff --git a/src/codex/app-server.test.ts b/src/codex/app-server.test.ts new file mode 100644 index 0000000..47dc21b --- /dev/null +++ b/src/codex/app-server.test.ts @@ -0,0 +1,14 @@ +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { CodexAppServerAdapter } from "./app-server.js"; + +const fixture = resolve("src/codex/fixtures/fake-app-server.mjs"); +describe("Codex app-server adapter", () => { + it("starts an ephemeral read-only task in the approved workspace", async () => { + const adapter = new CodexAppServerAdapter({ command: process.execPath, args: [fixture], timeoutMs: 5_000 }); const result = await adapter.run({ id: "d1", objective: "Review the repository", workspace: process.cwd(), scope: "read_only" }); + expect(result.threadId).toBe("thread-1"); expect(result.summary).toContain(`completed:read-only:${process.cwd()}:gpt-5.4`); + }); + it("maps write scope to workspace-write and rejects relative workspaces", async () => { + const adapter = new CodexAppServerAdapter({ command: process.execPath, args: [fixture], timeoutMs: 5_000 }); const result = await adapter.run({ id: "d2", objective: "Update docs", workspace: process.cwd(), scope: "workspace_write" }); expect(result.summary).toContain("completed:workspace-write"); await expect(adapter.run({ id: "d3", objective: "No", workspace: "relative", scope: "read_only" })).rejects.toThrow("invalid_workspace"); + }); +}); diff --git a/src/codex/app-server.ts b/src/codex/app-server.ts new file mode 100644 index 0000000..d05d190 --- /dev/null +++ b/src/codex/app-server.ts @@ -0,0 +1,30 @@ +import { spawn } from "node:child_process"; +import { mkdtemp, rm, realpath } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; +import { tmpdir } from "node:os"; +import readline from "node:readline"; + +type Scope = "discuss_only" | "read_only" | "workspace_write"; +type RunInput = { id: string; objective: string; workspace: string; scope: Scope }; +type Options = { command?: string; args?: string[]; timeoutMs?: number; model?: string }; + +export class CodexAppServerAdapter { + constructor(private readonly options: Options = {}) {} + async run(input: RunInput, signal?: AbortSignal) { + if (!isAbsolute(input.workspace)) throw new Error("invalid_workspace"); + let temporary: string | undefined; const cwd = input.scope === "discuss_only" ? (temporary = await mkdtemp(join(tmpdir(), "pigeon-discuss-"))) : await realpath(input.workspace); + const child = spawn(this.options.command ?? "codex", this.options.args ?? ["app-server", "--stdio"], { stdio: ["pipe", "pipe", "pipe"], env: process.env }); const pending = new Map void; reject: (error: Error) => void }>(); let nextId = 1; let summary = ""; let terminalResolve: ((value: void) => void) | undefined; let terminalReject: ((error: Error) => void) | undefined; + const terminal = new Promise((resolve, reject) => { terminalResolve = resolve; terminalReject = reject; }); const lines = readline.createInterface({ input: child.stdout }); + lines.on("line", line => { + try { const message = JSON.parse(line) as { id?: number; result?: unknown; error?: { message?: string }; method?: string; params?: Record }; if (message.id !== undefined) { const request = pending.get(message.id); if (!request) return; pending.delete(message.id); if (message.error) request.reject(new Error(message.error.message ?? "app_server_error")); else request.resolve(message.result); return; } if (message.method === "item/agentMessage/delta") summary += String(message.params?.delta ?? ""); if (message.method === "turn/completed") { const turn = message.params?.turn as { status?: string } | undefined; if (turn?.status === "completed") terminalResolve?.(); else terminalReject?.(new Error(`codex_turn_${turn?.status ?? "failed"}`)); } } catch (error) { terminalReject?.(error instanceof Error ? error : new Error("invalid_app_server_message")); } + }); + let stderr = ""; child.stderr.on("data", chunk => { stderr = `${stderr}${String(chunk)}`.slice(-4_000); }); child.once("exit", code => { if (code && pending.size) { const error = new Error(`app_server_exit_${code}:${stderr}`); for (const request of pending.values()) request.reject(error); pending.clear(); terminalReject?.(error); } }); + const request = (method: string, params: unknown) => new Promise((resolve, reject) => { const id = nextId++; pending.set(id, { resolve: value => resolve(value as T), reject }); child.stdin.write(`${JSON.stringify({ id, method, params })}\n`); }); + const abort = () => { child.kill("SIGTERM"); terminalReject?.(new Error("codex_cancelled")); }; signal?.addEventListener("abort", abort, { once: true }); const timer = setTimeout(() => { child.kill("SIGTERM"); terminalReject?.(new Error("codex_timeout")); }, this.options.timeoutMs ?? 30 * 60_000); + try { + await request("initialize", { clientInfo: { name: "pigeon", version: "0.1.0" }, capabilities: { experimentalApi: true } }); child.stdin.write(`${JSON.stringify({ method: "initialized", params: {} })}\n`); + const sandbox = input.scope === "workspace_write" ? "workspace-write" : "read-only"; const started = await request<{ thread: { id: string } }>("thread/start", { cwd, runtimeWorkspaceRoots: [cwd], sandbox, approvalPolicy: "never", ephemeral: true, serviceName: "pigeon", model: this.options.model ?? process.env.PIGEON_CODEX_MODEL ?? "gpt-5.4" }); + await request("turn/start", { threadId: started.thread.id, cwd, input: [{ type: "text", text: `Pigeon delegation ${input.id}. Complete this bounded objective and report a concise result:\n\n${input.objective}` }], approvalPolicy: "never" }); await terminal; return { threadId: started.thread.id, summary: summary.trim() || "Codex completed the delegation." }; + } finally { clearTimeout(timer); signal?.removeEventListener("abort", abort); lines.close(); child.kill("SIGTERM"); if (temporary) await rm(temporary, { recursive: true, force: true }); } + } +} diff --git a/src/codex/execution-worker.test.ts b/src/codex/execution-worker.test.ts new file mode 100644 index 0000000..ac00647 --- /dev/null +++ b/src/codex/execution-worker.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { RemoteExecutionWorker } from "./execution-worker.js"; + +describe("remote execution worker", () => { + it("executes an approved delegation once and reports its result", async () => { + const completed: unknown[] = []; let state = "approved"; const delegation = () => ({ id: "d1", state, version: state === "approved" ? 2 : state === "running" ? 3 : 4, objective: "Discuss", workspaceLabel: "pigeon", effectiveScope: "discuss_only" as const }); const relay = { events: async () => ({ events: [{ delegationId: "d1" }] }), get: async () => ({ delegation: delegation() }), start: async () => { state = "running"; return { delegation: delegation() }; }, complete: async (_id: string, _version: number, result: unknown) => { completed.push(result); state = "completed"; return { delegation: delegation() }; }, fail: async () => { throw new Error("unexpected_failure"); } }; const adapter = { run: async () => ({ summary: "Finished", threadId: "thread-1" }) }; + const worker = new RemoteExecutionWorker(relay, adapter, () => process.cwd()); await worker.runApproved(); await worker.runApproved(); expect(completed).toEqual([{ summary: "Finished", threadId: "thread-1" }]); + }); +}); diff --git a/src/codex/execution-worker.ts b/src/codex/execution-worker.ts new file mode 100644 index 0000000..1b5525b --- /dev/null +++ b/src/codex/execution-worker.ts @@ -0,0 +1,15 @@ +import type { Scope } from "../protocol.js"; + +type Delegation = { id: string; state: string; version: number; objective: string; workspaceLabel: string; effectiveScope?: Scope; resultSummary?: string; codexThreadId?: string }; +type Relay = { events(after?: number): Promise<{ events: Array<{ delegationId: string }> }>; get(id: string): Promise<{ delegation: Delegation }>; start(id: string, version: number): Promise<{ delegation: Delegation }>; complete(id: string, version: number, result: { summary: string; threadId: string }): Promise<{ delegation: Delegation }>; fail(id: string, version: number): Promise }; +type Adapter = { run(input: { id: string; objective: string; workspace: string; scope: Scope }): Promise<{ summary: string; threadId: string }> }; + +export class RemoteExecutionWorker { + #active = new Set(); + constructor(private readonly relay: Relay, private readonly adapter: Adapter, private readonly resolveWorkspace: (label: string, scope: Scope) => string) {} + async runApproved() { const events = await this.relay.events(0); const ids = [...new Set(events.events.map(event => event.delegationId))]; for (const id of ids) { const delegation = (await this.relay.get(id)).delegation; if (delegation.state === "approved") await this.execute(delegation); } } + async execute(delegation: Delegation) { + if (this.#active.has(delegation.id) || delegation.state !== "approved") return delegation; this.#active.add(delegation.id); + try { const scope = delegation.effectiveScope; if (!scope) throw new Error("missing_effective_scope"); let running = (await this.relay.start(delegation.id, delegation.version)).delegation; try { const result = await this.adapter.run({ id: delegation.id, objective: delegation.objective, workspace: this.resolveWorkspace(delegation.workspaceLabel, scope), scope }); return (await this.relay.complete(delegation.id, running.version, result)).delegation; } catch (error) { await this.relay.fail(delegation.id, running.version); throw error; } } finally { this.#active.delete(delegation.id); } + } +} diff --git a/src/codex/fixtures/fake-app-server.mjs b/src/codex/fixtures/fake-app-server.mjs new file mode 100644 index 0000000..0070e58 --- /dev/null +++ b/src/codex/fixtures/fake-app-server.mjs @@ -0,0 +1,4 @@ +import readline from "node:readline"; +const rl = readline.createInterface({ input: process.stdin }); let threadParams; +const send = value => process.stdout.write(`${JSON.stringify(value)}\n`); +rl.on("line", line => { const message = JSON.parse(line); if (message.method === "initialize") send({ id: message.id, result: {} }); else if (message.method === "thread/start") { threadParams = message.params; send({ id: message.id, result: { thread: { id: "thread-1" } } }); } else if (message.method === "turn/start") { send({ id: message.id, result: { turn: { id: "turn-1" } } }); send({ method: "item/agentMessage/delta", params: { threadId: "thread-1", turnId: "turn-1", itemId: "item-1", delta: `completed:${threadParams.sandbox}:${threadParams.cwd}:${threadParams.model}` } }); send({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", items: [] } } }); } }); diff --git a/src/codex/workspaces.test.ts b/src/codex/workspaces.test.ts new file mode 100644 index 0000000..486fa66 --- /dev/null +++ b/src/codex/workspaces.test.ts @@ -0,0 +1,9 @@ +import { describe, expect, it } from "vitest"; +import { resolveDelegationWorkspace } from "./workspaces.js"; + +describe("workspace resolution", () => { + it("requires an explicit local mapping for workspace authority", () => { + expect(resolveDelegationWorkspace("pigeon", "read_only", { pigeon: "/srv/pigeon" })).toBe("/srv/pigeon"); expect(() => resolveDelegationWorkspace("unknown", "workspace_write", {})).toThrow("workspace_not_configured"); + }); + it("does not expose a mapped workspace to discuss-only work", () => { expect(resolveDelegationWorkspace("pigeon", "discuss_only", { pigeon: "/srv/pigeon" })).toBe(process.cwd()); }); +}); diff --git a/src/codex/workspaces.ts b/src/codex/workspaces.ts new file mode 100644 index 0000000..e1b10ff --- /dev/null +++ b/src/codex/workspaces.ts @@ -0,0 +1,2 @@ +import type { Scope } from "../protocol.js"; +export function resolveDelegationWorkspace(label: string, scope: Scope, mappings: Record) { if (scope === "discuss_only") return process.cwd(); const workspace = mappings[label]; if (!workspace) throw new Error("workspace_not_configured"); return workspace; } diff --git a/src/relay/app.ts b/src/relay/app.ts index 6914224..e61f1cc 100644 --- a/src/relay/app.ts +++ b/src/relay/app.ts @@ -8,7 +8,7 @@ import type { EnrollmentService } from "../enrollment/service.js"; import type { SlackOidcClient } from "../enrollment/slack-oidc.js"; type Options = { store: RelayStore; devices: Map; clock?: () => number; slackInternalSecret?: string; slackSigningSecret?: string; enrollment?: EnrollmentService; oidc?: Pick }; -const TransitionBody = z.object({ expectedVersion: z.number().int().positive(), effectiveScope: z.enum(["discuss_only", "read_only", "workspace_write"]).optional() }); +const TransitionBody = z.object({ expectedVersion: z.number().int().positive(), effectiveScope: z.enum(["discuss_only", "read_only", "workspace_write"]).optional(), summary: z.string().max(4000).optional(), threadId: z.string().max(200).optional() }); const errorStatus: Record = { not_found: 404, expired: 410, version_conflict: 409, scope_widening: 403, invalid_transition: 409, replay: 401, stale_request: 401, invalid_signature: 401, unauthorized: 401, codex_confirmation_required: 403 }; export function createRelayApp({ store, devices, clock = Date.now, slackInternalSecret, slackSigningSecret, enrollment, oidc }: Options) { @@ -50,7 +50,7 @@ export function createRelayApp({ store, devices, clock = Date.now, slackInternal for (const action of ["approve", "reject", "start", "complete", "fail", "cancel"] as const) app.post(`/v1/delegations/:id/${action}`, async (req, res, next) => { try { const actor = transitionActor(req); const body = TransitionBody.parse(req.body); if (action === "approve" && actor.source === "slack" && body.effectiveScope !== "discuss_only") throw new Error("codex_confirmation_required"); - const command = action === "approve" ? { type: action, effectiveScope: body.effectiveScope ?? "discuss_only" } as const : { type: action } as const; + const command = action === "approve" ? { type: action, effectiveScope: body.effectiveScope ?? "discuss_only" } as const : action === "complete" ? { type: action, summary: body.summary, threadId: body.threadId } as const : { type: action } as const; res.json({ delegation: await store.transition(req.params.id!, body.expectedVersion, command, actor) }); } catch (error) { next(error); } }); diff --git a/src/relay/client.ts b/src/relay/client.ts index 56eccc2..efc5cf5 100644 --- a/src/relay/client.ts +++ b/src/relay/client.ts @@ -13,7 +13,8 @@ export class RelayClient { approve(id: string, expectedVersion: number, effectiveScope: RelayDelegation["requestedScope"]) { return this.call<{ delegation: RelayDelegation }>("POST", `/v1/delegations/${encodeURIComponent(id)}/approve`, { expectedVersion, effectiveScope }); } reject(id: string, expectedVersion: number) { return this.call<{ delegation: RelayDelegation }>("POST", `/v1/delegations/${encodeURIComponent(id)}/reject`, { expectedVersion }); } start(id: string, expectedVersion: number) { return this.call<{ delegation: RelayDelegation }>("POST", `/v1/delegations/${encodeURIComponent(id)}/start`, { expectedVersion }); } - complete(id: string, expectedVersion: number) { return this.call<{ delegation: RelayDelegation }>("POST", `/v1/delegations/${encodeURIComponent(id)}/complete`, { expectedVersion }); } + complete(id: string, expectedVersion: number, result?: { summary: string; threadId: string }) { return this.call<{ delegation: RelayDelegation }>("POST", `/v1/delegations/${encodeURIComponent(id)}/complete`, { expectedVersion, ...result }); } + fail(id: string, expectedVersion: number) { return this.call<{ delegation: RelayDelegation }>("POST", `/v1/delegations/${encodeURIComponent(id)}/fail`, { expectedVersion }); } private async call(method: string, relative: string, body: unknown): Promise { const url = new URL(relative, this.options.baseUrl); const timestamp = this.clock(); const nonce = this.nonce(); const path = `${url.pathname}${url.search}`; diff --git a/src/relay/memory-store.ts b/src/relay/memory-store.ts index 964ca05..0ca0940 100644 --- a/src/relay/memory-store.ts +++ b/src/relay/memory-store.ts @@ -45,6 +45,7 @@ export class MemoryRelayStore implements RelayStore { if (!canNarrowScope(current.requestedScope, command.effectiveScope)) throw new Error("scope_widening"); current.effectiveScope = command.effectiveScope; } + if (command.type === "complete") { current.resultSummary = command.summary; current.codexThreadId = command.threadId; } current.state = nextState(current.state, command.type); current.version += 1; current.updatedAt = this.clock(); this.#record(current, command.type, actor); return structuredClone(current); } diff --git a/src/relay/migrations/001_relay.sql b/src/relay/migrations/001_relay.sql index 12379f6..df45a27 100644 --- a/src/relay/migrations/001_relay.sql +++ b/src/relay/migrations/001_relay.sql @@ -2,9 +2,11 @@ CREATE TABLE IF NOT EXISTS delegations ( id uuid PRIMARY KEY, organization_id text NOT NULL, sender_id text NOT NULL, recipient_id text NOT NULL, objective text NOT NULL, workspace_label text NOT NULL, requested_scope text NOT NULL, effective_scope text, state text NOT NULL, version integer NOT NULL, idempotency_key text NOT NULL, expires_at bigint NOT NULL, - created_at bigint NOT NULL, updated_at bigint NOT NULL, + created_at bigint NOT NULL, updated_at bigint NOT NULL, result_summary text, codex_thread_id text, UNIQUE (organization_id, sender_id, idempotency_key) ); +ALTER TABLE delegations ADD COLUMN IF NOT EXISTS result_summary text; +ALTER TABLE delegations ADD COLUMN IF NOT EXISTS codex_thread_id text; CREATE TABLE IF NOT EXISTS relay_events ( sequence bigserial PRIMARY KEY, delegation_id uuid NOT NULL REFERENCES delegations(id), organization_id text NOT NULL, recipient_id text NOT NULL, type text NOT NULL, version integer NOT NULL, actor jsonb NOT NULL, created_at bigint NOT NULL diff --git a/src/relay/postgres.ts b/src/relay/postgres.ts index 36b5868..a4c8349 100644 --- a/src/relay/postgres.ts +++ b/src/relay/postgres.ts @@ -11,7 +11,7 @@ const delegationFrom = (row: Record): RelayDelegation => ({ id: String(row.id), organizationId: String(row.organization_id), senderId: String(row.sender_id), recipientId: String(row.recipient_id), objective: String(row.objective), workspaceLabel: String(row.workspace_label), requestedScope: row.requested_scope as RelayDelegation["requestedScope"], ...(row.effective_scope ? { effectiveScope: row.effective_scope as RelayDelegation["requestedScope"] } : {}), state: row.state as RelayDelegation["state"], - version: Number(row.version), idempotencyKey: String(row.idempotency_key), expiresAt: Number(row.expires_at), createdAt: Number(row.created_at), updatedAt: Number(row.updated_at) + ...(row.result_summary ? { resultSummary: String(row.result_summary) } : {}), ...(row.codex_thread_id ? { codexThreadId: String(row.codex_thread_id) } : {}), version: Number(row.version), idempotencyKey: String(row.idempotency_key), expiresAt: Number(row.expires_at), createdAt: Number(row.created_at), updatedAt: Number(row.updated_at) }); const eventFrom = (row: Record): RelayEvent => ({ sequence: Number(row.sequence), delegationId: String(row.delegation_id), organizationId: String(row.organization_id), recipientId: String(row.recipient_id), type: row.type as RelayEvent["type"], version: Number(row.version), actor: row.actor as Actor, createdAt: Number(row.created_at) }); @@ -45,7 +45,7 @@ export class PostgresRelayStore implements RelayStore { if (current.organizationId !== actor.organizationId || current.recipientId !== actor.userId) throw new Error("not_found"); if (current.expiresAt < this.clock() && current.state === "pending") throw new Error("expired"); if (current.version !== expectedVersion) throw new Error("version_conflict"); let effectiveScope = current.effectiveScope; if (command.type === "approve") { if (!canNarrowScope(current.requestedScope, command.effectiveScope)) throw new Error("scope_widening"); effectiveScope = command.effectiveScope; } - const state = nextState(current.state, command.type); const updated = await client.query("UPDATE delegations SET state=$2,effective_scope=$3,version=version+1,updated_at=$4 WHERE id=$1 RETURNING *", [id, state, effectiveScope ?? null, this.clock()]); + const state = nextState(current.state, command.type); const updated = await client.query("UPDATE delegations SET state=$2,effective_scope=$3,version=version+1,updated_at=$4,result_summary=COALESCE($5,result_summary),codex_thread_id=COALESCE($6,codex_thread_id) WHERE id=$1 RETURNING *", [id, state, effectiveScope ?? null, this.clock(), command.type === "complete" ? command.summary ?? null : null, command.type === "complete" ? command.threadId ?? null : null]); const delegation = delegationFrom(updated.rows[0]); const event = await this.insertEvent(client, delegation, command.type, actor); await client.query("INSERT INTO relay_outbox(event_sequence,payload,available_at) VALUES($1,$2,$3)", [event.sequence, JSON.stringify(event), this.clock()]); await client.query("COMMIT"); return delegation; } catch (error) { await client.query("ROLLBACK"); throw error; } finally { client.release(); } diff --git a/src/relay/store.test.ts b/src/relay/store.test.ts index 15e13a6..37d79ba 100644 --- a/src/relay/store.test.ts +++ b/src/relay/store.test.ts @@ -40,4 +40,8 @@ describe("relay store", () => { const store = new MemoryRelayStore(() => 1_000); await expect(store.createDelegation({ ...input, workspaceLabel: "/Users/alice/secret" }, actor)).rejects.toThrow("invalid_workspace_label"); }); + + it("persists a bounded completion result for the sender", async () => { + const store = new MemoryRelayStore(() => 1_000); let { delegation } = await store.createDelegation({ ...input, idempotencyKey: "request-result-0001" }, actor); const bob = { ...actor, userId: "bob", deviceId: "bob-mac" }; delegation = await store.transition(delegation.id, 1, { type: "approve", effectiveScope: "read_only" }, bob); delegation = await store.transition(delegation.id, 2, { type: "start" }, bob); delegation = await store.transition(delegation.id, 3, { type: "complete", summary: "Reviewed successfully", threadId: "thread-1" }, bob); expect(delegation).toMatchObject({ state: "completed", resultSummary: "Reviewed successfully", codexThreadId: "thread-1" }); + }); }); diff --git a/src/relay/types.ts b/src/relay/types.ts index 04e7743..5c0b86e 100644 --- a/src/relay/types.ts +++ b/src/relay/types.ts @@ -12,13 +12,13 @@ export type CreateDelegationInput = z.infer; export const RelayDelegationSchema = CreateDelegationInputSchema.safeExtend({ id: z.string().uuid(), organizationId: z.string(), senderId: z.string(), state: StateSchema, - effectiveScope: ScopeSchema.optional(), version: z.number().int().positive(), createdAt: z.number().int(), updatedAt: z.number().int() + effectiveScope: ScopeSchema.optional(), resultSummary: z.string().max(4000).optional(), codexThreadId: z.string().max(200).optional(), version: z.number().int().positive(), createdAt: z.number().int(), updatedAt: z.number().int() }); export type RelayDelegation = z.infer; export const RelayCommandSchema = z.discriminatedUnion("type", [ z.object({ type: z.literal("approve"), effectiveScope: ScopeSchema }), z.object({ type: z.literal("reject") }), - z.object({ type: z.literal("start") }), z.object({ type: z.literal("complete") }), z.object({ type: z.literal("fail") }), z.object({ type: z.literal("cancel") }) + z.object({ type: z.literal("start") }), z.object({ type: z.literal("complete"), summary: z.string().max(4000).optional(), threadId: z.string().max(200).optional() }), z.object({ type: z.literal("fail") }), z.object({ type: z.literal("cancel") }) ]); export type RelayCommand = z.infer; diff --git a/src/server.ts b/src/server.ts index 3fb5c24..59bbb9f 100644 --- a/src/server.ts +++ b/src/server.ts @@ -8,11 +8,15 @@ import { canNarrowScope, ScopeSchema } from "./protocol.js"; import { widgetHtml } from "./widget.js"; import { RelayClient } from "./relay/client.js"; import { loadRelayCredentials } from "./enrollment/local-credentials.js"; +import { CodexAppServerAdapter } from "./codex/app-server.js"; +import { RemoteExecutionWorker } from "./codex/execution-worker.js"; +import { resolveDelegationWorkspace } from "./codex/workspaces.js"; const relay = new InMemoryRelay(); const teammate = process.env.PIGEON_USER ?? "local"; const gateway = new Gateway(teammate, relay, { run: async d => ({ summary: `Delegation ${d.id} accepted; configure PIGEON_CODEX_COMMAND for execution.` }) }); const credentials = loadRelayCredentials(); const remote = credentials ? new RelayClient({ baseUrl: credentials.relayUrl, deviceId: credentials.deviceId, privateKey: credentials.privateKey }) : undefined; +const workspaceMappings = JSON.parse(process.env.PIGEON_WORKSPACES ?? "{}") as Record; const codexAdapter = new CodexAppServerAdapter(); const executionWorker = remote ? new RemoteExecutionWorker(remote, codexAdapter, (label, scope) => resolveDelegationWorkspace(label, scope, workspaceMappings)) : undefined; const teammates = (process.env.PIGEON_TEAMMATES ?? teammate).split(",").map(value => value.trim()).filter(Boolean); const server = new McpServer({ name: "pigeon", version: "0.1.0" }, { instructions: "Use Pigeon only for explicit teammate delegation. Open the inbox to review requests. Approval always requires native confirmation." }); const UI = "ui://pigeon/inbox-v1.html"; @@ -31,8 +35,10 @@ server.registerTool("prepare_approval", { description: "Use this only from the P const c = remoteDelegation ? { sender: remoteDelegation.senderId, objective: remoteDelegation.objective, workspace: remoteDelegation.workspaceLabel, effectiveScope } : prepared!.confirmation; const answer = await server.server.elicitInput({ mode: "form", message: `Approve ${c.sender}'s Codex delegation? Objective: ${c.objective} | Workspace: ${c.workspace} | Authority: ${c.effectiveScope}`, requestedSchema: { type: "object", properties: { confirm: { type: "boolean", title: "Grant this authority" } }, required: ["confirm"] } }); if (answer.action !== "accept" || answer.content?.confirm !== true) return result({ confirmed: false }, "Approval cancelled."); - if (remote) { let d = (await remote.approve(id, remoteDelegation!.version, effectiveScope)).delegation; d = (await remote.start(id, d.version)).delegation; d = (await remote.complete(id, d.version)).delegation; return result({ confirmed: true, delegation: d, result: { summary: `Delegation ${id} completed by the configured Codex gateway.` } }, "Delegation approved and completed."); } + if (remote) { const approved = (await remote.approve(id, remoteDelegation!.version, effectiveScope)).delegation; const d = await executionWorker!.execute(approved); return result({ confirmed: true, delegation: d, result: { summary: d.resultSummary, threadId: d.codexThreadId } }, "Delegation approved and completed."); } const run = await gateway.confirmApproval(id, prepared!.capability); return result({ confirmed: true, delegation: gateway.get(id), result: run }, "Delegation approved and completed."); }); +if (executionWorker) { const timer = setInterval(() => { void executionWorker.runApproved().catch(error => process.stderr.write(`Pigeon execution worker: ${error instanceof Error ? error.message : String(error)}\n`)); }, 2_000); timer.unref(); } + if (process.argv[1] && readFileSync(process.argv[1], "utf8").includes("new McpServer")) await server.connect(new StdioServerTransport()); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..d156984 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,2 @@ +import { defineConfig } from "vitest/config"; +export default defineConfig({ test: { fileParallelism: false } });