@m2canvas/connect 0.5.0 → 0.7.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.
- package/README.md +22 -4
- package/dist/bridge/adapters.js +130 -9
- package/dist/bridge/agents.js +5 -0
- package/dist/bridge/auth-call.js +68 -0
- package/dist/bridge/authorize.js +341 -0
- package/dist/bridge/env.js +12 -0
- package/dist/bridge/errors.js +9 -0
- package/dist/bridge/exec.js +53 -12
- package/dist/bridge/install-layout.js +37 -0
- package/dist/bridge/instance.js +245 -0
- package/dist/bridge/main.js +176 -36
- package/dist/bridge/npm-command.js +25 -0
- package/dist/bridge/pair-call.js +31 -0
- package/dist/bridge/pairing.js +13 -3
- package/dist/bridge/precheck.js +22 -6
- package/dist/bridge/protocol.js +11 -1
- package/dist/bridge/release-manifest.js +80 -0
- package/dist/bridge/self-test.js +30 -0
- package/dist/bridge/server.js +54 -32
- package/dist/bridge/service.js +103 -20
- package/dist/bridge/update-io.js +118 -0
- package/dist/bridge/update.js +365 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,11 +36,29 @@ Lets M2 (in Chrome or another Chromium browser) start and talk to the agents on
|
|
|
36
36
|
|
|
37
37
|
- **Local only.** It listens on `127.0.0.1:47821` and only accepts pages from `https://m2ui.ai`. Other sites, other machines and other hosts are refused before a connection is made. (`--dev` also accepts a local M2 dev server on port 5173; any app you serve on that port could then ask to pair, so only use it while developing M2.)
|
|
38
38
|
- **Pairing gives the page your agents.** A paired page can make these agents read and change files and run commands as you, the same as typing into them yourself. Only run commands you copied from M2, and only enter the code into M2.
|
|
39
|
-
- **Pairing.** When you start the bridge with the command M2 gives you, the page that gave it pairs automatically within 10 minutes. The command only carries a hash (`--pair`); the secret it matches stays in that browser tab, so the command is safe to appear in your shell history, and it works once. Any other page asks for the pairing code printed in this window; the window also shows which site is asking. After that the page remembers the pairing for 30 days of inactivity. After too many wrong codes, new pages can't pair until you restart the bridge (pages already paired keep working); close the page the window names first. `bridge --unpair` forgets all paired pages.
|
|
40
|
-
- **Your own logins.** Agents use their own sign-in and their own M2 MCP setup
|
|
41
|
-
- **
|
|
39
|
+
- **Pairing.** When you start the bridge with the command M2 gives you, the page that gave it pairs automatically within 10 minutes. The command only carries a hash (`--pair`); the secret it matches stays in that browser tab, so the command is safe to appear in your shell history, and it works once. Any other page asks for the pairing code printed in this window; the window also shows which site is asking. After that the page remembers the pairing for 30 days of inactivity. After too many wrong codes, new pages can't pair until you restart the bridge (pages already paired keep working); close the page the window names first. `bridge --unpair` forgets all paired pages. If you can't find this window, run the command M2 shows under the code box: it adds `--replace`, which stops the M2 bridge already running on the port (its agent sessions end) and starts a new one that pairs by itself. It only stops a bridge (0.6.0 or later) it can recognize from a small state file in `~/.m2canvas` (process id and start time; no codes or tokens are written there), never anything else on the port, and not on Windows. When it can't recognize what holds the port, it shows you that process (or how to find it) and leaves it alone. With an older bridge, M2 shows the Ctrl+C instructions instead.
|
|
40
|
+
- **Your own logins.** Agents use their own sign-in and their own M2 MCP setup. It starts agents with a minimal environment, so variables set by a host app (for example `ANTHROPIC_BASE_URL` inside Claude Code) don't leak into them.
|
|
41
|
+
- **Signing in from M2 (macOS).** M2 can start an agent's own sign-in, or its M2 authorization, for you: the bridge runs that agent's own command from a fixed list in this package, and M2 opens the sign-in page in the browser you are using (Codex opens your default browser itself). You finish signing in there; nothing is entered for you. The page only picks the agent and whether to sign in or authorize M2, never the command. Covered: sign-in for Claude Code, Codex, Cursor and Qoder; M2 authorization for Claude Code, Codex, Cursor and OpenCode (Qoder's M2 authorization is only available inside Qoder). Claude Code's M2 authorization needs a terminal, so the bridge provides one with macOS's built-in `/usr/bin/expect`. A sign-in can be canceled from M2, gives up after 10 minutes, and stops when the page disconnects or the bridge exits. On other systems M2 shows the command to run instead.
|
|
42
|
+
- **Claude and Codex** run through pinned versions of the official adapters (`@agentclientprotocol/claude-agent-acp@0.84.0`, `@agentclientprotocol/codex-acp@2.1.0`). They are installed on first use into `~/.m2canvas/acp-adapters/` from the lockfiles in this package (about 270 MB and 350 MB, because they bundle the agent); M2 shows the download progress. You don't need Claude Code or Codex installed yourself: checking sign-in, adding M2 and signing in all use the bundled `claude` and `codex`, which read the same settings as your own installs.
|
|
42
43
|
- Cursor runs as `agent acp`, OpenCode as `opencode acp`, Qoder as `qodercli --acp`.
|
|
43
44
|
|
|
44
45
|
Agent sessions start in that directory.
|
|
45
46
|
|
|
46
|
-
Options: `--port <n>`, `--cwd <dir>` (default: current directory), `--verbose` (also print agent stderr), `--dev` (also accept the local M2 dev server).
|
|
47
|
+
Options: `--port <n>`, `--cwd <dir>` (default: current directory), `--verbose` (also print agent stderr), `--dev` (also accept the local M2 dev server), `--replace` (stop the M2 bridge already running on the port first).
|
|
48
|
+
|
|
49
|
+
## One-time install (macOS)
|
|
50
|
+
|
|
51
|
+
M2's Local Agent tab can have an agent app set the bridge up for you, so you need neither a terminal nor Node.js:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
curl -fsSL https://m2ui.ai/connect/install.sh | sh -s -- --pair <value from M2>
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
- **From M2.** Click "Connect with Claude" (or Codex, or Cursor): the app opens with this command in a prompt, and you press Return. The installer puts a private Node.js and a pinned version of this package in `~/.m2canvas`, checking every download against the hashes M2 publishes; writes the launcher `~/.m2canvas/bin/m2-bridge`; and starts the bridge in the background. The page pairs by itself. Agent sessions start in `~/.m2canvas/workspace`.
|
|
58
|
+
- **Nothing starts at login.** After a restart, M2 asks the agent app to run `~/.m2canvas/bin/m2-bridge start --pair <value>` (you can run it yourself too). The launcher first updates the bridge to the version listed at `https://m2ui.ai/connect/manifest.json`, and goes back to the previous version if the new one doesn't start. The bridge keeps running after you quit the app, until you log out or shut down.
|
|
59
|
+
- **No pairing codes in the background.** A bridge started this way has no window to show a code in. If a page isn't paired (the pairing expired, or you use another browser), M2 offers "Reconnect", which runs the launcher with `--replace`: it restarts the bridge and pairs that page, and agent sessions on the old bridge end.
|
|
60
|
+
- **Sandboxes.** Codex needs its Full access permission mode: its default sandbox can't reach the network or write to your home folder. Inside the Codex or Claude command sandbox, the scripts change nothing and say what to do instead.
|
|
61
|
+
- **What it leaves alone.** macOS 13.5 or later. It doesn't use sudo, register anything to start at login, change your shell config or PATH, or touch a Node.js you installed yourself. Logs are in `~/.m2canvas/logs/`.
|
|
62
|
+
- **Uninstall** with `curl -fsSL https://m2ui.ai/connect/uninstall.sh | sh`, or "Uninstall local connection" in the Local Agent tab's ⋯ menu. It stops the bridge and removes what the installer added; `~/.m2canvas/workspace` is kept if it has files in it, and your agents' M2 settings stay as they are.
|
|
63
|
+
|
|
64
|
+
The `npx` command above still works and is what M2 shows under "Advanced", for running the bridge in a folder you choose.
|
package/dist/bridge/adapters.js
CHANGED
|
@@ -13,9 +13,12 @@
|
|
|
13
13
|
* - `--ignore-scripts`:两条依赖链上没有安装脚本(实测),加上它能挡掉安装期执行代码。
|
|
14
14
|
* - 安装目录名带 lockfile 哈希:换了 lockfile 就是新目录,旧目录不会被半新半旧地复用。旧目录目前不清理(FU-2026-10-02-120)。
|
|
15
15
|
* - 先装到临时目录、写好就绪标记再原子改名;残留的临时目录或没有标记的目录一律视为未安装。
|
|
16
|
+
* - 升级 Claude 适配器时复核 bundledClaudePath 与 SDK 自身的解析规则是否仍一致(acp-terminal-free-auth evidence/sdk-binary-resolution.md),
|
|
17
|
+
* 并按新版本实测 installedBytes。
|
|
16
18
|
*/
|
|
17
19
|
import { createHash, randomBytes } from 'node:crypto';
|
|
18
20
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { lstat, readdir } from 'node:fs/promises';
|
|
19
22
|
import { join } from 'node:path';
|
|
20
23
|
import { combinedOutput } from './exec.js';
|
|
21
24
|
import { tail } from './precheck-parse.js';
|
|
@@ -25,18 +28,89 @@ export const ADAPTERS = {
|
|
|
25
28
|
package: '@agentclientprotocol/claude-agent-acp',
|
|
26
29
|
version: '0.84.0',
|
|
27
30
|
entry: 'node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js',
|
|
31
|
+
installedBytes: 268_451_230,
|
|
28
32
|
},
|
|
29
33
|
codex: {
|
|
30
34
|
dir: 'codex-acp',
|
|
31
35
|
package: '@agentclientprotocol/codex-acp',
|
|
32
36
|
version: '2.1.0',
|
|
33
37
|
entry: 'node_modules/@agentclientprotocol/codex-acp/dist/index.js',
|
|
38
|
+
installedBytes: 346_221_434,
|
|
34
39
|
},
|
|
35
40
|
};
|
|
36
41
|
/** 适配器自带的 codex:预检与授权命令都要用它(本机旧版 codex 存的凭证,新版本刷新不了,PoC 报告 §2.2) */
|
|
37
42
|
export const CODEX_SCRIPT = 'node_modules/@openai/codex/bin/codex.js';
|
|
38
43
|
export const CODEX_BIN_LINK = 'node_modules/.bin/codex';
|
|
39
|
-
|
|
44
|
+
/** 安装完成的标记文件:没有它的目录一律视为未安装(自动更新清理旧目录时也只删带它的,change acp-bridge-one-time-install N2) */
|
|
45
|
+
export const ADAPTER_READY_MARKER = '.m2canvas-ready';
|
|
46
|
+
const READY_MARKER = ADAPTER_READY_MARKER;
|
|
47
|
+
const PROGRESS_INTERVAL_MS = 1000;
|
|
48
|
+
/**
|
|
49
|
+
* Linux 上没有 glibc(Alpine 等)时优先 musl 版本:与 claude-agent-sdk 自身的判断相同(process.report 的 glibcVersionRuntime)。
|
|
50
|
+
*/
|
|
51
|
+
function preferMusl() {
|
|
52
|
+
if (process.platform !== 'linux')
|
|
53
|
+
return false;
|
|
54
|
+
const report = typeof process.report?.getReport === 'function' ? process.report.getReport() : null;
|
|
55
|
+
return report !== null && report.header?.glibcVersionRuntime === undefined;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* 适配器自带的 claude(D9):会话真正用的是它,预检、写配置、授权也都用它,看到的才是同一份程序。
|
|
59
|
+
* 规则照搬 claude-agent-sdk 0.3.284 的解析(候选 `@anthropic-ai/claude-agent-sdk-<platform>-<arch>[-musl]/claude[.exe]`,
|
|
60
|
+
* 取第一个存在的)。找不到返回 null,调用方报 unknown —— **不得退回本机的 claude**,否则预检和会话用的是两个程序。
|
|
61
|
+
*/
|
|
62
|
+
export function bundledClaudePath(installDir, options = {}) {
|
|
63
|
+
const platform = options.platform ?? process.platform;
|
|
64
|
+
const arch = options.arch ?? process.arch;
|
|
65
|
+
const exists = options.exists ?? existsSync;
|
|
66
|
+
const musl = options.preferMusl ?? preferMusl();
|
|
67
|
+
const pkg = '@anthropic-ai/claude-agent-sdk';
|
|
68
|
+
const names = platform === 'android'
|
|
69
|
+
? [`${pkg}-linux-${arch}-android`]
|
|
70
|
+
: platform === 'linux'
|
|
71
|
+
? musl
|
|
72
|
+
? [`${pkg}-linux-${arch}-musl`, `${pkg}-linux-${arch}`]
|
|
73
|
+
: [`${pkg}-linux-${arch}`, `${pkg}-linux-${arch}-musl`]
|
|
74
|
+
: [`${pkg}-${platform}-${arch}`];
|
|
75
|
+
const binary = platform === 'win32' ? 'claude.exe' : 'claude';
|
|
76
|
+
for (const name of names) {
|
|
77
|
+
const candidate = join(installDir, 'node_modules', ...name.split('/'), binary);
|
|
78
|
+
if (exists(candidate))
|
|
79
|
+
return candidate;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
/** 已写入字节 / 预估体积 → 0–0.99(两位小数);算不出时 undefined(页面退回转圈) */
|
|
84
|
+
export function estimateProgress(bytes, expected) {
|
|
85
|
+
if (!Number.isFinite(bytes) || !Number.isFinite(expected) || expected <= 0)
|
|
86
|
+
return undefined;
|
|
87
|
+
return Math.min(0.99, Math.max(0, Math.floor((bytes / expected) * 100) / 100));
|
|
88
|
+
}
|
|
89
|
+
/** 目录里所有文件的字节数(不跟随符号链接)。npm 正在挪动文件,读不到的项直接跳过 */
|
|
90
|
+
async function directoryBytes(dir) {
|
|
91
|
+
let total = 0;
|
|
92
|
+
let entries;
|
|
93
|
+
try {
|
|
94
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
for (const entry of entries) {
|
|
100
|
+
const path = join(dir, entry.name);
|
|
101
|
+
if (entry.isDirectory())
|
|
102
|
+
total += await directoryBytes(path);
|
|
103
|
+
else {
|
|
104
|
+
try {
|
|
105
|
+
total += (await lstat(path)).size;
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
// 刚被 npm 移走
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return total;
|
|
113
|
+
}
|
|
40
114
|
function isProcessAlive(pid) {
|
|
41
115
|
try {
|
|
42
116
|
process.kill(pid, 0);
|
|
@@ -50,19 +124,24 @@ function isProcessAlive(pid) {
|
|
|
50
124
|
export function isAdapterAgent(agent) {
|
|
51
125
|
return agent === 'claude' || agent === 'codex';
|
|
52
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* 安装目录名 = `<适配器目录名>-<lockfile 的 sha256 前 16 位>`:换了 lockfile 就是新目录。
|
|
129
|
+
* 自动更新按 current / previous 两个版本各自的 lockfile 算出要保留的目录(change acp-bridge-one-time-install N2),所以规则只能在这一处。
|
|
130
|
+
*/
|
|
131
|
+
export function adapterInstallDirName(sourceRoot, dir) {
|
|
132
|
+
const lock = readFileSync(join(sourceRoot, dir, 'package-lock.json'));
|
|
133
|
+
return `${dir}-${createHash('sha256').update(lock).digest('hex').slice(0, 16)}`;
|
|
134
|
+
}
|
|
53
135
|
export class AdapterManager {
|
|
54
136
|
options;
|
|
55
137
|
installing = new Map();
|
|
56
138
|
lastError = new Map();
|
|
139
|
+
progress = new Map();
|
|
57
140
|
constructor(options) {
|
|
58
141
|
this.options = options;
|
|
59
142
|
}
|
|
60
|
-
lockKey(agent) {
|
|
61
|
-
const lock = readFileSync(join(this.options.sourceRoot, ADAPTERS[agent].dir, 'package-lock.json'));
|
|
62
|
-
return createHash('sha256').update(lock).digest('hex').slice(0, 16);
|
|
63
|
-
}
|
|
64
143
|
installDir(agent) {
|
|
65
|
-
return join(this.options.installRoot,
|
|
144
|
+
return join(this.options.installRoot, adapterInstallDirName(this.options.sourceRoot, ADAPTERS[agent].dir));
|
|
66
145
|
}
|
|
67
146
|
entryPath(agent) {
|
|
68
147
|
return join(this.installDir(agent), ADAPTERS[agent].entry);
|
|
@@ -71,13 +150,19 @@ export class AdapterManager {
|
|
|
71
150
|
const dir = this.installDir(agent);
|
|
72
151
|
return existsSync(join(dir, READY_MARKER)) && existsSync(join(dir, ADAPTERS[agent].entry));
|
|
73
152
|
}
|
|
153
|
+
/** 适配器自带的 claude(D9);适配器没就绪或找不到时为 null */
|
|
154
|
+
claudeBinary() {
|
|
155
|
+
return this.isReady('claude') ? bundledClaudePath(this.installDir('claude')) : null;
|
|
156
|
+
}
|
|
74
157
|
info(agent) {
|
|
75
158
|
const spec = ADAPTERS[agent];
|
|
76
159
|
const base = { package: spec.package, version: spec.version };
|
|
77
|
-
if (this.installing.has(agent))
|
|
78
|
-
|
|
160
|
+
if (this.installing.has(agent)) {
|
|
161
|
+
const progress = this.progress.get(agent);
|
|
162
|
+
return { ...base, state: 'installing', error: null, ...(progress === undefined ? {} : { progress }) };
|
|
163
|
+
}
|
|
79
164
|
if (this.isReady(agent))
|
|
80
|
-
return { ...base, state: 'ready', error: null };
|
|
165
|
+
return { ...base, state: 'ready', error: null, progress: 1 };
|
|
81
166
|
const error = this.lastError.get(agent);
|
|
82
167
|
return error ? { ...base, state: 'failed', error } : { ...base, state: 'not_installed', error: null };
|
|
83
168
|
}
|
|
@@ -91,6 +176,7 @@ export class AdapterManager {
|
|
|
91
176
|
this.lastError.delete(agent);
|
|
92
177
|
const job = this.install(agent).finally(() => {
|
|
93
178
|
this.installing.delete(agent);
|
|
179
|
+
this.progress.delete(agent);
|
|
94
180
|
this.options.onState?.(agent, this.info(agent));
|
|
95
181
|
});
|
|
96
182
|
this.installing.set(agent, job.then(() => this.info(agent)));
|
|
@@ -117,11 +203,13 @@ export class AdapterManager {
|
|
|
117
203
|
const finalDir = this.installDir(agent);
|
|
118
204
|
this.removeStaleTemps(spec);
|
|
119
205
|
const tmpDir = join(this.options.installRoot, `.tmp-${spec.dir}-${process.pid}-${randomBytes(4).toString('hex')}`);
|
|
206
|
+
let stopProgress = () => undefined;
|
|
120
207
|
try {
|
|
121
208
|
mkdirSync(tmpDir, { recursive: true });
|
|
122
209
|
for (const file of ['package.json', 'package-lock.json']) {
|
|
123
210
|
copyFileSync(join(this.options.sourceRoot, spec.dir, file), join(tmpDir, file));
|
|
124
211
|
}
|
|
212
|
+
stopProgress = this.trackProgress(agent, tmpDir);
|
|
125
213
|
const result = await this.options.runNpmCi(tmpDir);
|
|
126
214
|
if (result.code !== 0)
|
|
127
215
|
throw new Error(`npm ci failed (${result.timedOut ? 'timed out' : `exit ${String(result.code)}`}): ${tail(combinedOutput(result))}`);
|
|
@@ -140,7 +228,40 @@ export class AdapterManager {
|
|
|
140
228
|
this.lastError.set(agent, error instanceof Error ? error.message : String(error));
|
|
141
229
|
}
|
|
142
230
|
finally {
|
|
231
|
+
stopProgress();
|
|
143
232
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
144
233
|
}
|
|
145
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* npm ci 不报进度(D12):每隔一段时间统计临时安装目录已写入的字节,除以预估体积得到近似进度,变化了才广播。
|
|
237
|
+
* 只读不写,统计失败当 0 处理;返回停止函数。
|
|
238
|
+
*/
|
|
239
|
+
trackProgress(agent, dir) {
|
|
240
|
+
const expected = this.options.expectedBytes?.[agent] ?? ADAPTERS[agent].installedBytes;
|
|
241
|
+
let stopped = false;
|
|
242
|
+
let measuring = false;
|
|
243
|
+
const timer = setInterval(() => {
|
|
244
|
+
if (measuring)
|
|
245
|
+
return;
|
|
246
|
+
measuring = true;
|
|
247
|
+
void directoryBytes(dir)
|
|
248
|
+
.then((bytes) => {
|
|
249
|
+
const next = estimateProgress(bytes, expected);
|
|
250
|
+
if (stopped || next === undefined || next === this.progress.get(agent))
|
|
251
|
+
return;
|
|
252
|
+
this.progress.set(agent, next);
|
|
253
|
+
this.options.onState?.(agent, this.info(agent));
|
|
254
|
+
})
|
|
255
|
+
// 广播失败(日志 / 连接写出出错)只影响这一次进度:不能变成未处理的 rejection 让 bridge 退出、杀掉所有 agent(契约 5)
|
|
256
|
+
.catch((error) => console.warn(`Could not report adapter install progress: ${error instanceof Error ? error.message : String(error)}`))
|
|
257
|
+
.finally(() => {
|
|
258
|
+
measuring = false;
|
|
259
|
+
});
|
|
260
|
+
}, this.options.progressIntervalMs ?? PROGRESS_INTERVAL_MS);
|
|
261
|
+
timer.unref();
|
|
262
|
+
return () => {
|
|
263
|
+
stopped = true;
|
|
264
|
+
clearInterval(timer);
|
|
265
|
+
};
|
|
266
|
+
}
|
|
146
267
|
}
|
package/dist/bridge/agents.js
CHANGED
|
@@ -12,7 +12,12 @@
|
|
|
12
12
|
import { realpathSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import { isExecutable, which } from '../system.js';
|
|
15
|
+
import { BRIDGE_AGENT_IDS } from './protocol.js';
|
|
15
16
|
export const VERSION_TIMEOUT_MS = 15_000;
|
|
17
|
+
/** 页面传来的 agent id 只能是五家之一(server.ts 与 auth-call.ts 共用) */
|
|
18
|
+
export function isBridgeAgentId(value) {
|
|
19
|
+
return typeof value === 'string' && BRIDGE_AGENT_IDS.includes(value);
|
|
20
|
+
}
|
|
16
21
|
/** claude / codex 经适配器拉起(不用检测到的本机二进制);其余三家用自带的 ACP 模式 */
|
|
17
22
|
export const LAUNCH_KIND = {
|
|
18
23
|
claude: 'adapter',
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `agent/authorize` / `agent/authorize-cancel` 的分派(change acp-terminal-free-auth,design D2 / D7 / D16),从 server.ts 拆出。
|
|
3
|
+
*
|
|
4
|
+
* 安全边界(契约 3):页面只能选 agent × 动作,params 只能恰好是规定的键,多一个(command、args、cwd、env、entry……)就 bad_message、
|
|
5
|
+
* 不静默忽略;命令计划由 service 按写死清单生成(authorize.ts)。授权链接只发给发起的连接;发起的连接关闭即取消(server.ts 的 close 监听器)。
|
|
6
|
+
* 终端日志说明页面让 bridge 做了什么与结果,**不打印链接**(带 state 等一次性参数)。
|
|
7
|
+
*/
|
|
8
|
+
import { isBridgeAgentId } from './agents.js';
|
|
9
|
+
import { BridgeFailure } from './errors.js';
|
|
10
|
+
import { BRIDGE_AUTH_ACTIONS } from './protocol.js';
|
|
11
|
+
function isAuthAction(value) {
|
|
12
|
+
return typeof value === 'string' && BRIDGE_AUTH_ACTIONS.includes(value);
|
|
13
|
+
}
|
|
14
|
+
/** params 必须恰好是这些键(自有属性,`__proto__` 之类的也算多出来的键) */
|
|
15
|
+
function hasExactKeys(params, keys) {
|
|
16
|
+
const own = Object.keys(params);
|
|
17
|
+
return own.length === keys.length && keys.every((key) => Object.hasOwn(params, key));
|
|
18
|
+
}
|
|
19
|
+
function describeOutcome(action, result) {
|
|
20
|
+
switch (result.outcome) {
|
|
21
|
+
case 'succeeded':
|
|
22
|
+
return action === 'login' ? 'signed in' : 'authorized';
|
|
23
|
+
case 'failed':
|
|
24
|
+
return result.exitCode === null ? 'failed' : `failed (exit ${result.exitCode})`;
|
|
25
|
+
case 'canceled':
|
|
26
|
+
return 'canceled';
|
|
27
|
+
case 'timed_out':
|
|
28
|
+
return 'timed out';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
export async function handleAuthCall(ctx, ownerId, method, params) {
|
|
32
|
+
const keys = method === 'agent/authorize' ? ['agent', 'action'] : ['agent'];
|
|
33
|
+
if (!hasExactKeys(params, keys))
|
|
34
|
+
throw new BridgeFailure('bad_message', `${method} takes exactly: ${keys.join(', ')}`);
|
|
35
|
+
const agent = params.agent;
|
|
36
|
+
if (!isBridgeAgentId(agent))
|
|
37
|
+
throw new BridgeFailure('unknown_agent', `unknown agent ${String(agent)}`);
|
|
38
|
+
if (method === 'agent/authorize-cancel') {
|
|
39
|
+
if (!ctx.authorizations.cancel(ownerId, agent))
|
|
40
|
+
throw new BridgeFailure('auth_not_found', `no sign-in for ${agent} in progress on this connection`);
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
const action = params.action;
|
|
44
|
+
if (!isAuthAction(action))
|
|
45
|
+
throw new BridgeFailure('bad_message', `action must be one of ${BRIDGE_AUTH_ACTIONS.join(', ')}`);
|
|
46
|
+
const what = action === 'login' ? 'sign-in' : 'M2 authorization';
|
|
47
|
+
const pending = ctx.authorizations.run({
|
|
48
|
+
ownerId,
|
|
49
|
+
agent,
|
|
50
|
+
action,
|
|
51
|
+
plan: () => ctx.service.authorizePlan(agent, action),
|
|
52
|
+
precheck: () => ctx.service.precheck(agent),
|
|
53
|
+
onLink: (link) => ctx.sendLink(ownerId, { agent, action, url: link.url, open: link.open }),
|
|
54
|
+
});
|
|
55
|
+
// 占用在 run 的同步部分已登记;此时页面若已断开(极少见),没有人会再取消它
|
|
56
|
+
if (!ctx.isConnected(ownerId))
|
|
57
|
+
ctx.authorizations.cancelOwner(ownerId);
|
|
58
|
+
ctx.log(action === 'login' ? `Page asked to sign in to ${agent}.` : `Page asked to authorize M2 for ${agent}.`);
|
|
59
|
+
try {
|
|
60
|
+
const result = await pending;
|
|
61
|
+
ctx.log(`${agent} ${what}: ${describeOutcome(action, result)}.`);
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
ctx.log(`${agent} ${what}: did not start (${error instanceof BridgeFailure ? error.code : 'internal'}).`);
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|