diff --git a/docs/specs/2026-06-06-multi-instance-stage1-observer.md b/docs/specs/2026-06-06-multi-instance-stage1-observer.md new file mode 100644 index 0000000..b9ea43b --- /dev/null +++ b/docs/specs/2026-06-06-multi-instance-stage1-observer.md @@ -0,0 +1,200 @@ +# Multi-Instance Stage 1: Read-Only Observer — Design Spec + +Issue: #127 (Stage 1 of 3). Prerequisites: #125 (explicit workspace join + named +broker instances — pear side landed `435d78c`, relay/cloud halves in flight), +#126 (remote host support — not started). + +Status: DESIGN ONLY. Written 2026-06-06 during the #125 build-out; contract +references below are to the locked #125 naming contract. + +## Goal + +A second Pear instance (same user, different machine — multi-user comes with +invite scoping later in this stage) can open a project that is "hosted" +elsewhere, see the same agent graph, and watch live terminal output. It cannot +send PTY input, spawn/stop agents, or mutate project settings. + +## Non-goals (Stage 1) + +- No write path of any kind from the observer (Stage 2). +- No per-user permission levels beyond owner/observer (Stage 3 adds editor). +- No presence avatars/notifications UI beyond a minimal "N instances connected" + indicator (Stage 3). +- No CRDT/merge for project definitions: single-writer (the host instance), + observers treat shared state as read-only snapshots. +- No web observer; Electron only. + +## Foundations this builds on (all #125 vintage) + +| Primitive | Where | Why it matters here | +|---|---|---| +| Explicit workspace join | relay `--workspace-key` / `AGENT_RELAY_WORKSPACE_KEY`, fail-loud on invalid key | Observer joins the project workspace; MUST hard-fail rather than silently create a fresh one (the #125 failure mode) | +| Named broker instances | `--instance-name` / `AGENT_RELAY_BROKER_NAME`; `RuntimeSpawnOptions.workspaceKey?/brokerName?` | Instance identity. Observers are addressable, and ownership checks key off the name | +| Workspace key seam | `brokerManager.workspaceKeyForProject(projectId)` (broker.ts) | The host-side source of truth an invite token wraps | +| Remote attach primitive | `attachCloudSandbox()` connects by `execUrl + apiKey` (broker.ts:1467) | The observer's connection path to the host broker is the same shape (#126 generalizes it) | +| Event dedupe discipline | `slackLogicalInjections` canonical-identity claims (integration-event-bridge.ts) | Fan-out to N instances multiplies the duplicate-delivery surface; reuse logical-identity claims, never per-copy revisions | + +## Instance naming + +Local broker names are currently `pear-${project.relayWorkspaceId}` +(project-store.ts:58) — project-stable but NOT instance-unique; two instances +on one project collide. This was explicitly deferred out of #125. Stage 1 is +where it lands: + +- Name = `pear--` where machineSlug = + hostname-derived, 8 chars, persisted in local app config on first run + (NOT regenerated per session — names must be stable for ownership checks). +- The HOST instance keeps the legacy un-suffixed name working via PEAR + METADATA, not broker-side aliasing (relay-worker ruling, 2026-06-06): the + shared project definition publishes both `brokerName` (canonical suffixed) + and `legacyBrokerName`; consumers resolve through the definition. Broker-side + aliasing would disturb the just-stabilized strict-name registration + semantics and is rejected. + +## 1. Shared project definitions + +Authoritative project definition moves to the relay workspace as a single +relayfile document: `/pear/project.json` (channels, integration scopes, roots, +host assignment, schema version). Rationale for relayfile over a new relay +cloud API: sync, change events, and conflict surface already exist; observers +already need relayfile access for mirrors. + +- Host instance: writes `/pear/project.json` on every local mutation + (debounced). Local `projects.json` stays the cache/bootstrap copy. +- Observer instance: subscribes to `/pear/project.json` change events + (the same `subscribe()` machinery the integration-event bridge uses); applies + snapshots read-only; never writes. +- Conflict rule (Stage 1): host wins, always — observers don't write, so the + only conflict is host vs stale cache, resolved by `revision` compare on open. +- Schema versioned (`schemaVersion: 1`); observer with unknown newer version + shows "upgrade required" instead of guessing. + +## 2. Invite / join flow + +Stage 1 keeps tokens same-account (multi-user scoping is the second half of +this stage, gated on relay-side scoped tokens): + +``` +InviteToken = base64url(JSON{ + v: 1, + workspaceKey, // from workspaceKeyForProject(projectId) + relayWorkspaceId, // account workspace (cloud API URL construction) + hostBrokerName, // addressing + ownership root + brokerUrl?, // #126 remote-host URL when available; absent = cloud-relay discovery + role: 'observer' +}) +``` + +- Generate: host instance, new IPC `workspace.invite(projectId)` → token string + (UI: copy button in project settings). +- Join: `workspace.join(token)` → validates schema/version → spawns/joins + observer-side broker session with `workspaceKey` + its own instance name → + fetches `/pear/project.json` → materializes a read-only project entry in the + local store (flagged `origin: 'shared'`, `role: 'observer'`). +- Fail-loud inheritance: a bad/expired key surfaces the broker's strict-join + error verbatim. No fallback to create. The broker distinguishes fatal + rejection (401/403 — "rejected") from rate-limiting (429 — "rate-limited", + HTTP status preserved through AuthHttpError): the join UI treats the former + as a bad invite and the latter as retryable with backoff. +- Token carries no bearer secret beyond the workspace key in Stage 1 + (same-account); the multi-user variant swaps `workspaceKey` for a relay-issued + scoped token and is EXPLICITLY out of scope until relay exposes one. + +## 3. Read-only enforcement + +Two layers, because UI-only enforcement is not enforcement: + +1. **Pear layer (UX):** project entries with `role: 'observer'` get + permission-aware guards in the renderer stores — spawn/stop/input/settings + actions disabled with tooltips. IPC handlers for mutating calls check the + role flag and reject (`ROLE_OBSERVER_READONLY` error) so a buggy renderer + can't mutate either. +2. **Broker layer (authority):** per relay-worker (2026-06-06) the right home + is a readonly capability on the CONNECTION/API layer — a flag in the local + HTTP/WS connection/session context that rejects mutating REST endpoints and + delivery/spawn/release/write actions, while the host connection keeps + normal identity. The lease API is explicitly the wrong layer (leases govern + broker lifetime, not caller authority). Effort: moderate; scheduled with + relay, not in Stage 1's critical path. Stage 1 ships with pear-layer + enforcement only; the trust boundary is then "same account", acceptable for + same-user Stage 1, NOT for multi-user invites (hard gate: multi-user waits + for the broker-side capability). + +## 4. PTY fan-out + +The broker already supports multiple clients; #125 makes both instances land in +one workspace. Stage 1 needs verification + hardening, not new plumbing: + +- Test matrix: 2 instances × (local host, remote host) × (agent spawn before / + after observer join) — observer must receive output chunks for agents + spawned both before and after it connected. Catch-up contract per + relay-worker (2026-06-06): current visible-screen PTY snapshot (existing + snapshot/state machinery) + live stream from join. There is NO durable + per-observer scrollback contract — historical replay is out of scope and the + UI labels the point where the observer's view begins. +- Duplicate-event hardening per AGENTS.md guidance: PTY chunks are + sequence-numbered per (agentName, ptyId); pear-side consumer drops + already-seen sequence numbers — same canonical-identity discipline as the + slack dedupe, trivially cheaper (monotonic seq, not content hashes). +- Broker events (`agent_spawned`, `agent_exited`, …) fan out to all instances; + observer applies them to its read-only graph. Event `instanceName` field + (from the #125 named-instance work) distinguishes "who did that" for the + Stage 3 presence layer — carried but unused in Stage 1. + +## 5. Minimal presence (Stage 1 slice) + +- Each instance publishes `{instanceName, role, joinedAt}` on a relaycast + channel `#pear-presence-` on join, tombstone on clean exit, + TTL-expired by peers on silence (heartbeat every 60s). +- UI: "2 instances connected" pill on the project header. Nothing else. +- This channel is also Stage 2's coordination root (ownership claims), so the + message schema gets a `kind` discriminator from day one. + +## IPC / type additions + +- `workspace.invite(projectId) → string` +- `workspace.join(token) → { projectId }` +- `workspace.onPresenceUpdate(projectId, instances[])` +- Project record: `origin: 'local' | 'shared'`, `role: 'owner' | 'observer'`, + `hostBrokerName?`, `sharedRevision?` +- Mutating IPC handlers gain the role guard (single helper, applied at the + handler boundary, not per-store). + +## Dependencies / sequencing + +``` +#125 relay strict-join + named instances [in flight, T3] +#125 cloud verbatim env injection [in flight, T4] +#126 remote host (broker URL for observer) [not started — Stage 1 can demo + against a cloud-sandbox host first] +relay: scoped invite tokens [needed for multi-user only] +relay: broker-side readonly capability [needed for multi-user; stub OK same-user] +``` + +Buildable order inside Stage 1: instance-name uniqueness → shared +project.json (host write path, observer read path) → invite/join IPC + UI → +PTY fan-out verification → presence slice. Each lands behind a +`PEAR_MULTI_INSTANCE` flag until the stage is coherent. + +## Open questions (for #general before implementation) + +1. relay-worker: name alias for the legacy un-suffixed host broker name vs + pear publishing both names — which is cheaper broker-side? +2. relay-worker: per-connection readonly capability — connection API or + lease API? Effort estimate decides whether same-user Stage 1 waits for it. +3. cloud-lead: does `/pear/project.json` in the account workspace collide with + any cloud-side relayfile path conventions/reserved prefixes? +4. PTY backfill: does the broker keep enough scrollback per PTY to replay on + attach, or is "live from join" the Stage 1 contract? + +## Test plan sketch + +- Unit: invite token round-trip (incl. version/role rejection), role guard on + every mutating IPC handler (table-driven), project.json snapshot apply + + revision conflict. +- Integration: two BrokerManager instances against one broker (the + broker.test.ts harness already mocks spawn; extend with a second client), + PTY seq dedupe under interleaved chunks, observer join while agent mid-run. +- Manual/e2e (needs T2-style debug logging): second machine joins via token, + watches a live agent, kill -9 the host instance → observer survives in + read-only state with stale-host indicator. diff --git a/src/main/__tests__/integration-event-bridge.test.ts b/src/main/__tests__/integration-event-bridge.test.ts index 0ae7f6a..e95f39a 100644 --- a/src/main/__tests__/integration-event-bridge.test.ts +++ b/src/main/__tests__/integration-event-bridge.test.ts @@ -776,6 +776,76 @@ test('slack raw-id and slug alias duplicates suppress when one context read is s assert.match(harness.sent[0].input.text, /Message:\nreadable Slack message/u) }) +test('slack edits after a blind alias claim still inject once the content changes', async () => { + let messageText = 'original Slack message' + const harness = makeHarness(['alice'], { + readFileResponse: (_workspaceId, path) => { + if (!path.includes('__proj-cloud')) throw new Error('remote file not ready') + return { + path, + revision: 'rev-context', + contentType: 'application/json', + content: JSON.stringify({ provider: 'slack', text: messageText }), + encoding: 'utf-8' + } + } + }) + + await withMockedNow('2026-06-05T14:00:00.000Z', async () => { + await harness.bridge.reconcile('project-1', [ + integration({ + provider: 'slack', + integrationId: 'slack-1', + mountPaths: ['/slack/channels/C123ABC__proj-cloud'], + downloadHistoricalData: false, + scope: { notifyAgents: ['alice'] } + }) + ]) + }) + + // Raw-id copy first: every targeted read fails and the expanded event only + // carries the sparse relayfile pointer, so the injection is blind. + await harness.emit({ + ...changeEvent( + '/slack/channels/C123ABC/messages/1780668000_000000/meta.json', + 'slack', + { digest: 'revision:raw-copy' } + ), + expand: async () => ({ + level: 'full', + path: '/slack/channels/C123ABC/messages/1780668000_000000/meta.json', + data: { + path: '/slack/channels/C123ABC/messages/1780668000_000000/meta.json', + deleted: false + } + }) + } as ChangeEvent) + await waitForSent(harness, 1, 2_500) + assert.equal(harness.sent.length, 1) + assert.match(harness.sent[0].input.text, /Message: unavailable; targeted context read did not return content\./u) + + // The slug alias copy of the same record carries content: suppressed as a + // duplicate, but the claim learns the content hash. + await harness.emit(changeEvent( + '/slack/channels/C123ABC__proj-cloud/messages/1780668000_000000/meta.json', + 'slack', + { digest: 'revision:slug-copy' } + )) + await waitForDropped('project-1', 1, 2_500) + assert.equal(harness.sent.length, 1) + + // A genuine edit changes the content hash and must inject again. + messageText = 'edited Slack message' + await harness.emit(changeEvent( + '/slack/channels/C123ABC__proj-cloud/messages/1780668000_000000/meta.json', + 'slack', + { digest: 'revision:slug-edit' } + )) + await waitForSent(harness, 2) + assert.equal(harness.sent.length, 2) + assert.match(harness.sent[1].input.text, /Message:\nedited Slack message/u) +}) + test('remote replayed events older than the subscription session are dropped by default', async () => { const harness = makeHarness() diff --git a/src/main/broker.test.ts b/src/main/broker.test.ts index 42afcc9..3e4a75a 100644 --- a/src/main/broker.test.ts +++ b/src/main/broker.test.ts @@ -74,6 +74,7 @@ const mock = vi.hoisted(() => { connectedClients: [] as MockClient[], nextLocalAgents: [] as string[], nextCloudAgents: [] as string[], + nextCloudSessionMetadata: [] as Array>, nextConnectedAgents: [] as string[], nextConnectedSessionErrors: [] as Error[] } @@ -97,6 +98,10 @@ const mock = vi.hoisted(() => { constructor() { const client = createMockClient(state.nextCloudAgents.splice(0)) + const metadata = state.nextCloudSessionMetadata.shift() + if (metadata) { + client.getSession.mockResolvedValueOnce(metadata) + } state.constructedClients.push(client) // Re-key `this` as the mock client. return client as unknown as HarnessDriverClient @@ -334,6 +339,7 @@ describe('BrokerManager local + cloud coexistence', () => { mock.state.connectedClients.length = 0 mock.state.nextLocalAgents = [] mock.state.nextCloudAgents = [] + mock.state.nextCloudSessionMetadata = [] mock.state.nextConnectedAgents = [] mock.state.nextConnectedSessionErrors = [] mock.HarnessDriverClient.spawn.mockClear() @@ -375,6 +381,115 @@ describe('BrokerManager local + cloud coexistence', () => { await manager.shutdown() }) + it('passes an explicit workspace key env pin to local broker spawn options', async () => { + const previousWorkspaceKey = process.env.AGENT_RELAY_WORKSPACE_KEY + process.env.AGENT_RELAY_WORKSPACE_KEY = 'rk_live_pinned' + const manager = new BrokerManager() + + try { + await manager.start(PROJECT_ID, '/tmp/project-1', 'pear-project-1', undefined as never, []) + + expect(mock.HarnessDriverClient.spawn).toHaveBeenCalledWith(expect.objectContaining({ + brokerName: 'pear-project-1', + workspaceKey: 'rk_live_pinned' + })) + } finally { + if (previousWorkspaceKey === undefined) { + delete process.env.AGENT_RELAY_WORKSPACE_KEY + } else { + process.env.AGENT_RELAY_WORKSPACE_KEY = previousWorkspaceKey + } + await manager.shutdown() + } + }) + + it('reads the local broker workspace key for cloud provisioning', async () => { + const manager = new BrokerManager() + const local = await startLocal(manager) + local.getSession.mockResolvedValueOnce({ workspace_key: 'rk_live_project' }) + + await expect(manager.workspaceKeyForProject(PROJECT_ID)).resolves.toBe('rk_live_project') + + await manager.shutdown() + }) + + it('omits the project workspace key when no local broker exposes one', async () => { + const manager = new BrokerManager() + await startLocal(manager) + + await expect(manager.workspaceKeyForProject(PROJECT_ID)).resolves.toBeUndefined() + await expect(manager.workspaceKeyForProject('missing-project')).resolves.toBeUndefined() + + await manager.shutdown() + }) + + it('emits a cloud workspace mismatch event when the sandbox ignores the sent key', async () => { + const manager = new BrokerManager() + const win = createMockWindow() + mock.state.nextCloudSessionMetadata.push({ workspace_key: 'rk_sand_456' }) + + await manager.attachCloudSandbox(PROJECT_ID, { + sandboxId: 'sandbox-1', + execUrl: 'https://sandbox.example', + sentWorkspaceKey: 'rk_sent_123' + }, win) + + expect(win.webContents.send).toHaveBeenCalledWith( + 'broker:event', + expect.objectContaining({ + kind: 'cloud_workspace_key_mismatch', + projectId: PROJECT_ID, + cloudSandboxId: 'sandbox-1', + sentWorkspaceKeyPrefix: 'rk_sent_', + sandboxWorkspaceKeyPrefix: 'rk_sand_', + detail: expect.stringContaining('stale broker binary') + }) + ) + + await manager.shutdown() + }) + + it('does not emit a cloud workspace mismatch event when the sandbox joins the sent key', async () => { + const manager = new BrokerManager() + const win = createMockWindow() + mock.state.nextCloudSessionMetadata.push({ workspace_key: 'rk_live_same' }) + + await manager.attachCloudSandbox(PROJECT_ID, { + sandboxId: 'sandbox-1', + execUrl: 'https://sandbox.example', + sentWorkspaceKey: 'rk_live_same' + }, win) + + const mismatchEvents = (win.webContents.send as ReturnType).mock.calls + .filter(([channel, payload]) => + channel === 'broker:event' && + (payload as { kind?: string }).kind === 'cloud_workspace_key_mismatch' + ) + expect(mismatchEvents).toHaveLength(0) + + await manager.shutdown() + }) + + it('does not emit a cloud workspace mismatch event on keyless legacy attaches', async () => { + const manager = new BrokerManager() + const win = createMockWindow() + mock.state.nextCloudSessionMetadata.push({ workspace_key: 'rk_live_sandbox' }) + + await manager.attachCloudSandbox(PROJECT_ID, { + sandboxId: 'sandbox-1', + execUrl: 'https://sandbox.example' + }, win) + + const mismatchEvents = (win.webContents.send as ReturnType).mock.calls + .filter(([channel, payload]) => + channel === 'broker:event' && + (payload as { kind?: string }).kind === 'cloud_workspace_key_mismatch' + ) + expect(mismatchEvents).toHaveLength(0) + + await manager.shutdown() + }) + it('reuses current harness-driver connection files instead of spawning another broker', async () => { const tempDir = await mkdtemp(join(tmpdir(), 'pear-current-connection-')) const connectionPath = join(tempDir, '.agentworkforce', 'relay', 'connection.json') diff --git a/src/main/broker.ts b/src/main/broker.ts index 9797cd4..6eb3e7d 100644 --- a/src/main/broker.ts +++ b/src/main/broker.ts @@ -139,6 +139,18 @@ export function resolveAgentRelayMcpCommand(): string | undefined { }) } +// Strict-join failures from the broker (#125): an explicitly pinned workspace +// key never falls back to creating a fresh workspace. Auth rejection (401/403, +// "was rejected") is fatal — the key is bad or revoked; rate limiting (429, +// "was rate-limited") is retryable. Contract strings from agent-relay-broker +// relaycast/auth.rs, verified in the T3 review. +export function classifyWorkspaceJoinFailure(err: unknown): 'rejected' | 'rate-limited' | null { + const message = toErrorMessage(err) + if (/explicit workspace key .* was rate-limited/iu.test(message)) return 'rate-limited' + if (/explicit workspace key .* was rejected/iu.test(message)) return 'rejected' + return null +} + function parseAgentWorkforceJson(output: string, label: string): T { try { return JSON.parse(output) as T @@ -301,6 +313,13 @@ export interface CloudAgentSandboxHandle { execUrl: string apiKey?: string relayfileMountPath?: string + /** + * The relay workspace key provisioning actually sent on POST /box (#125). + * Set only when the warm path resolved a local key — its presence arms the + * attach-time tripwire that detects a sandbox broker silently ignoring + * AGENT_RELAY_WORKSPACE_KEY (stale, pre-strict-join binary). + */ + sentWorkspaceKey?: string } type BrokerEventObserver = (projectId: string, event: BrokerEvent) => void @@ -1304,12 +1323,20 @@ export class BrokerManager { console.warn('[broker] Agent Relay MCP command could not be resolved; broker will use its default MCP command') } - const opts: AgentRelaySpawnOptions = { + // Phase 1 of #125: the local broker stays the workspace creator, so the + // key is only threaded when explicitly pinned via env. The intersection + // type is the single cast site until @agent-relay/harness-driver + // PUBLISHES workspaceKey in RuntimeSpawnOptions (landed relay-side in + // 6419d59c; verified against the built 8.3.0+T3 dist locally) — the + // intersection erases to a no-op then and drops with the version bump. + const explicitWorkspaceKey = process.env.AGENT_RELAY_WORKSPACE_KEY?.trim() || undefined + const opts: AgentRelaySpawnOptions & { workspaceKey?: string } = { cwd, brokerName: name, channels: nextChannels, binaryArgs: { persist: true }, binaryPath: resolveBundledBrokerBinary(), + ...(explicitWorkspaceKey ? { workspaceKey: explicitWorkspaceKey } : {}), env: { PATH: augmentedPath(), ...(agentRelayMcpCommand ? { AGENT_RELAY_MCP_COMMAND: agentRelayMcpCommand } : {}) @@ -1356,7 +1383,13 @@ export class BrokerManager { return await startPromise } catch (err) { console.error(`[broker] Failed to start for project ${normalizedProjectId}:`, err) - this.sendStatusToWindow(win, normalizedProjectId, 'error', String(err)) + const joinFailure = classifyWorkspaceJoinFailure(err) + const statusMessage = joinFailure === 'rate-limited' + ? `Workspace join rate-limited (retryable): ${String(err)}` + : joinFailure === 'rejected' + ? `Workspace key rejected — broker refused to create a fresh workspace: ${String(err)}` + : String(err) + this.sendStatusToWindow(win, normalizedProjectId, 'error', statusMessage) throw err } finally { if (this.startPromises.get(normalizedProjectId) === startPromise) { @@ -1459,6 +1492,27 @@ export class BrokerManager { return { removed } } + /** + * The local broker creates the project's relay workspace; its workspace_key + * is what cloud sandbox brokers must join so local and cloud agents share + * one workspace (#125). Non-throwing: resolves undefined until a local + * session exists and exposes a key, so provisioning can proceed without it. + */ + async workspaceKeyForProject(projectId: string): Promise { + const normalizedProjectId = projectId.trim() + if (!normalizedProjectId) return undefined + const startPromise = this.startPromises.get(normalizedProjectId) + if (startPromise) await startPromise.catch(() => undefined) + const session = this.sessions.get(normalizedProjectId) + if (!session) return undefined + try { + const metadata = await session.client.getSession() + return metadata.workspace_key || undefined + } catch { + return undefined + } + } + /** * Attach to an already-provisioned cloud sandbox (used by CloudAgentManager * which warms the box via the cloud-agents/{id}/box endpoint). connectCloud @@ -1515,7 +1569,28 @@ export class BrokerManager { baseUrl: execUrl, ...(apiKey ? { apiKey } : {}) }) - await client.getSession() + const sessionMetadata = await client.getSession() + + // #125 tripwire: provisioning asked this sandbox broker to JOIN an + // explicit workspace. A different key in the session means the broker + // ignored AGENT_RELAY_WORKSPACE_KEY (a pre-strict-join binary, e.g. a + // stale snapshot bake) and silently created an isolated workspace — + // exactly the failure #125 fixed. Compare only when a key was actually + // sent; prefixes keep the event diagnosable from logs without leaking + // whole keys. + const sentWorkspaceKey = handle.sentWorkspaceKey?.trim() || undefined + const sandboxWorkspaceKey = sessionMetadata.workspace_key || undefined + const workspaceKeyMismatch = !!sentWorkspaceKey && sandboxWorkspaceKey !== sentWorkspaceKey + if (workspaceKeyMismatch) { + console.error( + `[broker] Cloud sandbox broker ignored workspace key for project ${normalizedProjectId} — stale broker binary?`, + { + sandboxId, + sentWorkspaceKeyPrefix: sentWorkspaceKey?.slice(0, 8), + sandboxWorkspaceKeyPrefix: sandboxWorkspaceKey?.slice(0, 8) ?? '(none)' + } + ) + } const eventStreamGeneration = this.nextEventStreamGeneration() const unsubEvent = this.attachClient(sessionKey, client, win, eventStreamGeneration) @@ -1544,6 +1619,15 @@ export class BrokerManager { }) client.connectEvents() + if (workspaceKeyMismatch) { + this.publishBrokerEvent(sessionKey, normalizedProjectId, win, { + kind: 'cloud_workspace_key_mismatch', + cloudSandboxId: sandboxId, + sentWorkspaceKeyPrefix: sentWorkspaceKey?.slice(0, 8) ?? null, + sandboxWorkspaceKeyPrefix: sandboxWorkspaceKey?.slice(0, 8) ?? null, + detail: 'sandbox broker ignored AGENT_RELAY_WORKSPACE_KEY — stale broker binary?' + }) + } this.publishBrokerEvent(sessionKey, normalizedProjectId, win, { kind: 'broker_initialized', name: `cloud-${normalizedProjectId}`, diff --git a/src/main/cloud-agent.test.ts b/src/main/cloud-agent.test.ts index acab282..fe52e63 100644 --- a/src/main/cloud-agent.test.ts +++ b/src/main/cloud-agent.test.ts @@ -65,7 +65,8 @@ const mock = vi.hoisted(() => { brokerManager: { onBrokerEvent: vi.fn(), attachCloudSandbox: vi.fn(async () => undefined), - detachCloudSandbox: vi.fn(async () => undefined) + detachCloudSandbox: vi.fn(async () => undefined), + workspaceKeyForProject: vi.fn(async () => undefined) }, fetch: vi.fn(async (url: string | URL | Request, init?: RequestInit) => { const normalizedUrl = String(url) @@ -221,6 +222,8 @@ describe('CloudAgentManager', () => { mock.brokerManager.onBrokerEvent.mockClear() mock.brokerManager.attachCloudSandbox.mockClear() mock.brokerManager.detachCloudSandbox.mockClear() + mock.brokerManager.workspaceKeyForProject.mockClear() + mock.brokerManager.workspaceKeyForProject.mockResolvedValue(undefined) mock.loadStore.mockClear() mock.saveStore.mockClear() mock.resolveCloudAuth.mockClear() @@ -261,6 +264,26 @@ describe('CloudAgentManager', () => { await Promise.resolve() } + function boxRequest(method: string): { url: string; init?: RequestInit } | undefined { + return mock.fetchCalls.find((call) => + call.init?.method === method && + call.url.includes('/cloud-agents/cloud-agent-1/box') + ) + } + + function boxRequestBody(method: string): Record { + const request = boxRequest(method) + if (!request?.init?.body) throw new Error(`missing ${method} box request body`) + return JSON.parse(String(request.init.body)) as Record + } + + function expectBoxPostBody(expected: Record): void { + expect(boxRequestBody('POST')).toEqual({ + brokerName: 'cloud-cloud-ag', + ...expected + }) + } + it('keeps a newly created cloud agent visible while the cloud list catches up', async () => { const manager = new CloudAgentManager() @@ -302,14 +325,84 @@ describe('CloudAgentManager', () => { expect(boxPost?.url).toBe( 'https://cloud.example/api/v1/workspaces/account-workspace-id/cloud-agents/cloud-agent-1/box?async=true' ) - expect(JSON.parse(String(boxPost?.init?.body))).toEqual({ + expectBoxPostBody({ relayfileMountPaths: ['/integrations/github', '/workspace'] }) + expect(boxRequestBody('POST')).not.toHaveProperty('workspaceKey') expect(boxPost?.url).not.toContain('relay-workspace-id') expect(mock.fetchCalls.filter((call) => call.url.endsWith('/api/v1/auth/whoami'))).toHaveLength(1) expect(mock.mountInputs[0]?.workspaceId).toBe('relay-workspace-id') }) + it('passes the local relay workspace key and stable cloud broker name when warming a box', async () => { + mock.brokerManager.workspaceKeyForProject.mockResolvedValueOnce('rk_live_project') + const manager = new CloudAgentManager() + + await manager.attach('project-1', 'cloud-agent-1') + + expect(mock.brokerManager.workspaceKeyForProject).toHaveBeenCalledWith('project-1') + expectBoxPostBody({ + relayfileMountPaths: ['/integrations/github', '/workspace'], + workspaceKey: 'rk_live_project' + }) + expect(mock.brokerManager.attachCloudSandbox).toHaveBeenCalledWith( + 'project-1', + expect.objectContaining({ + sandboxId: 'sandbox-1', + sentWorkspaceKey: 'rk_live_project' + }), + undefined + ) + }) + + it('does not pass a sent workspace key for keyless warms', async () => { + const manager = new CloudAgentManager() + + await manager.attach('project-1', 'cloud-agent-1') + + expect(mock.brokerManager.attachCloudSandbox).toHaveBeenCalledWith( + 'project-1', + expect.not.objectContaining({ + sentWorkspaceKey: expect.anything() + }), + undefined + ) + }) + + it('clears the sent workspace key when a cloud agent detaches', async () => { + mock.brokerManager.workspaceKeyForProject.mockResolvedValueOnce('rk_live_project') + const manager = new CloudAgentManager() + await manager.attach('project-1', 'cloud-agent-1') + await manager.detach('project-1') + mock.brokerManager.attachCloudSandbox.mockClear() + + await (manager as unknown as { + connectBroker: (projectId: string, sandbox: { + sandboxId: string + execUrl: string + filesUrl: string + relayfileToken: string + relayfileMountPath: string + status: 'ready' + }) => Promise + }).connectBroker('project-1', { + sandboxId: 'sandbox-2', + execUrl: 'https://sandbox-2.example', + filesUrl: 'https://sandbox-2.example/files', + relayfileToken: 'relayfile-token-2', + relayfileMountPath: '/remote/project-1', + status: 'ready' + }) + + expect(mock.brokerManager.attachCloudSandbox).toHaveBeenCalledWith( + 'project-1', + expect.not.objectContaining({ + sentWorkspaceKey: expect.anything() + }), + undefined + ) + }) + it('reuses a warm-on-intent box when attach is clicked', async () => { const manager = new CloudAgentManager() @@ -373,8 +466,7 @@ describe('CloudAgentManager', () => { await manager.attach('project-1', 'cloud-agent-1') - const boxPost = mock.fetchCalls.find((call) => call.init?.method === 'POST') - expect(JSON.parse(String(boxPost?.init?.body))).toEqual({ + expectBoxPostBody({ relayfileMountPaths: ['/integrations/github', '/workspace'], workspaceSource: expect.objectContaining({ kind: 'git-overlay', @@ -428,8 +520,7 @@ describe('CloudAgentManager', () => { await manager.attach('project-1', 'cloud-agent-1') - const boxPost = mock.fetchCalls.find((call) => call.init?.method === 'POST') - expect(JSON.parse(String(boxPost?.init?.body))).toEqual({ + expectBoxPostBody({ relayfileMountPaths: ['/integrations/github', '/workspace'], workspaceSource: { kind: 'git-overlay', @@ -449,8 +540,7 @@ describe('CloudAgentManager', () => { await manager.attach('project-1', 'cloud-agent-1') - const boxPost = mock.fetchCalls.find((call) => call.init?.method === 'POST') - expect(JSON.parse(String(boxPost?.init?.body))).toEqual({ + expectBoxPostBody({ relayfileMountPaths: ['/integrations/github'], workspaceSource: expect.objectContaining({ kind: 'git', @@ -534,4 +624,17 @@ describe('CloudAgentManager', () => { await expect(manager.attach('project-1', 'cloud-agent-1')).rejects.toThrow('broker failed to start') }) + + it('keeps mount-path PATCH bodies scoped to mount paths only', async () => { + mock.brokerManager.workspaceKeyForProject.mockResolvedValueOnce('rk_live_project') + const manager = new CloudAgentManager() + await manager.attach('project-1', 'cloud-agent-1') + mock.fetchCalls.length = 0 + + await manager.updateMountPaths('project-1', ['/integrations/slack']) + + expect(boxRequestBody('PATCH')).toEqual({ + relayfileMountPaths: ['/integrations/slack', '/workspace'] + }) + }) }) diff --git a/src/main/cloud-agent.ts b/src/main/cloud-agent.ts index 7d5f2e1..2f711b7 100644 --- a/src/main/cloud-agent.ts +++ b/src/main/cloud-agent.ts @@ -88,6 +88,7 @@ type CloudBrokerAdapter = { connectCloudSandbox?: (projectId: string, sandbox: CloudAgentSandbox, win?: BrowserWindow) => Promise detachCloudSandbox?: (projectId: string) => Promise onBrokerEvent?: (handler: (projectId: string, event: BrokerEvent) => void) => () => void + workspaceKeyForProject?: (projectId: string) => Promise } type CloudBrokerSystemMessageAdapter = { @@ -469,6 +470,10 @@ export class CloudAgentManager { private appliedConflictPolicies = new Map() private mountRestartPromises = new Map>() private workspaceSources = new Map() + // Relay workspace keys actually sent on POST /box, per project — arms the + // attach-time stale-broker tripwire (#125). Tracked here because the + // sandbox object is replaced by warm-poll GETs between warm and attach. + private sentWorkspaceKeys = new Map() private prewarms = new Map() private canceledAttaches = new Set() private eventHandlers = new Set<(event: CloudAgentEvent) => void>() @@ -658,6 +663,7 @@ export class CloudAgentManager { this.lastSettledAt.delete(normalizedProjectId) this.appliedConflictPolicies.delete(normalizedProjectId) this.workspaceSources.delete(normalizedProjectId) + this.sentWorkspaceKeys.delete(normalizedProjectId) this.persistCloudAgent(normalizedProjectId, null) this.emit({ type: 'mount-status', projectId: normalizedProjectId, mount: toMountStatus(null) }) } @@ -1077,7 +1083,23 @@ export class CloudAgentManager { ? integrationMountPaths : [SANDBOX_WORKSPACE_PATH, ...integrationMountPaths] const url = `${auth.apiUrl}/api/v1/workspaces/${encodeURIComponent(workspaceId)}/cloud-agents/${encodeURIComponent(cloudAgentId)}/box` - let sandbox = await this.fetchBox(url, auth.accessToken, 'POST', mountPaths, workspaceSource) + // #125: the sandbox broker joins the project's local relay workspace + // under a name pear knows ahead of time (cloud injects both verbatim as + // AGENT_RELAY_WORKSPACE_KEY / AGENT_RELAY_BROKER_NAME). The key is + // best-effort: without a local session the sandbox keeps creating its own + // workspace, exactly as before. + const workspaceKey = await (brokerManager as unknown as CloudBrokerAdapter) + .workspaceKeyForProject?.(projectId) + if (workspaceKey) { + this.sentWorkspaceKeys.set(projectId, workspaceKey) + } else { + this.sentWorkspaceKeys.delete(projectId) + } + const relayBroker = { + ...(workspaceKey ? { workspaceKey } : {}), + brokerName: `cloud-${cloudAgentId.slice(0, 8)}` + } + let sandbox = await this.fetchBox(url, auth.accessToken, 'POST', mountPaths, workspaceSource, relayBroker) options.onSandbox?.(sandbox) if (options.isCancelled?.()) { await this.deleteBox(toBinding(projectId, cloudAgentId, sandbox)).catch(() => undefined) @@ -1139,12 +1161,17 @@ export class CloudAgentManager { accessToken: string, method: 'GET' | 'POST', mountPaths?: string[], - workspaceSource?: CloudAgentWorkspaceSource + workspaceSource?: CloudAgentWorkspaceSource, + relayBroker?: { workspaceKey?: string; brokerName?: string } ): Promise { + // Broker identity is provision-time only: POST carries it, PATCH/GET never + // do (cloud preserves the injected env across mount-path rewrites). const body = method === 'POST' ? JSON.stringify({ relayfileMountPaths: normalizeMountPaths(mountPaths || []), - ...(workspaceSource && workspaceSource.kind !== 'relayfile' ? { workspaceSource } : {}) + ...(workspaceSource && workspaceSource.kind !== 'relayfile' ? { workspaceSource } : {}), + ...(relayBroker?.workspaceKey ? { workspaceKey: relayBroker.workspaceKey } : {}), + ...(relayBroker?.brokerName ? { brokerName: relayBroker.brokerName } : {}) }) : undefined const requestUrl = method === 'POST' ? withAsyncWarm(url) : url @@ -1264,7 +1291,13 @@ export class CloudAgentManager { const broker = brokerManager as unknown as CloudBrokerAdapter const attach = broker.attachCloudSandbox || broker.connectCloudSandbox if (attach) { - await attach.call(brokerManager, projectId, sandbox, win) + const sentWorkspaceKey = this.sentWorkspaceKeys.get(projectId) + await attach.call( + brokerManager, + projectId, + sentWorkspaceKey ? { ...sandbox, sentWorkspaceKey } : sandbox, + win + ) return } diff --git a/src/main/integration-event-bridge.ts b/src/main/integration-event-bridge.ts index 95e4206..930e8c8 100644 --- a/src/main/integration-event-bridge.ts +++ b/src/main/integration-event-bridge.ts @@ -2581,7 +2581,17 @@ export class IntegrationEventBridge { : undefined const existing = this.slackLogicalInjections.get(key) if (existing) { - if (!contentHash || !existing.contentHashes || existing.contentHashes.has(contentHash)) { + if (!contentHash) { + return false + } + if (!existing.contentHashes) { + // A blind claim (context read returned nothing) suppresses the late + // content-bearing alias copy, but must learn its hash so a genuine + // edit afterwards still injects instead of matching the blind claim. + existing.contentHashes = new Set([contentHash]) + return false + } + if (existing.contentHashes.has(contentHash)) { return false } existing.contentHashes.add(contentHash)