opencode-muse-auth 0.4.1 → 0.5.1
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.
- package/ADAPTER_CONTRACT.md +132 -0
- package/AUTH_CONTRACT.md +11 -3
- package/DISCLAIMER.md +13 -0
- package/README.md +37 -10
- package/dist/adapter.d.ts +97 -0
- package/dist/adapter.js +554 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +133 -57
- package/package.json +4 -3
|
@@ -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
|
|
4
|
-
this file and the pinned constants/tests together in all
|
|
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
|
|
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
|
[](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.
|
|
11
|
-
|
|
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
|
-
|
|
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,15 @@ 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.
|
|
59
|
+
Routing does not depend on the credential type: an existing `meta` API key and the V2 Muse OAuth
|
|
60
|
+
connection both go through the adapter; the OAuth access token only overrides `Authorization`
|
|
61
|
+
when the V2 sign-in is active. If Meta still refuses the request, the adapter retries once with
|
|
62
|
+
every tool aliased. See [ADAPTER_CONTRACT.md](ADAPTER_CONTRACT.md).
|
|
50
63
|
- V1 storage: the OpenCode auth store is the primary source, and the loader falls back to the
|
|
51
64
|
legacy cache only when the store has no key.
|
|
52
65
|
- V2 storage: the credential lives in the OpenCode integration store, and no local cache is
|
|
@@ -58,8 +71,9 @@ token from the login flow is kept in memory only and is never written to disk.
|
|
|
58
71
|
|
|
59
72
|
## Requirements
|
|
60
73
|
|
|
61
|
-
- opencode
|
|
62
|
-
|
|
74
|
+
- opencode 2.2.4 or newer for inference through the adapter (the V2 plugin module shape; 2.2.0
|
|
75
|
+
loads the plugin but does not invoke its setup). On the V1 plugin API the plugin keeps
|
|
76
|
+
authenticating but still receives Meta's `client_attestation_denied` 403.
|
|
63
77
|
- Node.js 22.6 or newer for development; CI runs Node.js 22 and 24.
|
|
64
78
|
- An active Muse Code subscription on the Meta account used at login.
|
|
65
79
|
|
|
@@ -71,14 +85,17 @@ token from the login flow is kept in memory only and is never written to disk.
|
|
|
71
85
|
| Auth store | Primary credential storage used by the V1 loader. | `~/.local/share/opencode/auth.json` |
|
|
72
86
|
| Legacy cache | Read-only fallback for credentials written by earlier versions. | `~/.config/opencode/muse-code-sub.json` |
|
|
73
87
|
| `/connect` method | Selects Muse Code subscription login for the `meta` provider. | `meta` provider |
|
|
88
|
+
| `MUSE_CODE_ADAPTER` | `off` keeps the local proxy but disables the tool-name reshaping and parameter normalization. | unset |
|
|
74
89
|
|
|
75
90
|
## Privacy and security
|
|
76
91
|
|
|
77
92
|
The inference key is held by opencode: in the V1 auth store or the V2 integration store. The
|
|
78
93
|
legacy cache, when present, stores only `apiKey`, `accountId`, and `email`; the OAuth access token
|
|
79
94
|
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.
|
|
81
|
-
|
|
95
|
+
`api.meta.ai` for key minting and inference. The V2 wire adapter listens on `127.0.0.1` only,
|
|
96
|
+
forwards the `Authorization` header unchanged, and never reads, stores or logs credentials,
|
|
97
|
+
request bodies or response bodies. See [SECURITY.md](SECURITY.md) for vulnerability reporting and
|
|
98
|
+
[ADAPTER_CONTRACT.md](ADAPTER_CONTRACT.md) for the inference wire rules.
|
|
82
99
|
|
|
83
100
|
To remove access:
|
|
84
101
|
|
|
@@ -103,9 +120,19 @@ its own.
|
|
|
103
120
|
### Meta rejects subscription requests from this client
|
|
104
121
|
|
|
105
122
|
Meta may return a 403 mentioning `client_attestation_denied` or that Muse Code subscriptions cover
|
|
106
|
-
the Muse Code client only.
|
|
107
|
-
|
|
108
|
-
|
|
123
|
+
the Muse Code client only. With the V2 adapter running this should not happen for tool names that
|
|
124
|
+
collide with Muse Code's builtins. If it appears anyway, one of these is true:
|
|
125
|
+
|
|
126
|
+
- the adapter could not bind its loopback port; opencode's log records
|
|
127
|
+
`the local request adapter could not start`, and the provider stays on the direct endpoint;
|
|
128
|
+
- `MUSE_CODE_ADAPTER=off` is set, which keeps the proxy but disables the reshaping;
|
|
129
|
+
- you are on the V1 plugin API, which has no adapter;
|
|
130
|
+
- Meta changed the gate, for example to a real per-client signature. Nothing in this plugin can
|
|
131
|
+
forge one.
|
|
132
|
+
|
|
133
|
+
This is a client restriction, not evidence of an expired key. Signing in again does not remove it.
|
|
134
|
+
Use Muse Code for the subscription, or a separate pay-as-you-go Meta Model API key for other
|
|
135
|
+
clients, subject to Meta's terms.
|
|
109
136
|
|
|
110
137
|
### The login times out or is denied
|
|
111
138
|
|
|
@@ -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 {};
|
package/dist/adapter.js
ADDED
|
@@ -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
|
+
// Every Meta inference request must reach the adapter: it reshapes the tool
|
|
135
|
+
// declarations the gate inspects, so the credential type does not matter
|
|
136
|
+
// (V1 API key or V2 OAuth). Native provider drivers honor this override.
|
|
137
|
+
const baseUrl = adapter.baseUrl;
|
|
138
|
+
await ctx.session.hook("model.request", async (event) => {
|
|
139
|
+
event.baseURL = baseUrl;
|
|
140
|
+
}, { providerID: "meta" });
|
|
141
|
+
}
|
|
142
|
+
// Meta's native provider expects a bearer inference key, not the short-lived
|
|
143
|
+
// OAuth token used to mint it: when the active Muse connection resolves one,
|
|
144
|
+
// put it on the request. AI SDK routes drop model.request.baseURL
|
|
145
|
+
// (opencode#55984), so this hook also rewrites the assembled request onto the
|
|
146
|
+
// adapter origin.
|
|
147
|
+
await ctx.session.hook("http.request", async (event) => {
|
|
148
|
+
const credential = await subscription();
|
|
149
|
+
if (credential)
|
|
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
|
-
|
|
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
|
-
"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.1",
|
|
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",
|