opencode-muse-auth 0.4.1 → 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,132 @@
1
+ # Muse wire adapter contract (v3)
2
+
3
+ Every Muse Code plugin in this family sends subscription inference through the same local
4
+ adapter. This file pins the wire rules; `ADAPTER_VERSION` in the code is the matching constant.
5
+ Change both together in all four repositories:
6
+
7
+ - `opencode-muse-auth`
8
+ - `hermes-muse-code`
9
+ - `openclaw-muse-code`
10
+ - `dsh-muse-code`
11
+
12
+ ## Why the adapter exists
13
+
14
+ Since 2026-10-29/30, `https://api.meta.ai/v1` refuses a Muse Code subscription credential when
15
+ the request declares a flat function tool whose name is exactly one of Muse Code's builtin tool
16
+ names. The refusal is HTTP 403 with `code: client_attestation_denied`:
17
+
18
+ > This API key is billed to a Muse Code subscription. Muse Code subscriptions cover requests from
19
+ > the Muse Code client only. Use it in Muse Code, or use a pay-as-you-go API key for other clients.
20
+
21
+ The gate inspects the declared tool names, not the client identity: headers, user agent, system
22
+ prompt and the rest of the payload do not change the outcome. The adapter aliases only the
23
+ colliding names on the way out and restores them on the way back, so the host keeps its own tool
24
+ names and no credential or signature is forged.
25
+
26
+ ## Measured trigger (live subscription key, 2026-10-30)
27
+
28
+ | Request tool declarations | Result |
29
+ |---|---|
30
+ | no `tools` | 200 |
31
+ | flat `get_weather` | 200 |
32
+ | flat `client_read_file` | 200 |
33
+ | flat `read_file` | 403 |
34
+ | flat `bash` | 403 |
35
+ | flat `web_search` | 403 |
36
+ | flat names outside the first six, e.g. `web_fetch`, `workflow`, `powershell`, `add_memory` | 403 (each, separate probe) |
37
+ | flat `read_file` + `get_weather` | 403 |
38
+ | namespace `client` containing `read_file` | 200 |
39
+ | namespace `muse` with the genuine builtin declaration | 200 |
40
+ | namespace `muse` with a re-schematized `read_file` | 403 |
41
+
42
+ Two consequences drive the rules below:
43
+
44
+ 1. A single colliding flat name is enough to fail the whole request.
45
+ 2. Wrapping client tools in a namespace also passes today, but Meta validates the contents of a
46
+ namespace named `muse`, and a wrapper changes the tool surface the model sees. The adapter
47
+ therefore keeps the flat shape and only renames the colliding declarations.
48
+
49
+ ## Builtin collision set (v3)
50
+
51
+ `workflow`, `read_file`, `write_file`, `edit_file`, `bash`, `bash_input`, `search`,
52
+ `web_search`, `web_fetch`, `powershell`, `powershell_input`, `read_memory`,
53
+ `add_memory`, `edit_memory`, `read_skill`, `write_todos`, `monitor`, `work_status`,
54
+ `work_list`, `work_stop`, `snooze_reminder`, `apply_patch`, `shell`, `create_goal`,
55
+ `get_goal`, `update_goal`, `report_progress`, `request_user_input`, `cron_create`,
56
+ `cron_delete`, `cron_list`, `send_session_message`, `list_peer_sessions`,
57
+ `subagent_send_message`, `subagent_cancel`, `subagent_spawn`, `subagent_status`,
58
+ `subagent_wait`, `subagent_read_result`, `tool_search`.
59
+
60
+ The set comes from the 1.4.14 binary's tool catalog plus `muse exec` captures;
61
+ every name was probed individually against api.meta.ai on 2026-10-30: all but
62
+ `tool_search` returned 403, and `tool_search` stays in the set defensively. The
63
+ catalog includes the goal, cron, session and patch tools as well as the file,
64
+ shell and web families.
65
+
66
+ The set is pinned in code (`COLLIDING_TOOL_NAMES`). Add a name only with evidence that Meta
67
+ rejects it, and bump `ADAPTER_VERSION`.
68
+
69
+ ## Request rules
70
+
71
+ 1. **Aliasing.** Every flat tool of `type: "function"` whose `name` is in the collision set is
72
+ renamed to `client_<name>`. If that alias is already taken by another declared tool, append
73
+ `_2`, `_3`, and so on. The same mapping is applied to `input[].function_call.name` for
74
+ `type: "function_call"` history items. Non-function tools, namespace containers and
75
+ non-colliding names are left untouched.
76
+ 2. **`tool_choice`.** Meta accepts only `"auto"`. Any other value (named function, `"none"`,
77
+ `"required"`) is replaced with `"auto"`.
78
+ 3. **Reasoning effort.** A top-level `reasoning_effort` string moves to `reasoning.effort`; an
79
+ explicit `reasoning.effort` already present wins.
80
+ 4. **Output floor.** `max_output_tokens` below 16 is raised to 16.
81
+ 5. **Reasoning replay.** `reasoning.encrypted_content` is appended to `include` when missing, so a
82
+ host that replays provider items can keep the encrypted reasoning item.
83
+ 6. **Version header.** `x-api-version: 1.0.0` is added when the host did not set it.
84
+ 7. Unknown fields are preserved untouched, and the caller's body object is never mutated.
85
+
86
+ ## Automatic fallback
87
+
88
+ If Meta refuses a request with `client_attestation_denied` even after these
89
+ rules, the adapter retries the same request once with every flat function tool
90
+ aliased (`client_<name>`), and restores the names in the answer. That keeps a
91
+ Muse builtin name this release does not know from taking the lane down; the
92
+ retry is logged as `gate refused; retried with all N tools aliased`.
93
+
94
+ ## Response rules
95
+
96
+ 1. Aliased `function_call` names are restored to the original name in:
97
+ `response.output_item.added` / `.done` items, the `output` array of every `response.*` event,
98
+ the `tools` echo of the response object, the top-level `name` of
99
+ `response.function_call_arguments.done`, and the `output` array of a non-streaming JSON body.
100
+ 2. If an authoritative `output_item.done` or completed item carries an empty `arguments`, the
101
+ value accumulated from `response.function_call_arguments.delta` events is written back.
102
+ 3. SSE is relayed line by line and flushed per line; responses are never buffered.
103
+
104
+ ## Transport rules
105
+
106
+ - The adapter listens on `127.0.0.1` only, on the first free port of `43219`, `43220`, `43221`,
107
+ `43222`, then an ephemeral port.
108
+ - The provider base URL becomes `http://127.0.0.1:<port>/v1`.
109
+ - `Authorization` is forwarded unchanged; the adapter never reads, stores or logs credentials,
110
+ request bodies or response bodies.
111
+ - Every path other than the Responses and Chat completion paths is relayed untouched, so
112
+ `GET /v1/models` discovery keeps working through the same base URL.
113
+ - `MUSE_CODE_ADAPTER=off` turns the wire rules off while keeping the proxy in place, so a
114
+ persisted base URL stays valid. Diagnostic logging is one line per adapted request, through the
115
+ host's debug logger.
116
+ - If the adapter cannot bind, the plugin logs the failure and leaves the provider on the direct
117
+ endpoint, which is the pre-0.3 behavior.
118
+
119
+ ## Failure modes
120
+
121
+ - Meta changes the gate to a real per-client signature: aliasing stops helping and requests fail
122
+ again with `client_attestation_denied`. Nothing in this design can fake a signature.
123
+ - Meta renames or removes a builtin: the collision set needs an update.
124
+ - A host does not preserve provider reasoning items across turns: tool arguments may degrade to
125
+ `{}`. The adapter repairs the empty-arguments field, but not a host that never replays the
126
+ reasoning item.
127
+
128
+ ## Verification
129
+
130
+ - Offline: the adapter unit and proxy tests in each repository, against a fake upstream.
131
+ - Live: start the adapter against `https://api.meta.ai`, send a request with a colliding tool
132
+ name, and require HTTP 200 with the original name restored in the response.
package/AUTH_CONTRACT.md CHANGED
@@ -1,11 +1,12 @@
1
1
  # Muse auth contract (v1)
