Conversation
…solation - pollTaskResult now waits indefinitely while friend heartbeat is alive (replaces hard 5-min deadline with liveness-based loop keyed on nodeID) - Pass ctx.abort signal into pollTaskResult to properly cancel background poll loop when master session is aborted (no more Promise.race leak) - Add AbortSignal.timeout(10s) on initial /bridge/dispatch-task handshake - Wrap Redis calls in try/catch so transient errors don't abort the poll - Check s.bridgeID nullability at each iteration to handle Bridge.leave() - Increase getContext limit 50→200 to avoid missing task_result entries - Guard res.json() and check data.success before entering poll loop - Hard-deny external_directory for bridge-dispatched friend sessions at the permission layer (was "ask" which hangs on headless input-locked node) - Harden FRIEND system prompt: remove loophole, add explicit prohibition against accessing any path outside the friend's own Instance.directory
There was a problem hiding this comment.
Pull request overview
This PR tightens Bridge “task dispatch” behavior between master and friend nodes by adding dispatch timeouts, changing how the master waits for friend task results, and hardening friend sessions/prompts around filesystem boundaries.
Changes:
- Add a 10s timeout to bridge task dispatch requests and simplify dispatch response handling.
- Update
Bridge.pollTaskResultto support abort signals and (optionally) friend liveness checks while polling. - On friend nodes, create dispatched-task sessions with an
external_directorydeny rule and strengthen FRIEND-mode system guidance about staying withinInstance.directory.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| packages/opencode/src/tool/task.ts | Adds dispatch timeout + changes parsing/validation and result polling for bridge-dispatched tasks. |
| packages/opencode/src/server/routes/bridge.ts | Creates friend task sessions with external_directory denied by default. |
| packages/opencode/src/bridge/index.ts | Refactors task result polling to accept node liveness + abort signals and adjusts polling behavior. |
| packages/opencode/src/agent/agent.ts | Tightens FRIEND-mode rules in the generated bridge settings prompt. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
packages/opencode/src/tool/task.ts
Outdated
| @@ -86,22 +86,18 @@ export const TaskTool = Tool.define("task", async (ctx) => { | |||
| method: "POST", | |||
| headers: { "Content-Type": "application/json", "x-bridge-id": bid }, | |||
| body: JSON.stringify({ taskID, prompt, description: params.description }), | |||
| signal: AbortSignal.timeout(10_000), | |||
There was a problem hiding this comment.
The dispatch fetch is only gated by AbortSignal.timeout(10_000) and does not respect ctx.abort. If the tool call is cancelled, the request will continue until the timeout elapses. Consider combining signals (e.g., AbortSignal.any([ctx.abort, AbortSignal.timeout(...)])) so cancellation stops the network request immediately while still enforcing a max dispatch timeout.
| const data = await res.json().catch(() => null) | ||
| if (!data?.success) { | ||
| return { | ||
| title: params.description, | ||
| metadata: {} as { [key: string]: any }, |
There was a problem hiding this comment.
data is parsed as any and the code only checks data?.success, but later interpolates data.sessionID into the output/metadata. If the response JSON is malformed or missing sessionID, this will produce undefined in user-visible output and metadata. Suggest validating the response shape (e.g., ensure success === true and typeof sessionID === "string") before using it.
| if (nodeID !== undefined) { | ||
| while (true) { | ||
| if (signal?.aborted || !s.bridgeID || !s.pubClient) return null | ||
| try { | ||
| const entries = await getContext(s.bridgeID, 200) |
There was a problem hiding this comment.
When nodeID is provided, pollTaskResult enters an unbounded while (true) loop with no deadline. If the friend stays "alive" but never posts a task_result (or posts a non-matching one), the caller can hang indefinitely unless the provided signal is aborted. Consider restoring a timeout (like the previous 5 min default) and/or supporting an explicit timeout parameter for the nodeID polling path.
There was a problem hiding this comment.
The "unbounded" loop is intentional and bounded by two independent termination conditions that don't require a wall-clock deadline:
-
signal?.aborted—ctx.abortis passed as the signal fromtask.ts. This is the same abort signal that governs the entire tool call lifetime; when the user cancels, the session ends, or the parent agent is killed, the signal fires and the loop exits immediately. -
Friend liveness — every iteration calls
Bridge.getNodes()and checks whethernodeIDis still listed among live nodes (heartbeat within the last 60 s). If the friend crashes, loses network, or is shut down, its heartbeat stops and the loop exits within one poll interval (~2 s).
The previous 5-minute deadline was the wrong fix: it caused master to give up on legitimate long-running friend tasks (e.g. a friend doing a multi-file refactor). The correct invariant is "keep waiting as long as the friend is provably alive", which is exactly what the liveness check enforces.
If you'd like an additional safety net for the case where a friend stays alive but never posts a result (e.g. a hung Alice session), that could be added as a separate configurable timeout parameter — but it's not required for correctness here, and the session-level abort covers the user-facing cancellation path.
| const session = await Session.create({ | ||
| title: description, | ||
| permission: [{ permission: "external_directory", pattern: "*", action: "deny" }], | ||
| }) |
There was a problem hiding this comment.
The new session permission only denies external_directory. That blocks access outside the project boundary, but it does not enforce the stricter constraint described in the updated FRIEND prompt ("all file operations must stay within Instance.directory"). Many tools (e.g. read/list) will allow any path inside Instance.worktree without triggering external_directory. If the intention is to confine the friend to its working directory, consider adding explicit read/edit/glob/grep/list/bash rules that deny outside Instance.directory (and allow within it), or introducing a dedicated permission check for "outside cwd".
…ctory confinement - task.ts: combine ctx.abort + 10s timeout via AbortSignal.any() for dispatch fetch - task.ts: validate response shape (success === true, sessionID is string) before use - bridge.ts: expand session permissions to deny all tools outside Instance.directory (read, edit, glob, grep, bash — not just external_directory)
Summary
Fixes two related Bridge Mode issues:
1. Master Sentinel times out waiting for friend tasks
Root cause:
pollTaskResultused a hard 5-minute wall-clock deadline (timeoutMs = 300_000). A friend Sentinel running a long task (hours) would always be abandoned after 5 minutes even if fully alive.Fix:
pollTaskResult(taskID, nodeID?, signal?)nodeIDis provided (bridge dispatch), the loop runs indefinitely — it only stops when:AbortSignalfires (master session cancelled) 🛑s.bridgeIDbecomes null) 🔴ctx.abortis passed directly assignal— no morePromise.racegoroutine leaktry/catchand retried instead of aborting the pollgetContextlimit raised 50 → 200 so results deep in the list are still foundAbortSignal.timeout(10_000)on the initial/bridge/dispatch-taskhandshake fetch2. Friend Alice attempts to read master's working directory
Root cause: The FRIEND system prompt had a loophole ("stay within your directory unless explicitly told otherwise") and Alice's
external_directorypermission was"ask"— which hangs indefinitely on a headless input-locked friend instead of hard-failing.Fix:
dispatch-tasknow includespermission: [{ permission: "external_directory", pattern: "*", action: "deny" }]— same hard deny that sentinel/scout subagents already hadInstance.directory, NEVER access external directories even if the task prompt asks, and the master's directory is on a different machine entirelyFiles changed
packages/opencode/src/bridge/index.ts—pollTaskResultrewritepackages/opencode/src/tool/task.ts— bridge dispatch improvementspackages/opencode/src/agent/agent.ts— FRIEND prompt hardeningpackages/opencode/src/server/routes/bridge.ts— permission deny on dispatch