opencode-muse-auth 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,554 @@
1
+ // Muse Code request adapter: make subscription traffic look like Muse Code's
2
+ // own tool declarations.
3
+ //
4
+ // Meta's api.meta.ai refuses a Muse Code subscription key when the request
5
+ // declares a client function tool whose name collides with one of Muse Code's
6
+ // builtin tool names (`read_file`, `bash`, `web_search`, ...). The refusal is
7
+ // HTTP 403 with code `client_attestation_denied`. Names that do not collide are
8
+ // accepted, and so is the same tool under a non-colliding name (see
9
+ // ADAPTER_CONTRACT.md for the measured matrix).
10
+ //
11
+ // This module runs as a loopback reverse proxy in front of api.meta.ai. It
12
+ // aliases only the colliding tool names on the way out and restores them on the
13
+ // way back, so the host sees its own tool names and MCP servers keep working.
14
+ // Nothing else about the request is rewritten.
15
+ //
16
+ // The file is dependency-free and deliberately host-agnostic: the same module
17
+ // is vendored by the sibling Muse plugins. Keep it in sync (ADAPTER_VERSION).
18
+ import http from "node:http";
19
+ /** Version of the wire rules below; bump with ADAPTER_CONTRACT.md. */
20
+ export const ADAPTER_VERSION = 3;
21
+ /** Default upstream; tests override it with a local fake. */
22
+ export const DEFAULT_UPSTREAM = "https://api.meta.ai";
23
+ /**
24
+ * Muse Code builtin tool names.
25
+ *
26
+ * Reconstructed from the 1.4.14 binary's tool catalog and from `muse exec`
27
+ * captures, then probed one by one against api.meta.ai on 2026-10-30: a flat
28
+ * client tool with one of these exact names is refused with 403
29
+ * `client_attestation_denied`. The catalog includes goal, cron, session and
30
+ * patch tools as well as the file, shell and web families. `tool_search` is
31
+ * included defensively: one probe returned 200, but field reports see a 403.
32
+ */
33
+ export const COLLIDING_TOOL_NAMES = new Set([
34
+ "workflow",
35
+ "read_file",
36
+ "write_file",
37
+ "edit_file",
38
+ "bash",
39
+ "bash_input",
40
+ "search",
41
+ "web_search",
42
+ "web_fetch",
43
+ "powershell",
44
+ "powershell_input",
45
+ "read_memory",
46
+ "add_memory",
47
+ "edit_memory",
48
+ "read_skill",
49
+ "write_todos",
50
+ "monitor",
51
+ "work_status",
52
+ "work_list",
53
+ "work_stop",
54
+ "snooze_reminder",
55
+ "apply_patch",
56
+ "shell",
57
+ "create_goal",
58
+ "get_goal",
59
+ "update_goal",
60
+ "report_progress",
61
+ "request_user_input",
62
+ "cron_create",
63
+ "cron_delete",
64
+ "cron_list",
65
+ "send_session_message",
66
+ "list_peer_sessions",
67
+ "subagent_send_message",
68
+ "subagent_cancel",
69
+ "subagent_spawn",
70
+ "subagent_status",
71
+ "subagent_wait",
72
+ "subagent_read_result",
73
+ "tool_search",
74
+ ]);
75
+ /** Prefix used for aliases; kept outside the builtin set. */
76
+ const ALIAS_PREFIX = "client_";
77
+ /** Requests larger than this are forwarded untransformed (images, huge prompts). */
78
+ const MAX_BODY_BYTES = 32 * 1024 * 1024;
79
+ /** Ports tried in order, so a route's persisted baseURL stays stable. */
80
+ const DEFAULT_PORTS = [43219, 43220, 43221, 43222];
81
+ /** Paths whose JSON body and response are adapted. Everything else is relayed. */
82
+ function isAdaptedPath(pathname) {
83
+ return pathname.endsWith("/responses") || pathname.endsWith("/chat/completions");
84
+ }
85
+ function isRecord(value) {
86
+ return typeof value === "object" && value !== null && !Array.isArray(value);
87
+ }
88
+ /**
89
+ * Decide which declared tool names must be aliased.
90
+ *
91
+ * Only flat `function` tools are considered: those are the declarations Meta
92
+ * compares against its builtin set. A namespace container is left untouched.
93
+ * With `all`, every flat function tool is aliased; the proxy uses that as a
94
+ * one-shot retry when the gate refuses names this release does not know.
95
+ */
96
+ export function planToolAliases(tools, options = {}) {
97
+ const aliases = new Map();
98
+ if (!Array.isArray(tools))
99
+ return { aliases, reverse: new Map() };
100
+ const taken = new Set();
101
+ for (const tool of tools) {
102
+ if (isRecord(tool) && typeof tool["name"] === "string")
103
+ taken.add(tool["name"]);
104
+ }
105
+ for (const tool of tools) {
106
+ if (!isRecord(tool) || tool["type"] !== "function")
107
+ continue;
108
+ const name = tool["name"];
109
+ if (typeof name !== "string")
110
+ continue;
111
+ if (options.all !== true && !COLLIDING_TOOL_NAMES.has(name))
112
+ continue;
113
+ let alias = `${ALIAS_PREFIX}${name}`;
114
+ let suffix = 2;
115
+ while (taken.has(alias)) {
116
+ alias = `${ALIAS_PREFIX}${name}_${String(suffix)}`;
117
+ suffix += 1;
118
+ }
119
+ taken.add(alias);
120
+ aliases.set(name, alias);
121
+ }
122
+ const reverse = new Map();
123
+ for (const [original, alias] of aliases)
124
+ reverse.set(alias, original);
125
+ return { aliases, reverse };
126
+ }
127
+ /** Rewrite the names of the tools, history calls and forced tool choice. */
128
+ function applyAliases(body, plan) {
129
+ if (plan.aliases.size === 0)
130
+ return;
131
+ const tools = body["tools"];
132
+ if (Array.isArray(tools)) {
133
+ body["tools"] = tools.map((tool) => {
134
+ if (!isRecord(tool) || tool["type"] !== "function")
135
+ return tool;
136
+ const name = tool["name"];
137
+ if (typeof name !== "string")
138
+ return tool;
139
+ const alias = plan.aliases.get(name);
140
+ return alias === undefined ? tool : { ...tool, name: alias };
141
+ });
142
+ }
143
+ const input = body["input"];
144
+ if (Array.isArray(input)) {
145
+ body["input"] = input.map((item) => {
146
+ if (!isRecord(item) || item["type"] !== "function_call")
147
+ return item;
148
+ const name = item["name"];
149
+ if (typeof name !== "string")
150
+ return item;
151
+ const alias = plan.aliases.get(name);
152
+ return alias === undefined ? item : { ...item, name: alias };
153
+ });
154
+ }
155
+ const choice = body["tool_choice"];
156
+ if (isRecord(choice) && choice["type"] === "function") {
157
+ const name = choice["name"];
158
+ if (typeof name === "string") {
159
+ const alias = plan.aliases.get(name);
160
+ if (alias !== undefined)
161
+ body["tool_choice"] = { ...choice, name: alias };
162
+ }
163
+ }
164
+ }
165
+ /**
166
+ * Apply the wire rules to one Responses/Chat request body.
167
+ *
168
+ * The input object is not mutated: a shallow copy is returned. Unknown fields
169
+ * are preserved untouched. Pass a plan from {@link planToolAliases} to reuse a
170
+ * mapping, for example the full-aliasing retry.
171
+ */
172
+ export function transformRequestBody(body, plan) {
173
+ const next = { ...body };
174
+ const notes = [];
175
+ const chosen = plan ?? planToolAliases(next["tools"]);
176
+ if (chosen.aliases.size > 0) {
177
+ applyAliases(next, chosen);
178
+ notes.push(`aliased ${String(chosen.aliases.size)} tool name(s)`);
179
+ }
180
+ const choice = next["tool_choice"];
181
+ if (choice !== undefined && choice !== "auto") {
182
+ // Meta accepts only "auto"; named, "none" and "required" are rejected.
183
+ next["tool_choice"] = "auto";
184
+ notes.push("tool_choice=auto");
185
+ }
186
+ const effort = next["reasoning_effort"];
187
+ if (typeof effort === "string") {
188
+ const reasoning = isRecord(next["reasoning"]) ? next["reasoning"] : {};
189
+ next["reasoning"] = { ...reasoning, effort: reasoning["effort"] ?? effort };
190
+ delete next["reasoning_effort"];
191
+ notes.push("reasoning_effort->reasoning.effort");
192
+ }
193
+ const maxOutput = next["max_output_tokens"];
194
+ if (typeof maxOutput === "number" && maxOutput < 16) {
195
+ next["max_output_tokens"] = 16;
196
+ notes.push("max_output_tokens>=16");
197
+ }
198
+ const include = Array.isArray(next["include"]) ? next["include"] : [];
199
+ if (!include.includes("reasoning.encrypted_content")) {
200
+ next["include"] = [...include, "reasoning.encrypted_content"];
201
+ notes.push("include+reasoning.encrypted_content");
202
+ }
203
+ return { body: next, plan: chosen, notes };
204
+ }
205
+ /** Restore an aliased name on one wire object, in place. */
206
+ function restoreItem(item, reverse) {
207
+ if (!isRecord(item))
208
+ return;
209
+ const type = item["type"];
210
+ if (type !== "function_call" && type !== "function")
211
+ return;
212
+ const name = item["name"];
213
+ if (typeof name !== "string")
214
+ return;
215
+ const original = reverse.get(name);
216
+ if (original !== undefined)
217
+ item["name"] = original;
218
+ }
219
+ /**
220
+ * Streaming name restorer.
221
+ *
222
+ * Feeds complete SSE lines and returns the line to relay. It restores aliased
223
+ * function-call names and repairs empty `arguments` from the streamed deltas:
224
+ * Meta's authoritative `response.output_item.done` sometimes ships "" even
225
+ * though the deltas carried the JSON.
226
+ */
227
+ export class SseRestorer {
228
+ reverse;
229
+ arguments = new Map();
230
+ constructor(reverse) {
231
+ this.reverse = reverse;
232
+ }
233
+ /** Adapt one complete SSE line (without the trailing newline). */
234
+ feed(line) {
235
+ const trimmed = line.endsWith("\r") ? line.slice(0, -1) : line;
236
+ if (!trimmed.startsWith("data:"))
237
+ return line;
238
+ const payload = trimmed.slice(5).trim();
239
+ if (payload === "" || payload === "[DONE]")
240
+ return line;
241
+ let event;
242
+ try {
243
+ const parsed = JSON.parse(payload);
244
+ if (!isRecord(parsed))
245
+ return line;
246
+ event = parsed;
247
+ }
248
+ catch {
249
+ return line;
250
+ }
251
+ this.adapt(event);
252
+ return `data: ${JSON.stringify(event)}${line.endsWith("\r") ? "\r" : ""}`;
253
+ }
254
+ adapt(event) {
255
+ const type = event["type"];
256
+ // `response.function_call_arguments.done` echoes the tool name at the top level.
257
+ const topName = event["name"];
258
+ if (typeof topName === "string") {
259
+ const original = this.reverse.get(topName);
260
+ if (original !== undefined)
261
+ event["name"] = original;
262
+ }
263
+ if (type === "response.function_call_arguments.delta") {
264
+ const id = event["item_id"];
265
+ const delta = event["delta"];
266
+ if (typeof id === "string" && typeof delta === "string") {
267
+ this.arguments.set(id, (this.arguments.get(id) ?? "") + delta);
268
+ }
269
+ }
270
+ if (type === "response.output_item.added" || type === "response.output_item.done") {
271
+ const item = event["item"];
272
+ this.collectArguments(item);
273
+ restoreItem(item, this.reverse);
274
+ this.repairArguments(item);
275
+ }
276
+ const response = event["response"];
277
+ if (isRecord(response))
278
+ this.adaptResponse(response);
279
+ }
280
+ /** Restore names in an emitted response object (tools echo and output items). */
281
+ adaptResponse(response) {
282
+ const tools = response["tools"];
283
+ if (Array.isArray(tools)) {
284
+ for (const tool of tools)
285
+ restoreItem(tool, this.reverse);
286
+ }
287
+ const output = response["output"];
288
+ if (!Array.isArray(output))
289
+ return;
290
+ for (const item of output) {
291
+ this.collectArguments(item);
292
+ restoreItem(item, this.reverse);
293
+ this.repairArguments(item);
294
+ }
295
+ }
296
+ collectArguments(item) {
297
+ if (!isRecord(item) || item["type"] !== "function_call")
298
+ return;
299
+ const id = item["id"];
300
+ const args = item["arguments"];
301
+ if (typeof id === "string" && typeof args === "string" && args !== "") {
302
+ this.arguments.set(id, args);
303
+ }
304
+ }
305
+ repairArguments(item) {
306
+ if (!isRecord(item) || item["type"] !== "function_call")
307
+ return;
308
+ const id = item["id"];
309
+ if (typeof id !== "string")
310
+ return;
311
+ const args = item["arguments"];
312
+ if (args !== undefined && args !== "")
313
+ return;
314
+ const known = this.arguments.get(id);
315
+ if (known !== undefined && known !== "")
316
+ item["arguments"] = known;
317
+ }
318
+ }
319
+ /** Restore aliased names and empty arguments in a complete JSON response. */
320
+ export function transformResponseBody(body, reverse) {
321
+ const output = body["output"];
322
+ if (!Array.isArray(output))
323
+ return body;
324
+ for (const item of output)
325
+ restoreItem(item, reverse);
326
+ return body;
327
+ }
328
+ function readBody(request) {
329
+ return new Promise((resolve, reject) => {
330
+ const chunks = [];
331
+ let size = 0;
332
+ request.on("data", (chunk) => {
333
+ size += chunk.length;
334
+ if (size > MAX_BODY_BYTES) {
335
+ reject(new Error("request body too large"));
336
+ request.destroy();
337
+ return;
338
+ }
339
+ chunks.push(chunk);
340
+ });
341
+ request.on("end", () => resolve(Buffer.concat(chunks)));
342
+ request.on("error", reject);
343
+ });
344
+ }
345
+ function listen(server, port) {
346
+ return new Promise((resolve, reject) => {
347
+ const onError = (error) => {
348
+ server.off("listening", onListening);
349
+ reject(error);
350
+ };
351
+ const onListening = () => {
352
+ server.off("error", onError);
353
+ const address = server.address();
354
+ if (address === null || typeof address === "string") {
355
+ reject(new Error("adapter has no TCP address"));
356
+ return;
357
+ }
358
+ resolve(address.port);
359
+ };
360
+ server.once("error", onError);
361
+ server.once("listening", onListening);
362
+ server.listen(port, "127.0.0.1");
363
+ });
364
+ }
365
+ /** Hop-by-hop headers that must not be forwarded to the upstream origin. */
366
+ const HOP_BY_HOP = new Set([
367
+ "connection",
368
+ "keep-alive",
369
+ "proxy-authenticate",
370
+ "proxy-authorization",
371
+ "te",
372
+ "trailer",
373
+ "transfer-encoding",
374
+ "upgrade",
375
+ ]);
376
+ function upstreamHeaders(request) {
377
+ const headers = new Headers();
378
+ for (const [name, value] of Object.entries(request.headers)) {
379
+ if (value === undefined)
380
+ continue;
381
+ if (name === "host" || name === "content-length" || name === "accept-encoding")
382
+ continue;
383
+ if (HOP_BY_HOP.has(name))
384
+ continue;
385
+ if (Array.isArray(value))
386
+ headers.set(name, value.join(", "));
387
+ else
388
+ headers.set(name, value);
389
+ }
390
+ headers.set("accept-encoding", "identity");
391
+ if (!headers.has("x-api-version"))
392
+ headers.set("x-api-version", "1.0.0");
393
+ return headers;
394
+ }
395
+ function toNodeHeaders(response) {
396
+ const headers = {};
397
+ response.headers.forEach((value, name) => {
398
+ if (name === "content-length" || name === "content-encoding" || name === "transfer-encoding")
399
+ return;
400
+ headers[name] = value;
401
+ });
402
+ return headers;
403
+ }
404
+ /**
405
+ * Start the loopback adapter.
406
+ *
407
+ * The server binds 127.0.0.1 only. Requests to the Responses and Chat
408
+ * completion paths are adapted; every other path is relayed untouched, so
409
+ * the provider's model discovery keeps working through the same baseURL.
410
+ */
411
+ export async function startMuseCodeAdapter(options = {}) {
412
+ const upstream = options.upstream ?? DEFAULT_UPSTREAM;
413
+ const passthrough = options.passthrough === true;
414
+ const log = options.log ?? (() => { });
415
+ const candidates = options.ports ?? DEFAULT_PORTS;
416
+ let lastError;
417
+ let server;
418
+ let port = 0;
419
+ for (const candidate of [...candidates, 0]) {
420
+ const attempt = http.createServer();
421
+ try {
422
+ port = await listen(attempt, candidate);
423
+ server = attempt;
424
+ break;
425
+ }
426
+ catch (error) {
427
+ lastError = error;
428
+ }
429
+ }
430
+ if (server === undefined) {
431
+ throw new Error(`muse-code adapter could not bind a loopback port: ${String(lastError)}`);
432
+ }
433
+ const running = server;
434
+ running.on("request", (request, response) => {
435
+ void handle(request, response).catch((error) => {
436
+ log(`muse-code adapter error: ${error instanceof Error ? error.message : String(error)}`);
437
+ if (!response.headersSent) {
438
+ response.writeHead(502, { "content-type": "application/json" });
439
+ }
440
+ response.end(JSON.stringify({
441
+ error: {
442
+ code: "muse_code_adapter_error",
443
+ message: "The local Muse Code adapter could not relay this request.",
444
+ type: "server_error",
445
+ },
446
+ }));
447
+ });
448
+ });
449
+ async function handle(request, response) {
450
+ const url = request.url ?? "/";
451
+ const pathname = url.split("?", 1)[0] ?? "/";
452
+ const method = request.method ?? "GET";
453
+ const raw = method === "POST" ? await readBody(request) : Buffer.alloc(0);
454
+ let body = raw;
455
+ let reverse = new Map();
456
+ const notes = [];
457
+ let parsedBody;
458
+ if (!passthrough && method === "POST" && isAdaptedPath(pathname) && raw.length > 0) {
459
+ try {
460
+ const parsed = JSON.parse(raw.toString("utf8"));
461
+ if (isRecord(parsed)) {
462
+ parsedBody = parsed;
463
+ const adapted = transformRequestBody(parsed);
464
+ body = Buffer.from(JSON.stringify(adapted.body), "utf8");
465
+ reverse = adapted.plan.reverse;
466
+ notes.push(...adapted.notes);
467
+ }
468
+ }
469
+ catch {
470
+ notes.push("body not JSON; relayed as-is");
471
+ }
472
+ }
473
+ const send = (payload) => fetch(`${upstream}${url}`, {
474
+ method,
475
+ headers: upstreamHeaders(request),
476
+ ...(method === "POST" ? { body: payload } : {}),
477
+ });
478
+ let upstreamResponse = await send(body);
479
+ if (!passthrough && parsedBody !== undefined && upstreamResponse.status === 403) {
480
+ // A refusal can mean a builtin name this release does not know yet:
481
+ // retry once with every flat function tool aliased, so a new Muse tool
482
+ // name cannot take the subscription lane down.
483
+ const probe = await upstreamResponse.clone().text();
484
+ if (probe.includes("client_attestation_denied")) {
485
+ const allPlan = planToolAliases(parsedBody["tools"], { all: true });
486
+ if (allPlan.aliases.size > reverse.size) {
487
+ const retry = transformRequestBody(parsedBody, allPlan);
488
+ reverse = allPlan.reverse;
489
+ notes.push(`gate refused; retried with all ${String(allPlan.aliases.size)} tools aliased`);
490
+ upstreamResponse = await send(Buffer.from(JSON.stringify(retry.body), "utf8"));
491
+ }
492
+ }
493
+ }
494
+ const contentType = upstreamResponse.headers.get("content-type") ?? "";
495
+ if (notes.length > 0) {
496
+ log(`muse-code adapter: ${method} ${pathname} ${String(upstreamResponse.status)} | ${notes.join(", ")}`);
497
+ }
498
+ if (contentType.includes("text/event-stream") && upstreamResponse.body !== null) {
499
+ response.writeHead(upstreamResponse.status, toNodeHeaders(upstreamResponse));
500
+ const restorer = new SseRestorer(reverse);
501
+ const reader = upstreamResponse.body.getReader();
502
+ const decoder = new TextDecoder();
503
+ let buffer = "";
504
+ for (;;) {
505
+ const { done, value } = await reader.read();
506
+ if (done)
507
+ break;
508
+ buffer += decoder.decode(value, { stream: true });
509
+ let index = buffer.indexOf("\n");
510
+ while (index >= 0) {
511
+ const line = buffer.slice(0, index);
512
+ buffer = buffer.slice(index + 1);
513
+ response.write(`${restorer.feed(line)}\n`);
514
+ index = buffer.indexOf("\n");
515
+ }
516
+ }
517
+ if (buffer.length > 0)
518
+ response.write(restorer.feed(buffer));
519
+ response.end();
520
+ return;
521
+ }
522
+ const text = await upstreamResponse.text();
523
+ let payload = text;
524
+ if (!passthrough && contentType.includes("application/json") && reverse.size > 0) {
525
+ try {
526
+ const parsed = JSON.parse(text);
527
+ if (isRecord(parsed))
528
+ payload = JSON.stringify(transformResponseBody(parsed, reverse));
529
+ }
530
+ catch {
531
+ payload = text;
532
+ }
533
+ }
534
+ response.writeHead(upstreamResponse.status, {
535
+ ...toNodeHeaders(upstreamResponse),
536
+ "content-length": Buffer.byteLength(payload),
537
+ });
538
+ response.end(payload);
539
+ }
540
+ return {
541
+ baseUrl: `http://127.0.0.1:${String(port)}/v1`,
542
+ port,
543
+ close: () => new Promise((resolve, reject) => {
544
+ // The host keeps its sockets alive; do not wait for them to time out.
545
+ running.closeAllConnections();
546
+ running.close((error) => {
547
+ if (error)
548
+ reject(error);
549
+ else
550
+ resolve();
551
+ });
552
+ }),
553
+ };
554
+ }
package/dist/auth.js CHANGED
@@ -101,9 +101,12 @@ export async function pollToken(deviceCode, intervalS, expiresInS) {
101
101
  let interval = Math.max(MIN_INTERVAL_S, Number(intervalS) || 5);
102
102
  const deadline = Date.now() + Number(expiresInS || 600) * 1000;
103
103
  let slowDowns = 0;
104
+ let transportFailures = 0;
104
105
  while (Date.now() < deadline) {
105
- // The token endpoint answers HTTP errors (e.g. 400 authorization_pending)
106
- // as part of the normal flow: never throw here, classify instead.
106
+ // The token endpoint answers OAuth errors (e.g. 400 authorization_pending) as part of the
107
+ // normal flow. A rejected fetch, an unparseable body, or a body carrying neither a token nor an
108
+ // OAuth error is a transport/proxy blip, not a verdict: count it and keep polling until the
109
+ // deadline. Only an explicit, terminal OAuth error aborts the device login.
107
110
  let body = null;
108
111
  try {
109
112
  const res = await fetch(TOKEN_URL, {
@@ -126,18 +129,22 @@ export async function pollToken(deviceCode, intervalS, expiresInS) {
126
129
  catch {
127
130
  body = null;
128
131
  }
129
- if (body && !body.error) {
130
- if (typeof body.access_token !== "string" || !body.access_token) {
131
- throw new Error("token response has no access_token");
132
- }
132
+ if (body && typeof body.access_token === "string" && body.access_token) {
133
133
  return body.access_token;
134
134
  }
135
135
  if (body?.error === "slow_down") {
136
136
  slowDowns += 1;
137
137
  interval = Math.max(MIN_INTERVAL_S, interval + SLOW_DOWN_STEP_S);
138
138
  }
139
- else if (body?.error !== "authorization_pending") {
140
- throw new Error(`login failed: ${body?.error || "unreadable token response"}`);
139
+ else if (body?.error === "authorization_pending") {
140
+ // Expected while the user completes approval: keep polling at the current interval.
141
+ }
142
+ else if (typeof body?.error === "string" && body.error) {
143
+ throw new Error(`login failed: ${body.error}`);
144
+ }
145
+ else {
146
+ // Unreadable response: no body, no token, and no OAuth error to classify.
147
+ transportFailures += 1;
141
148
  }
142
149
  const remaining = deadline - Date.now();
143
150
  if (remaining <= 0)
@@ -146,7 +153,11 @@ export async function pollToken(deviceCode, intervalS, expiresInS) {
146
153
  setTimeout(resolve, Math.min(interval, remaining) * 1000);
147
154
  await promise;
148
155
  }
149
- throw new Error(slowDowns ? "login timed out after slow_down responses" : "login timed out waiting for approval");
156
+ throw new Error(slowDowns
157
+ ? "login timed out after slow_down responses"
158
+ : transportFailures > 0
159
+ ? `login timed out waiting for approval after ${transportFailures} network error${transportFailures === 1 ? "" : "s"}`
160
+ : "login timed out waiting for approval");
150
161
  }
151
162
  export async function mintKey(accessToken) {
152
163
  const res = await fetch(KEY_URL, {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,20 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin";
2
2
  import { Plugin as V2Plugin } from "@opencode/plugin";
3
+ import { type RunningAdapter } from "./adapter.js";
4
+ /** The part of a running adapter the plugin needs. */
5
+ type AdapterHandle = Pick<RunningAdapter, "baseUrl" | "close">;
6
+ /** Starts the loopback adapter; tests inject a stub instead of binding a port. */
7
+ export type AdapterFactory = () => Promise<AdapterHandle>;
8
+ /** Options for {@link setupV2}; the adapter is a test seam. */
9
+ export interface V2SetupOptions {
10
+ readonly adapter?: AdapterFactory;
11
+ }
12
+ /**
13
+ * V2 setup. Returns the cleanup function, which stops the adapter.
14
+ *
15
+ * @param options - test seam; production calls setup without options.
16
+ */
17
+ export declare function setupV2(ctx: V2Plugin.Context, options?: V2SetupOptions): Promise<() => Promise<void>>;
3
18
  declare const _default: {
4
19
  id: string;
5
20
  setup: (context: V2Plugin.Context) => Promise<V2Plugin.Cleanup | void> | V2Plugin.Cleanup | void;