2
2
 
3
- The three Muse Code plugins implement the same Meta device-code flow. Change
4
- this file and the pinned constants/tests together in all three repositories:
3
+ The four Muse Code plugins implement the same Meta device-code flow. Change
4
+ this file and the pinned constants/tests together in all four repositories:
5
5
 
6
6
  - `opencode-muse-auth`
7
7
  - `hermes-muse-code`
8
8
  - `openclaw-muse-code`
9
+ - `dsh-muse-code`
9
10
 
10
11
  ## Wire contract
11
12
 
@@ -18,6 +19,7 @@ this file and the pinned constants/tests together in all three repositories:
18
19
  | API version header | `x-api-version: 1.0.0` |
19
20
  | Cache fields | `apiKey`, `accountId`, `email` (never the OAuth access token) |
20
21
  | Inference base URL | `https://api.meta.ai/v1` |
22
+ | Inference wire adapter | see `ADAPTER_CONTRACT.md` (v3) |
21
23
 
22
24
  ## Behaviour
23
25
 
@@ -28,5 +30,11 @@ this file and the pinned constants/tests together in all three repositories:
28
30
  key; reject an inactive subscription or a pending payment.
29
31
  - Persistence: only the inference key and account identity are stored; the
30
32
  OAuth access token stays in memory.
