Skip to content

Commit 0a62b11

Browse files
committed
fix(acp,terminal): support xdg-open url bridging and ACP auth fallbacks
1 parent b16ce68 commit 0a62b11

File tree

2 files changed

+248
-5
lines changed

2 files changed

+248
-5
lines changed

src/lib/acp/client.ts

Lines changed: 66 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import {
22
type Client as ACPProtocolClient,
33
type AgentCapabilities,
4+
type AuthenticateResponse,
5+
type AuthMethod,
46
type ClientCapabilities,
57
ClientSideConnection,
68
type ContentBlock,
@@ -46,19 +48,28 @@ const ACP_METHODS = {
4648
TERMINAL_KILL: "terminal/kill",
4749
} as const;
4850

51+
const AUTH_REQUIRED_CODE = -32000;
52+
4953
type ClientEventType =
5054
| "state_change"
5155
| "session_update"
5256
| "session_controls_update"
5357
| "error"
54-
| "permission_request";
58+
| "permission_request"
59+
| "ext_notification"
60+
| "ext_request";
5561

5662
type ClientEventHandler = (data: unknown) => void;
5763

5864
type PendingPermissionRequest = {
5965
resolve: (response: PermissionResponse) => void;
6066
};
6167

68+
type ExtensionRequestHandler = (
69+
method: string,
70+
params: Record<string, unknown>,
71+
) => Promise<Record<string, unknown>> | Record<string, unknown>;
72+
6273
export interface StartSessionOptions {
6374
url: string;
6475
cwd?: string;
@@ -80,10 +91,12 @@ export class ACPClient {
8091
PendingPermissionRequest
8192
>();
8293
private nextPermissionRequestId = 1;
94+
private extensionRequestHandler: ExtensionRequestHandler | null = null;
8395

8496
private _state: ConnectionState = ConnectionState.DISCONNECTED;
8597
private _agentCapabilities: AgentCapabilities | null = null;
8698
private _agentInfo: Implementation | null = null;
99+
private _authMethods: AuthMethod[] = [];
87100
private _session: ACPSession | null = null;
88101
private _sessionModes: SessionModeState | null = null;
89102
private _sessionModels: SessionModelState | null = null;
@@ -101,6 +114,10 @@ export class ACPClient {
101114
return this._agentInfo;
102115
}
103116

117+
get authMethods(): AuthMethod[] {
118+
return this._authMethods;
119+
}
120+
104121
get session(): ACPSession | null {
105122
return this._session;
106123
}
@@ -158,6 +175,10 @@ export class ACPClient {
158175
this.requestHandlers.set(method, handler);
159176
}
160177

178+
setExtensionRequestHandler(handler: ExtensionRequestHandler | null): void {
179+
this.extensionRequestHandler = handler;
180+
}
181+
161182
async connect(config: TransportConfig): Promise<void> {
162183
if (
163184
this._state !== ConnectionState.DISCONNECTED &&
@@ -225,6 +246,9 @@ export class ACPClient {
225246

226247
this._agentCapabilities = result.agentCapabilities ?? null;
227248
this._agentInfo = result.agentInfo ?? null;
249+
this._authMethods = Array.isArray(result.authMethods)
250+
? result.authMethods
251+
: [];
228252
this.setState(ConnectionState.READY);
229253
return result;
230254
} catch (error) {
@@ -411,6 +435,30 @@ export class ACPClient {
411435
this.updateSessionConfigOptionValue(configId, value);
412436
}
413437

438+
async authenticate(methodId: string): Promise<AuthenticateResponse> {
439+
this.ensureReady();
440+
const connection = this.getConnection();
441+
const result = await this.runWhileConnected(
442+
connection.authenticate({ methodId }),
443+
);
444+
return result as AuthenticateResponse;
445+
}
446+
447+
static isAuthRequiredError(error: unknown): boolean {
448+
if (error instanceof RequestError && error.code === AUTH_REQUIRED_CODE) {
449+
return true;
450+
}
451+
const asAny = error as { code?: number; message?: string };
452+
if (asAny?.code === AUTH_REQUIRED_CODE) return true;
453+
if (
454+
typeof asAny?.message === "string" &&
455+
/auth.?required/i.test(asAny.message)
456+
) {
457+
return true;
458+
}
459+
return false;
460+
}
461+
414462
cancel(): void {
415463
if (!this._session || !this.connection) return;
416464

@@ -472,8 +520,22 @@ export class ACPClient {
472520
this.callRegisteredRequest(ACP_METHODS.TERMINAL_WAIT_FOR_EXIT, params),
473521
killTerminal: async (params) =>
474522
this.callRegisteredRequest(ACP_METHODS.TERMINAL_KILL, params),
475-
extMethod: async (method, params) =>
476-
this.callRegisteredRequest(method, params),
523+
extMethod: async (method, params) => {
524+
if (this.requestHandlers.has(method)) {
525+
return await this.callRegisteredRequest(method, params);
526+
}
527+
this.emit("ext_request", { method, params });
528+
if (this.extensionRequestHandler) {
529+
return await this.extensionRequestHandler(
530+
method,
531+
(params as Record<string, unknown>) || {},
532+
);
533+
}
534+
throw RequestError.methodNotFound(method);
535+
},
536+
extNotification: async (method, params) => {
537+
this.emit("ext_notification", { method, params });
538+
},
477539
};
478540
}
479541

@@ -721,6 +783,7 @@ export class ACPClient {
721783
this._session = null;
722784
this._agentCapabilities = null;
723785
this._agentInfo = null;
786+
this._authMethods = [];
724787
this.clearSessionConfigurationState();
725788
}
726789

src/pages/acp/acp.js

Lines changed: 182 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,15 @@ export default function AcpPageInclude() {
7474
setFormStatus("Connecting...");
7575
await ensureReadyForUrl(url);
7676
setFormStatus("Starting session...");
77-
await client.newSession(nextCwd || undefined);
77+
78+
try {
79+
await client.newSession(nextCwd || undefined);
80+
} catch (sessionErr) {
81+
if (!ACPClient.isAuthRequiredError(sessionErr)) throw sessionErr;
82+
await handleAuthentication();
83+
await client.newSession(nextCwd || undefined);
84+
}
85+
7886
currentSessionUrl = url;
7987
switchToChat(client.agentName);
8088
saveCurrentSessionHistory();
@@ -85,6 +93,120 @@ export default function AcpPageInclude() {
8593
}
8694
}
8795

96+
async function handleAuthentication() {
97+
const methods = client.authMethods;
98+
if (!methods.length) {
99+
throw new Error(
100+
"Agent requires authentication but did not advertise any auth methods.",
101+
);
102+
}
103+
104+
let selectedMethod = methods[0];
105+
106+
if (methods.length > 1) {
107+
setFormStatus("Authentication required — choose a method");
108+
const picked = await select(
109+
"Authentication Required",
110+
methods.map((m) => ({
111+
value: m.id,
112+
text: m.name + (m.description ? ` — ${m.description}` : ""),
113+
icon: "vpn_key",
114+
})),
115+
{ textTransform: false },
116+
);
117+
if (!picked) throw new Error("Authentication cancelled");
118+
selectedMethod = methods.find((m) => m.id === picked) || methods[0];
119+
}
120+
121+
let browserOpened = false;
122+
const maybeOpenAuthUrl = (value) => {
123+
const url = extractExternalUrl(value);
124+
if (!url || browserOpened) return false;
125+
browserOpened = true;
126+
system.openInBrowser(url);
127+
setFormStatus("Complete sign-in in your browser…");
128+
return true;
129+
};
130+
131+
const handleExtNotification = (event) => {
132+
maybeOpenAuthUrl(event);
133+
};
134+
const handleExtRequest = (event) => {
135+
maybeOpenAuthUrl(event);
136+
};
137+
const handleAuthExtRequest = async (method, params = {}) => {
138+
const didOpen = maybeOpenAuthUrl({ method, params });
139+
if (!didOpen) {
140+
throw RequestError.methodNotFound(method);
141+
}
142+
return {
143+
ok: true,
144+
handled: true,
145+
opened: true,
146+
};
147+
};
148+
149+
const { alpineRoot } = getTerminalPaths();
150+
const urlTempPath = `file://${alpineRoot}/tmp/.acode_open_url`;
151+
try {
152+
await fsOperation(urlTempPath).delete();
153+
} catch {
154+
/* ignore */
155+
}
156+
157+
setFormStatus(`Authenticating via ${selectedMethod.name}…`);
158+
159+
client.setExtensionRequestHandler(handleAuthExtRequest);
160+
client.on("ext_request", handleExtRequest);
161+
client.on("ext_notification", handleExtNotification);
162+
const stopPolling = startAuthUrlPolling(urlTempPath);
163+
try {
164+
const response = await client.authenticate(selectedMethod.id);
165+
maybeOpenAuthUrl(response);
166+
} finally {
167+
stopPolling();
168+
client.setExtensionRequestHandler(null);
169+
client.off("ext_request", handleExtRequest);
170+
client.off("ext_notification", handleExtNotification);
171+
}
172+
173+
setFormStatus("Authenticated — starting session…");
174+
}
175+
176+
function startAuthUrlPolling(urlFilePath) {
177+
let stopped = false;
178+
let opened = false;
179+
180+
const poll = async () => {
181+
while (!stopped) {
182+
await new Promise((resolve) => setTimeout(resolve, 400));
183+
if (stopped) break;
184+
try {
185+
const raw = await fsOperation(urlFilePath).readFile("utf8");
186+
const url = String(raw || "").trim();
187+
if (url && !opened) {
188+
opened = true;
189+
system.openInBrowser(url);
190+
setFormStatus("Complete sign-in in your browser…");
191+
try {
192+
await fsOperation(urlFilePath).delete();
193+
} catch {
194+
/* ignore */
195+
}
196+
}
197+
} catch {
198+
/* file doesn't exist yet */
199+
}
200+
}
201+
};
202+
203+
void poll();
204+
205+
return () => {
206+
stopped = true;
207+
};
208+
}
209+
88210
function getTerminalPaths() {
89211
const packageName = window.BuildInfo?.packageName || "com.foxdebug.acode";
90212
const dataDir = `/data/user/0/${packageName}`;
@@ -95,6 +217,58 @@ export default function AcpPageInclude() {
95217
};
96218
}
97219

220+
function extractExternalUrl(value, visited = new Set()) {
221+
if (typeof value === "string") {
222+
const trimmed = value.trim();
223+
return /^(https?|ftps?|mailto|tel|sms|geo):/i.test(trimmed)
224+
? trimmed
225+
: "";
226+
}
227+
228+
if (!value || typeof value !== "object") return "";
229+
if (visited.has(value)) return "";
230+
visited.add(value);
231+
232+
if (Array.isArray(value)) {
233+
for (const entry of value) {
234+
const nestedUrl = extractExternalUrl(entry, visited);
235+
if (nestedUrl) return nestedUrl;
236+
}
237+
return "";
238+
}
239+
240+
const prioritizedKeys = [
241+
"url",
242+
"uri",
243+
"href",
244+
"openUrl",
245+
"open_url",
246+
"browserUrl",
247+
"browser_url",
248+
"verificationUri",
249+
"verification_uri",
250+
"verificationUrl",
251+
"verification_url",
252+
"authorizationUrl",
253+
"authorization_url",
254+
"authorizeUrl",
255+
"authorize_url",
256+
];
257+
258+
for (const key of prioritizedKeys) {
259+
if (!(key in value)) continue;
260+
const nestedUrl = extractExternalUrl(value[key], visited);
261+
if (nestedUrl) return nestedUrl;
262+
}
263+
264+
for (const nestedValue of Object.values(value)) {
265+
const nestedUrl = extractExternalUrl(nestedValue, visited);
266+
if (nestedUrl) return nestedUrl;
267+
}
268+
269+
return "";
270+
}
271+
98272
function normalizePathInput(value = "") {
99273
return String(value || "")
100274
.trim()
@@ -1455,7 +1629,13 @@ export default function AcpPageInclude() {
14551629
switchToChat(entry.agentName || client.agentName);
14561630
updateStatusDot("connecting");
14571631

1458-
await client.loadSession(entry.sessionId, cwd);
1632+
try {
1633+
await client.loadSession(entry.sessionId, cwd);
1634+
} catch (loadErr) {
1635+
if (!ACPClient.isAuthRequiredError(loadErr)) throw loadErr;
1636+
await handleAuthentication();
1637+
await client.loadSession(entry.sessionId, cwd);
1638+
}
14591639
client.session?.finishAgentTurn();
14601640
currentSessionUrl = entry.url;
14611641
setChatAgentName(entry.agentName || client.agentName);

0 commit comments

Comments
 (0)