Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,17 @@ NEXUS_COLLAB_SERVER_PORT=1234
# Public WebSocket URL that browsers use to connect to the collab server.
NEXT_PUBLIC_COLLAB_SERVER_URL=ws://localhost:1234

# ── ACP Bridge permissions ──────────────────────────────────────────────────
# Default permission handling for packages/nexus-acp-bridge.
# "auto" preserves compatibility by selecting an allow option when available.
# "forward" emits permission.requested SSE events and waits for
# POST /session/:id/permission before continuing.
NEXUS_ACP_BRIDGE_PERMISSION_MODE=auto

# Timeout for forwarded bridge permissions in milliseconds. If no response is
# received before the timeout, the request is cancelled.
NEXUS_ACP_BRIDGE_PERMISSION_TIMEOUT_MS=60000

# ── Authentication (OIDC/OAuth2) ─────────────────────────────────────────────
# When all four AUTH_* variables below are set, Nexus requires SSO login.
# When absent, the app is open (no authentication).
Expand Down
12 changes: 7 additions & 5 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,14 +67,16 @@ storage.json
.dev.log
.dev.pid
/run.sh
/.claude/
/temp_examples/
/test-results/
_workspace*
_workspace
/graphify-out
_workspace*

# Local runtime data stores (Brain / collab)
.nexus-brain/
.nexus-collab/
/.nexus-brain/
/.nexus-collab/

# nexus-acp-bridge vendored agent installs
/packages/nexus-acp-bridge/vendor/
/.adw/config.yaml
/.adw/templates/.prefs.json

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions packages/nexus-acp-bridge/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -148,15 +148,16 @@ When `NEXUS_ACP_BRIDGE_ADAPTER=acp`, the bridge:
2. speaks the real [Agent Client Protocol](https://agentclientprotocol.com) over stdio (JSON-RPC 2.0, newline-framed by default, `Content-Length`-framed on request)
3. negotiates via `initialize` with `protocolVersion: 1` and advertises `fs.readTextFile` + `fs.writeTextFile` client capabilities
4. creates an ACP session per bridge session via `session/new`, keyed by the bridge session id
5. streams agent output from `session/update` notifications with the `agent_message_chunk` variant (text content blocks)
6. caches slash-command advertisements from `session/update` notifications with the `available_commands_update` variant and exposes them via `GET /command`
7. sends `session/cancel` when Nexus aborts a prompt
5. streams agent output from `session/update` notifications with the `agent_message_chunk` and `agent_thought_chunk` variants (text content blocks)
6. forwards `tool_call` / `tool_call_update` notifications as OpenCode-compatible `tool.call` / `tool.call.updated` SSE events with the bridge `sessionID`, assistant `messageID`, and ACP call id
7. caches slash-command advertisements from `session/update` notifications with the `available_commands_update` variant and exposes them via `GET /command`
8. sends `session/cancel` when Nexus aborts a prompt

The bridge responds to agent-initiated requests:

- `fs/read_text_file` — reads files inside configured project roots, honoring optional `line` / `limit` parameters
- `fs/write_text_file` — writes files inside configured project roots
- `session/request_permission` — auto-approves with the first `allow_once` / `allow_always` option (or the first option if none are explicitly "allow")
- `session/request_permission` — defaults to `auto`, which preserves compatibility by selecting the first `allow_once` / `allow_always` option (or the first option if none are explicitly "allow"). Set `NEXUS_ACP_BRIDGE_PERMISSION_MODE=forward` or create a session with `{ "permissionMode": "forward" }` to emit `permission.requested` SSE events instead. Callers resolve forwarded requests with `POST /session/:id/permission` and a body such as `{ "requestID": "permission_...", "outcome": "selected", "optionId": "allow_once" }` or `{ "requestID": "permission_...", "outcome": "cancelled" }`. Unanswered forwarded requests cancel after `NEXUS_ACP_BRIDGE_PERMISSION_TIMEOUT_MS` (default `60000`).

Tools, MCP status, provider metadata, and resources are served locally from the bridge's configured defaults — ACP does not expose discovery endpoints for these.
Slash commands are the exception: ACP advertises them dynamically through `session/update`, and the bridge normalizes those into an OpenCode-style `GET /command` response. `POST /session/:id/command` is translated into a slash-command prompt like `/plan add tests` before it is forwarded to ACP.
Expand Down
151 changes: 149 additions & 2 deletions packages/nexus-acp-bridge/src/__tests__/acp-protocol-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
ACPRequestHandler,
ACPSessionUpdateHandler,
} from "../transport/jsonrpc-client";
import type { OpenCodeEvent } from "../types";
import { ACPProtocolAdapter } from "../adapters/acp-protocol";
import { makeBridgeConfig, makeGenerateTextRequest } from "./test-helpers";