33
+ - Inference traffic on the V2 plugin API goes through the loopback adapter
34
+ described in `ADAPTER_CONTRACT.md` (tool-name aliasing, parameter
35
+ normalization, response name restoration). The adapter is part of the wire
36
+ contract: a change to those rules bumps `ADAPTER_VERSION` and the pinned
37
+ tests in every repository that vendors the adapter. The V1 code path keeps
38
+ sending directly to `https://api.meta.ai/v1`.
31
39
  - Any change to this contract bumps `AUTH_CONTRACT_VERSION` in the code and
32
- requires updating the pinned tests in all three repositories.
40
+ requires updating the pinned tests in all four repositories.
package/DISCLAIMER.md CHANGED
@@ -25,6 +25,19 @@ Meta may change, restrict or enforce its terms at any time, and may suspend,
25
25
  restrict or terminate accounts, subscriptions or credentials involved. The
26
26
  authors cannot prevent, predict or reverse that outcome.
27
27
 
28
+ ## Request shaping
29
+
30
+ Since 2026-10-29 Meta refuses subscription requests from other clients when the
31
+ declared client tool names collide with Muse Code's builtin tool names. On the
32
+ V2 plugin API this plugin therefore runs a local adapter that renames exactly
33
+ those declarations on the way to `api.meta.ai` and restores them in the
34
+ response, so the request matches Muse Code's wire format. This is a deliberate
35
+ workaround of Meta's client restriction: it does not forge a credential, token
36
+ or signature, and the subscription key remains your own, but Meta may treat the
37
+ reshaping itself as a violation of its terms. The suspension, revocation and
38
+ no-refund risks described above apply with full force, and revocation can
39
+ happen immediately.
40
+
28
41
  ## No warranty, no guarantee
29
42
 
30
43
  - The integration is provided "as is"; there is no guarantee that it is
package/README.md CHANGED
@@ -7,8 +7,10 @@ Muse Code subscription sign-in for opencode
7
7
  [![npm](https://img.shields.io/npm/v/opencode-muse-auth)](https://www.npmjs.com/package/opencode-muse-auth)
8
8
 
9
9
  opencode plugin that registers the **Muse Code subscription** login method for the built-in `meta`
10
- provider. No API key to copy and no third-party CLI to install. A successful login does not guarantee
11
- model access: Meta may reject subscription requests from clients other than Muse Code.
10
+ provider. No API key to copy and no third-party CLI to install. On the V2 plugin API the plugin also
11
+ routes subscription inference through a local wire adapter that keeps requests compatible with Muse
12
+ Code's tool declarations; see [ADAPTER_CONTRACT.md](ADAPTER_CONTRACT.md). A successful login still
13
+ does not guarantee model access: Meta may reject or restrict this use.
12
14
 
13
15
  > **Unofficial and experimental.** This plugin uses a Muse Code subscription credential from
14
16
  > opencode. Meta's terms limit that credential to Muse Code, so this use may not be supported and
@@ -34,7 +36,9 @@ Add the plugin to `opencode.json`; opencode installs it from npm:
34
36
  3. Open the printed Meta device page, enter the printed code, and approve the login in your own
35
37
  browser. The login completes by itself.
36
38
  4. Select a `muse-*` model exposed by the `meta` provider, for example `meta/muse-spark-1.3`.
37
- Meta's subscription restrictions still apply to requests from opencode.
39
+ On opencode 2.x the plugin routes inference through the local adapter, so client tool names that
40
+ collide with Muse Code's builtins no longer trip Meta's client gate. The V1 plugin API still
41
+ sends requests straight to `api.meta.ai` and keeps receiving the 403.
38
42
 
39
43
  The plugin mints a stable, account-bound inference key and hands it to opencode. The OAuth access
40
44
  token from the login flow is kept in memory only and is never written to disk.
@@ -47,6 +51,12 @@ token from the login flow is kept in memory only and is never written to disk.
47
51
  inference key through the Model API.
48
52
  - Routes through the Responses API; the `meta.txt` system prompt applies automatically to `muse-*`
49
53
  model ids.
54
+ - Runs a loopback wire adapter for the V2 plugin API: it aliases the client tool names that collide
55
+ with Muse Code's builtins on the way to `api.meta.ai`, restores them in the response, and
56
+ normalizes parameters Meta rejects (`tool_choice`, reasoning effort, output-token floor,
57
+ encrypted-reasoning include). The adapter's `/v1` origin becomes the per-request base URL; on
58
+ routes that ignore that override the plugin rewrites the assembled request onto the same origin. If Meta still refuses the request, the
59
+ adapter retries once with every tool aliased. See [ADAPTER_CONTRACT.md](ADAPTER_CONTRACT.md).
50
60
  - V1 storage: the OpenCode auth store is the primary source, and the loader falls back to the
51
61
  legacy cache only when the store has no key.
52
62
  - V2 storage: the credential lives in the OpenCode integration store, and no local cache is
@@ -60,6 +70,8 @@ token from the login flow is kept in memory only and is never written to disk.
60
70
 
61
71
  - opencode with plugin support, including the v2 plugin module shape accepted by opencode 1.18.29+
62
72
  and 2.x.
73
+ - The loopback adapter requires the V2 plugin API (opencode 2.x). V1 installs keep authenticating
74
+ but still receive Meta's `client_attestation_denied` 403.
63
75
  - Node.js 22.6 or newer for development; CI runs Node.js 22 and 24.
64
76
  - An active Muse Code subscription on the Meta account used at login.
65
77
 
@@ -71,14 +83,17 @@ token from the login flow is kept in memory only and is never written to disk.
71
83
  | Auth store | Primary credential storage used by the V1 loader. | `~/.local/share/opencode/auth.json` |
72
84
  | Legacy cache | Read-only fallback for credentials written by earlier versions. | `~/.config/opencode/muse-code-sub.json` |
73
85
  | `/connect` method | Selects Muse Code subscription login for the `meta` provider. | `meta` provider |
86
+ | `MUSE_CODE_ADAPTER` | `off` keeps the local proxy but disables the tool-name reshaping and parameter normalization. | unset |
74
87
 
75
88
  ## Privacy and security
76
89
 
77
90
  The inference key is held by opencode: in the V1 auth store or the V2 integration store. The
78
91
  legacy cache, when present, stores only `apiKey`, `accountId`, and `email`; the OAuth access token
79
92
  is never written to disk. The plugin talks only to `auth.meta.com` for the device login and
80
- `api.meta.ai` for key minting and inference. See [SECURITY.md](SECURITY.md) for vulnerability
81
- reporting.
93
+ `api.meta.ai` for key minting and inference. The V2 wire adapter listens on `127.0.0.1` only,
94
+ forwards the `Authorization` header unchanged, and never reads, stores or logs credentials,
95
+ request bodies or response bodies. See [SECURITY.md](SECURITY.md) for vulnerability reporting and
96
+ [ADAPTER_CONTRACT.md](ADAPTER_CONTRACT.md) for the inference wire rules.
82
97
 
83
98
  To remove access:
84
99
 
@@ -103,9 +118,19 @@ its own.
103
118
  ### Meta rejects subscription requests from this client
104
119
 
105
120
  Meta may return a 403 mentioning `client_attestation_denied` or that Muse Code subscriptions cover
106
- the Muse Code client only. This is a client restriction, not evidence of an expired key. Signing in
107
- again does not remove the restriction. Use Muse Code for the subscription, or a separate
108
- pay-as-you-go Meta Model API key for other clients, subject to Meta's terms.
121
+ the Muse Code client only. With the V2 adapter running this should not happen for tool names that
122
+ collide with Muse Code's builtins. If it appears anyway, one of these is true:
123
+
124
+ - the adapter could not bind its loopback port; opencode's log records
125
+ `the local request adapter could not start`, and the provider stays on the direct endpoint;
126
+ - `MUSE_CODE_ADAPTER=off` is set, which keeps the proxy but disables the reshaping;
127
+ - you are on the V1 plugin API, which has no adapter;
128
+ - Meta changed the gate, for example to a real per-client signature. Nothing in this plugin can
129
+ forge one.
130
+
131
+ This is a client restriction, not evidence of an expired key. Signing in again does not remove it.
132
+ Use Muse Code for the subscription, or a separate pay-as-you-go Meta Model API key for other
133
+ clients, subject to Meta's terms.
109
134
 
110
135
  ### The login times out or is denied
111
136
 
@@ -0,0 +1,97 @@
1
+ /** Version of the wire rules below; bump with ADAPTER_CONTRACT.md. */
2
+ export declare const ADAPTER_VERSION = 3;
3
+ /** Default upstream; tests override it with a local fake. */
4
+ export declare const DEFAULT_UPSTREAM = "https://api.meta.ai";
5
+ /**
6
+ * Muse Code builtin tool names.
7
+ *
8
+ * Reconstructed from the 1.4.14 binary's tool catalog and from `muse exec`
9
+ * captures, then probed one by one against api.meta.ai on 2026-10-30: a flat
10
+ * client tool with one of these exact names is refused with 403
11
+ * `client_attestation_denied`. The catalog includes goal, cron, session and
12
+ * patch tools as well as the file, shell and web families. `tool_search` is
13
+ * included defensively: one probe returned 200, but field reports see a 403.
14
+ */
15
+ export declare const COLLIDING_TOOL_NAMES: ReadonlySet<string>;
16
+ type JsonRecord = Record<string, unknown>;
17
+ /** One alias plan: original name to wire name, plus the reverse map. */
18
+ export interface AliasPlan {
19
+ readonly aliases: ReadonlyMap<string, string>;
20
+ readonly reverse: ReadonlyMap<string, string>;
21
+ }
22
+ /**
23
+ * Decide which declared tool names must be aliased.
24
+ *
25
+ * Only flat `function` tools are considered: those are the declarations Meta
26
+ * compares against its builtin set. A namespace container is left untouched.
27
+ * With `all`, every flat function tool is aliased; the proxy uses that as a
28
+ * one-shot retry when the gate refuses names this release does not know.
29
+ */
30
+ export declare function planToolAliases(tools: unknown, options?: {
31
+ all?: boolean;
32
+ }): AliasPlan;
33
+ /** Result of adapting a request body: the body to send, its plan and a log line. */
34
+ export interface AdaptedRequest {
35
+ readonly body: JsonRecord;
36
+ readonly plan: AliasPlan;
37
+ readonly notes: string[];
38
+ }
39
+ /**
40
+ * Apply the wire rules to one Responses/Chat request body.
41
+ *
42
+ * The input object is not mutated: a shallow copy is returned. Unknown fields
43
+ * are preserved untouched. Pass a plan from {@link planToolAliases} to reuse a
44
+ * mapping, for example the full-aliasing retry.
45
+ */
46
+ export declare function transformRequestBody(body: JsonRecord, plan?: AliasPlan): AdaptedRequest;
47
+ /**
48
+ * Streaming name restorer.
49
+ *
50
+ * Feeds complete SSE lines and returns the line to relay. It restores aliased
51
+ * function-call names and repairs empty `arguments` from the streamed deltas:
52
+ * Meta's authoritative `response.output_item.done` sometimes ships "" even
53
+ * though the deltas carried the JSON.
54
+ */
55
+ export declare class SseRestorer {
56
+ private readonly reverse;
57
+ private readonly arguments;
58
+ constructor(reverse: ReadonlyMap<string, string>);
59
+ /** Adapt one complete SSE line (without the trailing newline). */
60
+ feed(line: string): string;
61
+ private adapt;
62
+ /** Restore names in an emitted response object (tools echo and output items). */
63
+ private adaptResponse;
64
+ private collectArguments;
65
+ private repairArguments;
66
+ }
67
+ /** Restore aliased names and empty arguments in a complete JSON response. */
68
+ export declare function transformResponseBody(body: JsonRecord, reverse: ReadonlyMap<string, string>): JsonRecord;
69
+ /** Options for {@link startMuseCodeAdapter}. */
70
+ export interface AdapterOptions {
71
+ /** Upstream origin; override for tests only. */
72
+ readonly upstream?: string;
73
+ /** Candidate loopback ports; the first free one wins, then an ephemeral port. */
74
+ readonly ports?: readonly number[];
75
+ /** Diagnostic sink; one short line per adapted request. */
76
+ readonly log?: (message: string) => void;
77
+ /** Disable the wire rules but keep proxying (MUSE_CODE_ADAPTER=off). */
78
+ readonly passthrough?: boolean;
79
+ }
80
+ /** A running adapter. */
81
+ export interface RunningAdapter {
82
+ /** Loopback origin to use as the provider baseURL, e.g. http://127.0.0.1:43219/v1. */
83
+ readonly baseUrl: string;
84
+ /** Actual loopback port. */
85
+ readonly port: number;
86
+ /** Stop accepting and close the server. */
87
+ close(): Promise<void>;
88
+ }
89
+ /**
90
+ * Start the loopback adapter.
91
+ *
92
+ * The server binds 127.0.0.1 only. Requests to the Responses and Chat
93
+ * completion paths are adapted; every other path is relayed untouched, so
94
+ * the provider's model discovery keeps working through the same baseURL.
95
+ */
96
+ export declare function startMuseCodeAdapter(options?: AdapterOptions): Promise<RunningAdapter>;
97
+ export {};
@@ -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/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;
package/dist/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { Plugin as V2Plugin, Integration } from "@opencode/plugin";
2
+ import { DEFAULT_UPSTREAM, startMuseCodeAdapter } from "./adapter.js";
2
3
  import { writeCache, deviceAuthorize, pollToken, mintKey, resolveLoaderKey, } from "./auth.js";
3
4
  const TEN_YEARS_MS = 10 * 365 * 24 * 60 * 60 * 1000;
5
+ /** Origin of the real Meta inference endpoint; the adapter forwards to it. */
6
+ const META_ORIGIN = new URL(DEFAULT_UPSTREAM).origin;
4
7
  /** Keep diagnostics useful without ever copying a credential into a log line. */
5
8
  function describeError(error) {
6
9
  const text = error instanceof Error ? error.message : String(error);
@@ -25,6 +28,135 @@ async function logAuthFailure(client, error) {
25
28
  // Logging must never break the auth flow.
26
29
  }
27
30
  }
31
+ /** Start the loopback adapter; `off` keeps the proxy but skips the reshaping. */
32
+ function startLocalAdapter() {
33
+ return startMuseCodeAdapter({ passthrough: process.env["MUSE_CODE_ADAPTER"] === "off" });
34
+ }
35
+ /**
36
+ * Rewrite one direct Meta request onto the adapter origin.
37
+ *
38
+ * Only the origin changes: path, query, method, headers, body and signal are
39
+ * preserved. A request already targeting the adapter, or any other host, is
40
+ * returned untouched.
41
+ */
42
+ function routeThroughAdapter(request, baseUrl) {
43
+ const url = new URL(request.url);
44
+ const target = new URL(baseUrl);
45
+ if (url.origin === target.origin || url.origin !== META_ORIGIN)
46
+ return request;
47
+ url.protocol = target.protocol;
48
+ url.hostname = target.hostname;
49
+ url.port = target.port;
50
+ const init = {
51
+ method: request.method,
52
+ headers: request.headers,
53
+ signal: request.signal,
54
+ redirect: request.redirect,
55
+ };
56
+ if (request.method !== "GET" && request.method !== "HEAD" && request.body !== null) {
57
+ init.body = request.body;
58
+ init.duplex = "half";
59
+ }
60
+ return new Request(url.toString(), init);
61
+ }
62
+ /**
63
+ * V2 setup. Returns the cleanup function, which stops the adapter.
64
+ *
65
+ * @param options - test seam; production calls setup without options.
66
+ */
67
+ export async function setupV2(ctx, options = {}) {
68
+ const methodID = Integration.MethodID.make("muse-code-subscription");
69
+ await ctx.integration.transform((editor) => {
70
+ editor.method.update({
71
+ integrationID: "meta",
72
+ method: {
73
+ id: methodID,
74
+ type: "oauth",
75
+ label: "Muse Code subscription (Meta device login)",
76
+ },
77
+ async authorize() {
78
+ const device = await deviceAuthorize();
79
+ const url = device.verification_uri_complete || device.verification_uri;
80
+ const callback = (async () => {
81
+ try {
82
+ const access = await pollToken(device.device_code, device.interval, device.expires_in);
83
+ const minted = await mintKey(access);
84
+ return {
85
+ type: "oauth",
86
+ methodID,
87
+ access: minted.apiKey,
88
+ refresh: "",
89
+ expires: Date.now() + TEN_YEARS_MS,
90
+ metadata: { accountId: minted.accountId, ...(minted.email ? { email: minted.email } : {}) },
91
+ };
92
+ }
93
+ catch (error) {
94
+ throw new Error(`Muse Code login failed: ${describeError(error)}`);
95
+ }
96
+ })();
97
+ // The runtime awaits this promise after displaying the device code.
98
+ // Attach a handler immediately in case the exchange fails first.
99
+ void callback.catch(() => { });
100
+ return {
101
+ url,
102
+ instructions: `Open ${url}\nEnter code: ${device.user_code}`,
103
+ mode: "auto",
104
+ expiresAt: Date.now() + device.expires_in * 1000,
105
+ callback,
106
+ };
107
+ },
108
+ label: (credential) => typeof credential.metadata?.email === "string"
109
+ ? credential.metadata.email : "Muse Code subscription",
110
+ });
111
+ });
112
+ // Meta's gate inspects the declared tool names, so subscription inference
113
+ // goes through a loopback proxy that aliases the colliding names and
114
+ // restores them in the response. Failures keep the pre-adapter behavior.
115
+ let started;
116
+ try {
117
+ started = await (options.adapter ?? startLocalAdapter)();
118
+ }
119
+ catch (error) {
120
+ console.error(`opencode-muse-auth: the local request adapter could not start; ` +
121
+ `subscription requests will not be reshaped: ${describeError(error)}`);
122
+ }
123
+ const adapter = started;
124
+ async function subscription() {
125
+ const connection = await ctx.integration.connection.active("meta");
126
+ if (!connection)
127
+ return undefined;
128
+ const credential = await ctx.integration.connection.resolve(connection);
129
+ if (credential?.type !== "oauth" || credential.methodID !== methodID)
130
+ return undefined;
131
+ return credential;
132
+ }
133
+ if (adapter) {
134
+ // Primary mechanism: the provider uses the adapter's /v1 origin as its
135
+ // request base URL. Native provider drivers honor this override.
136
+ const baseUrl = adapter.baseUrl;
137
+ await ctx.session.hook("model.request", async (event) => {
138
+ if (await subscription())
139
+ event.baseURL = baseUrl;
140
+ }, { providerID: "meta" });
141
+ }
142
+ // Meta's native provider expects a bearer inference key, not the
143
+ // short-lived OAuth token used to mint it. Honor the active connection.
144
+ // AI SDK routes drop model.request.baseURL (opencode#55984), so this hook
145
+ // also rewrites the assembled request onto the adapter origin.
146
+ await ctx.session.hook("http.request", async (event) => {
147
+ const credential = await subscription();
148
+ if (!credential)
149
+ return;
150
+ event.request.headers.set("Authorization", `Bearer ${credential.access}`);
151
+ if (adapter)
152
+ event.request = routeThroughAdapter(event.request, adapter.baseUrl);
153
+ }, { providerID: "meta" });
154
+ return async () => {
155
+ await adapter?.close();
156
+ };
157
+ }
158
+ // V1 has no adapter: its inference keeps going straight to api.meta.ai and
159
+ // still receives Meta's client-restriction 403.
28
160
  const MuseCodeAuth = async ({ client }) => {
29
161
  return {
30
162
  auth: {
@@ -66,63 +198,7 @@ const MuseCodeAuth = async ({ client }) => {
66
198
  export default {
67
199
  ...V2Plugin.define({
68
200
  id: "muse-code-auth",
69
- async setup(ctx) {
70
- const methodID = Integration.MethodID.make("muse-code-subscription");
71
- await ctx.integration.transform((editor) => {
72
- editor.method.update({
73
- integrationID: "meta",
74
- method: {
75
- id: methodID,
76
- type: "oauth",
77
- label: "Muse Code subscription (Meta device login)",
78
- },
79
- async authorize() {
80
- const device = await deviceAuthorize();
81
- const url = device.verification_uri_complete || device.verification_uri;
82
- const callback = (async () => {
83
- try {
84
- const access = await pollToken(device.device_code, device.interval, device.expires_in);
85
- const minted = await mintKey(access);
86
- return {
87
- type: "oauth",
88
- methodID,
89
- access: minted.apiKey,
90
- refresh: "",
91
- expires: Date.now() + TEN_YEARS_MS,
92
- metadata: { accountId: minted.accountId, ...(minted.email ? { email: minted.email } : {}) },
93
- };
94
- }
95
- catch (error) {
96
- throw new Error(`Muse Code login failed: ${describeError(error)}`);
97
- }
98
- })();
99
- // The runtime awaits this promise after displaying the device code.
100
- // Attach a handler immediately in case the exchange fails first.
101
- void callback.catch(() => { });
102
- return {
103
- url,
104
- instructions: `Open ${url}\nEnter code: ${device.user_code}`,
105
- mode: "auto",
106
- expiresAt: Date.now() + device.expires_in * 1000,
107
- callback,
108
- };
109
- },
110
- label: (credential) => typeof credential.metadata?.email === "string"
111
- ? credential.metadata.email : "Muse Code subscription",
112
- });
113
- });
114
- // Meta's native provider expects a bearer inference key, not the
115
- // short-lived OAuth token used to mint it. Honor the active connection.
116
- await ctx.session.hook("http.request", async (event) => {
117
- const connection = await ctx.integration.connection.active("meta");
118
- if (!connection)
119
- return;
120
- const credential = await ctx.integration.connection.resolve(connection);
121
- if (credential?.type === "oauth" && credential.methodID === methodID) {
122
- event.request.headers.set("Authorization", `Bearer ${credential.access}`);
123
- }
124
- }, { providerID: "meta" });
125
- },
201
+ setup: (ctx) => setupV2(ctx),
126
202
  }),
127
203
  server: MuseCodeAuth,
128
204
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "opencode-muse-auth",
3
- "version": "0.4.1",
4
- "description": "Unofficial and experimental: Muse Spark in opencode using a Muse Code subscription credential. May conflict with Meta's terms; use at your own risk. See DISCLAIMER.md.",
3
+ "version": "0.5.0",
4
+ "description": "Unofficial and experimental: Muse Spark in opencode using a Muse Code subscription credential, with a local loopback adapter for Meta's subscription wire format. May conflict with Meta's terms; use at your own risk. See DISCLAIMER.md.",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",
7
7
  "type": "module",
@@ -33,7 +33,8 @@
33
33
  "LICENSE",
34
34
  "NOTICE",
35
35
  "DISCLAIMER.md",
36
- "AUTH_CONTRACT.md"
36
+ "AUTH_CONTRACT.md",
37
+ "ADAPTER_CONTRACT.md"
37
38
  ],
38
39
  "scripts": {
39
40
  "build": "tsc",