Expand All @@ -21,6 +22,8 @@ class FakeACPClient implements ACPJsonRpcClientLike {
private readonly requestHandlers = new Map<string, ACPRequestHandler>();
private nextAcpSessionId = 1;
sessionNewResult: unknown = null;
promptUpdates: unknown[] = [];
promptHook: ((sessionId: string) => Promise<void>) | null = null;

requestHandler: ((method: string, params: unknown) => Promise<unknown>) | null = null;

Expand Down Expand Up @@ -55,6 +58,9 @@ class FakeACPClient implements ACPJsonRpcClientLike {
if (method === "session/prompt") {
const sessionId = (params as { sessionId?: string })?.sessionId;
if (sessionId) {
for (const update of this.promptUpdates) {
queueMicrotask(() => this.emitSessionUpdate(sessionId, update));
}
queueMicrotask(() => this.emitSessionUpdate(sessionId, {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "hello " },
Expand All @@ -63,6 +69,7 @@ class FakeACPClient implements ACPJsonRpcClientLike {
sessionUpdate: "agent_message_chunk",
content: { type: "text", text: "world" },
}));
await this.promptHook?.(sessionId);
}
return { stopReason: "end_turn" } as T;
}
Expand Down Expand Up @@ -109,7 +116,7 @@ class FakeACPClient implements ACPJsonRpcClientLike {
return await handler(params);
}

private emitSessionUpdate(sessionId: string, update: unknown): void {
emitSessionUpdate(sessionId: string, update: unknown): void {
const notification = { jsonrpc: "2.0", method: "session/update", params: { sessionId, update } } as const;
for (const listener of this.notificationListeners) {
listener(notification);
Expand Down Expand Up @@ -272,7 +279,61 @@ describe("ACPProtocolAdapter", () => {
}
});

test("registers fs/read_text_file and session/request_permission handlers", async () => {
test("emits tool call events with bridge session and assistant message IDs", async () => {
const client = new FakeACPClient();
client.promptUpdates = [
{
sessionUpdate: "tool_call",
toolCall: { callId: "call-1", title: "Read file", kind: "read", input: { path: "README.md" } },
status: "running",
},
{
sessionUpdate: "tool_call_update",
toolCall: { callId: "call-1", title: "Read file", kind: "read", output: { lines: 3 } },
status: "completed",
},
];
const adapter = new ACPProtocolAdapter(makeBridgeConfig({ adapterMode: "acp" }), client);
const events: unknown[] = [];

try {
const request = {
...makeGenerateTextRequest(),
assistantMessageID: "assistant-123",
publishEvent: (event: unknown) => events.push(event),
};
for await (const _ of adapter.generateText(request)) { /* drain */ }

expect(events).toContainEqual({
type: "tool.call",
properties: {
sessionID: "session-1",
messageID: "assistant-123",
callID: "call-1",
title: "Read file",
kind: "read",
rawInput: { path: "README.md" },
status: "running",
},
});
expect(events).toContainEqual({
type: "tool.call.updated",
properties: {
sessionID: "session-1",
messageID: "assistant-123",
callID: "call-1",
title: "Read file",
kind: "read",
status: "completed",
rawOutput: { lines: 3 },
},
});
} finally {
await adapter.dispose();
}
});

test("registers fs/read_text_file and keeps auto permission approval by default", async () => {
const client = new FakeACPClient();
const adapter = new ACPProtocolAdapter(
makeBridgeConfig({ adapterMode: "acp", projectDirs: [process.cwd()] }),
Expand All @@ -299,6 +360,92 @@ describe("ACPProtocolAdapter", () => {
await adapter.dispose();
}
});

test("forwards permission requests and resolves selected/cancelled responses", async () => {
const client = new FakeACPClient();
const adapter = new ACPProtocolAdapter(
makeBridgeConfig({ adapterMode: "acp", permissionTimeoutMs: 1_000 }),
client,
);
const events: OpenCodeEvent[] = [];

client.promptHook = async (sessionId) => {
const pending = client.invokeRequestHandler("session/request_permission", {
sessionId,
toolCall: { title: "Write file", kind: "write" },
options: [{ optionId: "allow_once", name: "Allow once", kind: "allow_once" }],
});
await new Promise((resolve) => setTimeout(resolve, 0));
const requestID = events.find((event) => event.type === "permission.requested")?.properties?.requestID;
expect(requestID).toBeString();
const resolved = await adapter.respondToPermission({
sessionID: "session-1",
requestID: requestID as string,
outcome: { outcome: "selected", optionId: "allow_once" },
});
expect(resolved).toBe(true);
expect(await pending).toEqual({ outcome: { outcome: "selected", optionId: "allow_once" } });

const cancelPending = client.invokeRequestHandler("session/request_permission", {
sessionId,
toolCall: { title: "Run command" },
options: [{ optionId: "reject_once", name: "Reject", kind: "reject_once" }],
});
await new Promise((resolve) => setTimeout(resolve, 0));
const cancelID = events.filter((event) => event.type === "permission.requested").at(-1)?.properties.requestID;
expect(cancelID).toBeString();
expect(await adapter.respondToPermission({
sessionID: "session-1",
requestID: cancelID as string,
outcome: { outcome: "cancelled" },
})).toBe(true);
expect(await cancelPending).toEqual({ outcome: { outcome: "cancelled" } });
};

try {
const request = {
...makeGenerateTextRequest(),
permissionMode: "forward" as const,
publishEvent: (event: OpenCodeEvent) => { events.push(event); },
};
for await (const _ of adapter.generateText(request)) { /* drain */ }

expect(events[0]).toMatchObject({
type: "permission.requested",
properties: {
sessionID: "session-1",
toolCall: { title: "Write file", kind: "write" },
options: [{ optionId: "allow_once", name: "Allow once", kind: "allow_once" }],
},
});
} finally {
await adapter.dispose();
}
});

test("forwarded permissions timeout to cancelled", async () => {
const client = new FakeACPClient();
const adapter = new ACPProtocolAdapter(
makeBridgeConfig({ adapterMode: "acp", permissionTimeoutMs: 5 }),
client,
);
let permissionResult: unknown;

client.promptHook = async (sessionId) => {
permissionResult = await client.invokeRequestHandler("session/request_permission", {
sessionId,
toolCall: { title: "Dangerous" },
options: [{ optionId: "allow_once", name: "Allow", kind: "allow_once" }],
});
};

try {
for await (const _ of adapter.generateText({ ...makeGenerateTextRequest(), permissionMode: "forward" })) { /* drain */ }
expect(permissionResult).toEqual({ outcome: { outcome: "cancelled" } });
} finally {
await adapter.dispose();
}
});
});


Expand Down
28 changes: 28 additions & 0 deletions packages/nexus-acp-bridge/src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,34 @@ describe("bridge config", () => {
expect(config.serverIdleTimeoutSeconds).toBe(45);
});

test("defaults permission handling to auto with a 60 second timeout", () => {
delete process.env.NEXUS_ACP_BRIDGE_PERMISSION_MODE;
delete process.env.NEXUS_ACP_BRIDGE_PERMISSION_TIMEOUT_MS;

const config = loadBridgeConfig([]);

expect(config.permissionMode).toBe("auto");
expect(config.permissionTimeoutMs).toBe(60_000);
});

test("parses permission mode and timeout from env", () => {
process.env.NEXUS_ACP_BRIDGE_PERMISSION_MODE = "forward";
process.env.NEXUS_ACP_BRIDGE_PERMISSION_TIMEOUT_MS = "1234";

const config = loadBridgeConfig([]);

expect(config.permissionMode).toBe("forward");
expect(config.permissionTimeoutMs).toBe(1234);
});

test("CLI permission mode overrides env and invalid values fall back to auto", () => {
process.env.NEXUS_ACP_BRIDGE_PERMISSION_MODE = "invalid";
expect(loadBridgeConfig([]).permissionMode).toBe("auto");

const config = loadBridgeConfig(["--permission-mode=forward"]);
expect(config.permissionMode).toBe("forward");
});

test("explicit environment variables override bundled defaults", () => {
process.env.NEXUS_ACP_BRIDGE_ADAPTER = "stdio";
process.env.NEXUS_ACP_BRIDGE_AGENT_COMMAND = "custom-agent";
Expand Down
Loading