@m2canvas/connect 0.12.0 → 0.13.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 CHANGED
@@ -26,40 +26,22 @@ Options: `--client <id>` sets up only that client; `--dry-run` shows the plan wi
26
26
 
27
27
  After setup, restart each client so it loads the new server.
28
28
 
29
- ## Local agent bridge
29
+ ## Local agent bridge (M2 for Mac)
30
30
 
31
- ```bash
32
- npx -y @m2canvas/connect@latest bridge
33
- ```
34
-
35
- Lets M2 (in Chrome or another Chromium browser) start and talk to the agents on this machine over [ACP](https://agentclientprotocol.com): Claude Code, Codex, Cursor CLI, OpenCode and Qoder. Run it in the project directory you want the agents to work in, and keep the window open. Copy the command from M2's Local Agent tab: it adds `--pair <value>` so that page pairs by itself.
31
+ The `bridge` subcommand is the local connection that lets M2 start and talk to the agents on this machine over [ACP](https://agentclientprotocol.com): Claude Code, Codex, Cursor CLI, OpenCode and Qoder. Local agents in M2 only work inside the M2 desktop app, which runs this bridge for you in the background. Running `npx @m2canvas/connect bridge` in a terminal only explains this and exits; to add M2 to an agent client instead, run `npx @m2canvas/connect` without `bridge`.
36
32
 
37
- - **Local only.** It listens on `127.0.0.1:47821` and only accepts pages from `https://patch.design`. 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
- - **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. 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.
33
+ - **Desktop app only.** It listens on `127.0.0.1:47821` and only accepts pages from `https://patch.design` that the M2 desktop app connects: for every connection the app first gets a one-time ticket from the bridge over `~/.m2canvas/bridge-<port>.sock` (only your account can open it) and adds it to the request. A browser tab can't add it, so browsers, other sites, other machines and other hosts are refused before a connection is made. Tickets work once, expire after 60 seconds and stop working when the bridge exits; pages hold no pairing token or other credential. (`--dev` also accepts a local M2 dev server on port 5173, still only through a desktop app started for development.)
34
+ - **Connecting gives the page your agents.** A connected page can make these agents read and change files and run commands as you, the same as typing into them yourself. The first time, the desktop app asks you before it starts the bridge.
40
35
  - **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
36
  - **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
37
  - **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 (they bundle the agent: about 110 MB and 140 MB to download, 270 MB and 350 MB on disk); 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.
43
38
  - **Checking an agent before use.** Before starting an agent, M2 asks the bridge whether it is signed in and has M2 set up (commands such as `claude auth status` and `claude mcp list`; `claude mcp list` starts the MCP servers you configured in order to check them). From 0.9.0 the bridge runs this check for Claude Code and Codex once when it starts, and remembers a result that passed, so opening another project doesn't wait for it again. It checks again when you click "Check again" in M2 and when M2 adds itself to an agent or signs in for you; when a page uses a remembered result that is more than 5 minutes old, it also checks again in the background and tells M2 if the result no longer holds. There is no periodic check. Results that need you to do something are never remembered, and nothing is written to disk.
44
39
  - Cursor runs as `agent acp`, OpenCode as `opencode acp`, Qoder as `qodercli --acp`.
45
40
 
46
- Agent sessions start in that directory.
47
-
48
- 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).
49
-
50
- ## One-time install (macOS)
41
+ ## Install and updates (macOS)
51
42
 
52
- 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:
53
-
54
- ```bash
55
- curl -fsSL https://patch.design/connect/install.sh | sh -s -- --pair <value from M2>
56
- ```
57
-
58
- - **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`.
59
- - **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://patch.design/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.
60
- - **No pairing codes in the background.** A bridge started this way has no window to show a code in. When a page isn't paired yet (another browser, or the pairing expired), M2 gives it the same start command: the new bridge process sees the one already running and hands the page's pairing value to it, so the page pairs without restarting the bridge and other tabs keep their sessions. The handoff goes through `~/.m2canvas/bridge-<port>.sock` plus a one-time file in `~/.m2canvas`, so only a process that can write to that folder (your account; agent sandboxes normally can't write there) can hand a pairing value over; the file holds only the hash and is deleted right away. If that doesn't work, M2 offers to restart the bridge with `--replace`, which ends the sessions on the old one.
43
+ - **From the M2 desktop app.** The app puts a private Node.js and a pinned version of this package in `~/.m2canvas` (from its own bundle, or with `https://patch.design/connect/install.sh` as a fallback, checking every download against the hashes M2 publishes), writes the launcher `~/.m2canvas/bin/m2-bridge`, and starts the bridge in the background. Agent sessions start in `~/.m2canvas/workspace`.
44
+ - **Nothing starts at login.** The app runs the launcher when it opens. The launcher first updates the bridge to the version listed at `https://patch.design/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.
61
45
  - **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.
62
46
  - **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/`.
63
- - **Uninstall** with "Uninstall local connection" in the Local Agent tab's ⋯ menu, or `curl -fsSL https://patch.design/connect/uninstall.sh | sh`. 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. While a bridge started this way (0.8.0 or later) is running, M2 asks it to uninstall itself: it stops its agents, runs the same uninstaller (shipped in this package as `install/uninstall.sh`), reports what happened to M2, and exits. Only a paired page can ask, and it can't pass anything that changes what gets removed. Otherwise M2 offers the M2 app, an agent app or the Terminal command.
64
-
65
- The `npx` command above still works and is what M2 shows under "Advanced", for running the bridge in a folder you choose.
47
+ - **Uninstall** with "Uninstall local connection" in the Local Agent tab's ⋯ menu, or `curl -fsSL https://patch.design/connect/uninstall.sh | sh`. 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. While a bridge started this way (0.8.0 or later) is running, M2 asks it to uninstall itself: it stops its agents, runs the same uninstaller (shipped in this package as `install/uninstall.sh`), reports what happened to M2, and exits. Only a page the M2 desktop app connected can ask, and it can't pass anything that changes what gets removed. Otherwise M2 offers the M2 app, an agent app or the Terminal command.
@@ -5,7 +5,6 @@
5
5
  * (契约 14、`install/m2-bridge.sh` 头注释),已安装的 bridge 又会自动更新,所以每个版本都要保持这套接口兼容。
6
6
  */
7
7
  import { resolve } from 'node:path';
8
- import { isPairCommitment } from './pairing.js';
9
8
  import { BRIDGE_DEFAULT_PORT } from './protocol.js';
10
9
  import { DEV_ORIGINS, PROD_ORIGINS } from './security.js';
11
10
  /**
@@ -27,53 +26,50 @@ export const DEV_BACKGROUND_LIFETIME_MS = 2 * 60 * 60 * 1000;
27
26
  export function backgroundLifetimeMs(args) {
28
27
  return args.background && args.dev ? DEV_BACKGROUND_LIFETIME_MS : null;
29
28
  }
30
- export const BRIDGE_HELP = `Usage: npx @m2canvas/connect bridge [--port <n>] [--cwd <dir>] [--verbose] [--dev] [--pair <value>] [--replace]
31
- npx @m2canvas/connect bridge --unpair
29
+ export const BRIDGE_HELP = `Usage: this command is run by the M2 desktop app; you don't need to run it yourself.
32
30
 
33
- Runs the local agent bridge so M2 (in Chrome) can start and talk to the agents
31
+ Runs the local agent bridge that lets M2 for Mac start and talk to the agents
34
32
  installed on this machine: Claude Code, Codex, Cursor CLI, OpenCode and Qoder.
35
33
 
36
- It only listens on 127.0.0.1 and only accepts the M2 web app (${PROD_ORIGINS.join(', ')}).
37
- The command M2 gives you pairs that page automatically; other pages ask for the
38
- pairing code shown here. A paired page can make these agents read and change
39
- files and run commands as you, so only run commands you copied from M2.
40
- Agents run in the directory you start the bridge from (or --cwd), with your own logins.
41
- On macOS a paired page can also start an agent's own sign-in; you finish it in
42
- your browser.
34
+ It only listens on 127.0.0.1 and only accepts pages that the M2 desktop app
35
+ opens from ${PROD_ORIGINS.join(', ')}: the app authorizes each connection, so a browser tab
36
+ can't connect. A connected page can make these agents read and change files and
37
+ run commands as you.
43
38
 
44
- --port <n> port to listen on (default ${BRIDGE_DEFAULT_PORT})
45
- --cwd <dir> working directory for agent sessions (default: current directory)
46
- --verbose also print agent stderr and connection details
47
- --dev also accept a local M2 dev server (${DEV_ORIGINS.join(', ')});
48
- any app served on that port could then ask to pair
49
- --pair <value> pair the M2 page that gave you this command (M2 adds it for you;
50
- valid for 10 minutes, once)
51
- --replace if an M2 bridge (0.6.0 or later) is already running on the port,
52
- stop it first (its agent sessions end) and start this one instead;
53
- not on Windows
54
- --unpair forget all paired pages and exit
55
- -h, --help show this help
39
+ To add M2 to an agent client instead (Claude Code, Codex, Cursor, OpenCode,
40
+ Qoder), run "npx @m2canvas/connect" without "bridge".
56
41
 
57
42
  Internal (used by the M2 launcher ~/.m2canvas/bin/m2-bridge; don't run these yourself):
58
- --background run without a terminal: no pairing codes are shown; exits with
59
- code ${EXIT_ALREADY_RUNNING} if an M2 bridge is already running on the port (handing its --pair
60
- to that bridge first), ${EXIT_PORT_UNAVAILABLE} if something else holds the port; with --dev it
61
- stops by itself after 2 hours
43
+ --background run without a terminal; this is the only mode that accepts pages.
44
+ Exits with code ${EXIT_ALREADY_RUNNING} if an M2 bridge is already running on the port,
45
+ ${EXIT_PORT_UNAVAILABLE} if something else holds the port; with --dev it stops by itself after 2 hours
46
+ --port <n> port to listen on (default ${BRIDGE_DEFAULT_PORT})
47
+ --cwd <dir> working directory for agent sessions
48
+ --dev also accept a local M2 dev server (${DEV_ORIGINS.join(', ')})
49
+ --replace if an M2 bridge is already running on the port, stop it first
50
+ (its agent sessions end) and start this one instead; not on Windows
51
+ --verbose also print agent stderr and connection details
62
52
  --check-update update this installation to the version M2 publishes, then exit
63
53
  --self-test check that this installation loads completely, print its version and exit
64
54
  --stop stop the M2 bridge running on the port (only one it can recognize; it's
65
- sent SIGTERM so it cleans up), then exit`;
55
+ sent SIGTERM so it cleans up), then exit
56
+ -h, --help show this help`;
57
+ /**
58
+ * 不带 `--background` 时的说明(change bridge-client-only D7,用户 2026-11-07「去掉NPX」):终端模式发不出票据,
59
+ * 任何页面都连不上,所以直接说明并退出。不带 `bridge` 的 `npx @m2canvas/connect` 是给外接 agent 写 M2 配置的命令,不受影响。
60
+ */
61
+ export const TERMINAL_MODE_UNSUPPORTED = `Local agents in M2 now run only inside the M2 desktop app.
62
+ Open your project in M2 for Mac and connect from the Agent panel there; the app starts this bridge for you.
63
+ To add M2 to an agent client instead, run "npx @m2canvas/connect" (without "bridge").`;
66
64
  /** 解析 `bridge` 之后的参数;返回字符串表示参数错误(调用方连同帮助打印、退出码 2)。 */
67
65
  export function parseBridgeArgs(argv) {
68
- const args = { port: BRIDGE_DEFAULT_PORT, cwd: process.cwd(), verbose: false, unpair: false, help: false, dev: false, pair: null, replace: false, background: false, selfTest: false, checkUpdate: false, stop: false };
66
+ const args = { port: BRIDGE_DEFAULT_PORT, cwd: process.cwd(), verbose: false, help: false, dev: false, legacyPair: false, replace: false, background: false, selfTest: false, checkUpdate: false, stop: false };
69
67
  for (let i = 0; i < argv.length; i++) {
70
68
  const arg = argv[i];
71
69
  if (arg === '-h' || arg === '--help')
72
70
  args.help = true;
73
71
  else if (arg === '--verbose')
74
72
  args.verbose = true;
75
- else if (arg === '--unpair')
76
- args.unpair = true;
77
73
  else if (arg === '--dev')
78
74
  args.dev = true;
79
75
  else if (arg === '--replace')
@@ -87,12 +83,10 @@ export function parseBridgeArgs(argv) {
87
83
  else if (arg === '--stop')
88
84
  args.stop = true;
89
85
  else if (arg === '--pair') {
90
- const value = argv[++i];
91
- if (value === undefined)
86
+ // 值照收不验:旧启动器已按自己的规则校验过;这里拒绝任何值都会让启动器把新版本拉黑(见 BridgeArgs.legacyPair)
87
+ if (argv[++i] === undefined)
92
88
  return '--pair needs a value';
93
- if (!isPairCommitment(value))
94
- return `invalid --pair value ${value}; copy the start command from M2 again`;
95
- args.pair = value;
89
+ args.legacyPair = true;
96
90
  }
97
91
  else if (arg === '--port' || arg === '--cwd') {
98
92
  const value = argv[++i];
@@ -1,26 +1,19 @@
1
1
  /**
2
- * bridge 方法的分派(从 server.ts 拆出):未配对只放行 pair,其余按方法交给 service / ProcessManager / auth-call.ts。
3
- * 连接状态(是否已配对、连接是否还在)只在 server.ts 里改,这里经 ctx 的回调读写。
2
+ * bridge 方法的分派(从 server.ts 拆出):按方法交给 service / ProcessManager / auth-call.ts。
3
+ * 连接状态(怎么认证的、连接是否还在)只在 server.ts 里改,这里经 ctx 的回调读。
4
4
  *
5
- * 安全边界同 server.ts 文件头:页面可控的参数只有 agent id(枚举校验)、配对码、processId,进不了 spawn 的命令、参数、cwd、环境;
5
+ * 安全边界同 server.ts 文件头:页面可控的参数只有 agent id(枚举校验)、processId,进不了 spawn 的命令、参数、cwd、环境;
6
6
  * agent/authorize 与 agent/authorize-cancel 的参数校验在 auth-call.ts。
7
7
  * bridge/uninstall 不经过这里:server.ts 在调用 handleCall 之前就把它交给 uninstall-call.ts(它要等回复写出再关连接、退出)。
8
8
  */
9
9
  import { isBridgeAgentId } from './agents.js';
10
10
  import { handleAuthCall } from './auth-call.js';
11
- import { handlePairCall } from './pair-call.js';
12
- import { TOKEN_IDLE_DAYS } from './pairing.js';
13
11
  import { ProcessError } from './processes.js';
14
12
  import { BridgeFailure } from './service.js';
15
13
  export async function handleCall(ctx, conn, id, method, params) {
16
- if (method === 'pair') {
17
- const token = handlePairCall(ctx.pairing, conn.origin, params);
18
- ctx.markPaired(conn.id);
19
- ctx.log(`Paired with ${conn.origin}.`);
20
- return { token, cwd: ctx.cwd, idleExpiryDays: TOKEN_IDLE_DAYS };
21
- }
22
- if (!conn.paired)
23
- throw new BridgeFailure('not_paired', 'pair this page first');
14
+ // 纵深判断:upgrade 只放行带有效票据的连接(server.ts 也在调用前判过一次)
15
+ if (conn.auth !== 'client-ticket')
16
+ throw new BridgeFailure('not_paired', 'this connection was not authorized by the M2 desktop app');
24
17
  if (method === 'process/stop') {
25
18
  const processId = params.processId;
26
19
  if (typeof processId !== 'string' || !ctx.processes.stop(conn.id, processId))
@@ -5,7 +5,8 @@
5
5
  * (脚本不能 import 这里)。改这里要同批改那两处;脚本测试会按这里的路径核对它们的产物。
6
6
  *
7
7
  * 全部在用户主目录下,不写任何系统目录、不注册任何启动项、不改 PATH(`connect/local-bridge` 契约 14 delta)。
8
- * 其余已有文件(配对记录 `bridge-pairings.json`、状态文件 `bridge-<port>.json`、适配器 `acp-adapters/`)位置不变。
8
+ * 其余已有文件(状态文件 `bridge-<port>.json`、票据 socket `bridge-<port>.sock`、适配器 `acp-adapters/`)位置不变;旧版本的配对记录
9
+ * `bridge-pairings.json` 由新版本 bridge 启动时删除(change bridge-client-only D4)。
9
10
  */
10
11
  import { join } from 'node:path';
11
12
  export function installLayout(home) {
@@ -3,29 +3,30 @@
3
3
  *
4
4
  * 只由 cli.ts 在第一个参数是 `bridge` 时动态加载:默认的 MCP 配置流程(Copy to agent 起手式)不会加载这里的任何代码与依赖。
5
5
  *
6
- * 本文件负责组装与终端交互:启动横幅、配对码打印、信号处理、退出兜底清理。参数与帮助在 args.ts,协议在 server.ts,安全判定在 security.ts / pairing.ts。
7
- * 终端输出用英文,与 connect 现有输出一致。
6
+ * 本文件负责组装:启动横幅、信号处理、退出兜底清理。参数与帮助在 args.ts,协议在 server.ts,安全判定在 security.ts / ticket.ts。
7
+ * 输出用英文,与 connect 现有输出一致。
8
8
  *
9
- * 后台模式(`--background`,只由启动器 `~/.m2canvas/bin/m2-bridge` 使用,change acp-bridge-one-time-install):输出写进日志文件,
10
- * 所以不出配对码(契约 2)、不提示「保持窗口打开」;页面经 `welcome.bridge.background` 得知,改走「重新连接」。
11
- * 端口上已有认定的 M2 bridge 时以 EXIT_ALREADY_RUNNING 退出,启动器把它当成功。
12
- * 后台模式且在 macOS 上时还能卸载自己(`bridge/uninstall`,change bridge-uninstall-from-page D2 / D3;执行在 uninstall.ts)。
9
+ * 只剩后台模式(`--background`,由启动器 `~/.m2canvas/bin/m2-bridge` 使用;M2 客户端运行启动器)。change bridge-client-only 起:
10
+ * - 页面只能经 M2 客户端主进程替它领的一次性票据连上(D2 / D4)。票据从本进程监听的 `~/.m2canvas/bridge-<port>.sock` 领(ticket-socket.ts);
11
+ * socket 建不起来时退出,不留一个谁也连不上的进程(F7)。
12
+ * - 不带 `--background` 时直接说明「只能在 M2 客户端里用」并退出(D7,用户 2026-11-07「去掉NPX」)。
13
+ * - 没有配对:`--pair` 只为启动器兼容而接受(F1),拿到端口之后删掉遗留的 `bridge-pairings.json`。
14
+ * 输出写进日志文件,所以不能出现票据。端口上已有认定的 M2 bridge 时以 EXIT_ALREADY_RUNNING 退出,启动器把它当成功。
15
+ * 在 macOS 上还能卸载自己(`bridge/uninstall`,change bridge-uninstall-from-page D2 / D3;执行在 uninstall.ts)。
13
16
  */
14
- import { existsSync, readFileSync, statSync } from 'node:fs';
17
+ import { existsSync, readFileSync, rmSync, statSync } from 'node:fs';
15
18
  import { join } from 'node:path';
16
19
  import { fileURLToPath } from 'node:url';
17
20
  import { home, isExecutable, which } from '../system.js';
18
21
  import { AdapterManager, ADAPTERS, isAdapterAgent } from './adapters.js';
19
- import { BRIDGE_HELP, EXIT_ALREADY_RUNNING, EXIT_PORT_UNAVAILABLE, backgroundLifetimeMs, parseBridgeArgs } from './args.js';
22
+ import { BRIDGE_HELP, EXIT_ALREADY_RUNNING, EXIT_PORT_UNAVAILABLE, TERMINAL_MODE_UNSUPPORTED, backgroundLifetimeMs, parseBridgeArgs } from './args.js';
20
23
  import { detectAgent } from './agents.js';
21
24
  import { EXPECT_PATH } from './authorize.js';
22
25
  import { buildAgentEnv, buildNpmEnv, buildProbeEnv } from './env.js';
23
26
  import { execCommand, killActiveExecs } from './exec.js';
24
27
  import { createLsofProbe, createPsProbe, createStopDeps, identifyRunningBridge, instanceFilePath, listenWithReplace, processStartedAt, removeInstanceRecord, stopRunningBridge, writeInstanceRecord } from './instance.js';
25
- import { handOffCommitment, listenForHandoff } from './handoff.js';
26
28
  import { installLayout } from './install-layout.js';
27
29
  import { installShutdownSignals } from './lifecycle.js';
28
- import { PairingManager, createFilePairingStore } from './pairing.js';
29
30
  import { BRIDGE_AGENT_IDS, BRIDGE_WS_PATH } from './protocol.js';
30
31
  import { resolveNpmCommand } from './npm-command.js';
31
32
  import { allowedOrigins } from './security.js';
@@ -35,6 +36,8 @@ import { runCheckUpdate } from './update-io.js';
35
36
  import { startBridgeServer } from './server.js';
36
37
  import { createAgentService } from './service.js';
37
38
  import { KILL_GRACE_MS } from './processes.js';
39
+ import { TicketBook } from './ticket.js';
40
+ import { listenForTicketRequests, ticketSocketPathProblem } from './ticket-socket.js';
38
41
  import { createSelfUninstaller, uninstallScriptPath } from './uninstall.js';
39
42
  const NPM_CI_TIMEOUT_MS = 15 * 60 * 1000;
40
43
  /** 启动时预热预检的 agent(契约 18;用户 2026-11-04 拍板 B-P1:随包的两家,不管用没用过) */
@@ -53,31 +56,24 @@ function packageVersion() {
53
56
  const version = typeof parsed === 'object' && parsed !== null ? parsed.version : undefined;
54
57
  return typeof version === 'string' ? version : '0.0.0';
55
58
  }
56
- function printCode(info) {
57
- const until = new Date(info.expiresAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
58
- const code = `Pairing code: ${info.display} (valid until ${until})`;
59
- if (info.reason === 'startup')
60
- console.log(`\n ${code}\n Enter it in M2 when it asks to connect to this bridge.\n`);
61
- else if (info.reason === 'requested')
62
- console.log(`\n→ ${info.origin ?? 'A page'} wants to pair with this bridge. ${code}\n Only enter it if you opened M2 yourself.\n`);
63
- else if (info.reason === 'expired')
64
- console.log(`\n→ The pairing code expired. New ${code}\n`);
65
- else
66
- console.log(`\n→ Too many wrong pairing codes${info.origin ? ` from ${info.origin}` : ''}. New ${code}\n`);
67
- }
68
59
  function clock(at) {
69
60
  return new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
70
61
  }
71
- /** 启动命令里的配对已被用掉,同一个密钥又出现:同一个 M2 标签页被复制 / 连接途中刷新,或者别的程序抢先配对了(review r1 S2) */
72
- function printReused(info) {
73
- console.log(`\n→ Warning: the pairing in your start command was already used (by ${info.firstOrigin} at ${clock(info.usedAt)}), ` +
74
- `and ${info.origin} just presented it again. If you didn't duplicate or reload that M2 tab while it was connecting, another program on this machine ` +
75
- `may have paired with this bridge: press Ctrl+C, run "npx @m2canvas/connect bridge --unpair", then start the bridge again.\n`);
76
- }
77
- function printLocked(origin) {
78
- const from = origin ? ` (the last ones came from ${origin})` : '';
79
- console.log(`\n→ Too many wrong pairing codes${from}. New pages can't pair until you restart the bridge (Ctrl+C, then run it again); ` +
80
- `pages that are already paired keep working.${origin ? ` If you didn't open ${origin}, close that page first.` : ''}\n`);
62
+ /**
63
+ * 拿到端口之后删掉旧版本留下的配对记录(change bridge-client-only D4):里面只有哈希,删掉是为了不留下「还能用」的误解。
64
+ * 必须在拿到端口之后:端口上可能还是旧版本的 bridge,它每次校验 token 都重读这个文件。删不掉只记日志。
65
+ */
66
+ function removeLegacyPairings(m2Dir, log) {
67
+ const path = join(m2Dir, 'bridge-pairings.json');
68
+ if (!existsSync(path))
69
+ return;
70
+ try {
71
+ rmSync(path, { force: true });
72
+ log('Removed the pairing records left by an older version; pages now connect through the M2 desktop app.');
73
+ }
74
+ catch (error) {
75
+ log(`Couldn't remove ${path} (${error instanceof Error ? error.message : String(error)}); it isn't used anymore.`);
76
+ }
81
77
  }
82
78
  function describeAdapter(agent, info) {
83
79
  const name = `${LABELS[agent]} adapter (${ADAPTERS[agent].package}@${ADAPTERS[agent].version})`;
@@ -129,34 +125,31 @@ export async function runBridge(argv) {
129
125
  return runCheckUpdate();
130
126
  if (parsed.stop)
131
127
  return stopRecognizedBridge(parsed.port);
132
- const m2Dir = join(home, '.m2canvas');
133
- const store = createFilePairingStore(join(m2Dir, 'bridge-pairings.json'), (message) => console.warn(message));
134
- if (parsed.unpair) {
135
- store.clear();
136
- console.log('Forgot all paired pages. M2 will ask for a new pairing code next time.');
137
- return 0;
128
+ // 终端模式发不出票据,任何页面都连不上(D7):说明后退出,不占端口、不写状态文件
129
+ if (!parsed.background) {
130
+ console.error(TERMINAL_MODE_UNSUPPORTED);
131
+ return 1;
138
132
  }
133
+ const m2Dir = join(home, '.m2canvas');
139
134
  if (!existsSync(parsed.cwd) || !statSync(parsed.cwd).isDirectory()) {
140
135
  console.error(`--cwd ${parsed.cwd} is not a directory.`);
141
136
  return 2;
142
137
  }
143
138
  const log = (message) => console.log(`[${new Date().toLocaleTimeString()}] ${message}`);
139
+ // 票据 socket 的路径放不进 sun_path 时,开始监听之前就退出:不去占端口,也不会 --replace 掉正在运行的那个(F7)
140
+ const socketProblem = ticketSocketPathProblem(m2Dir, parsed.port);
141
+ if (socketProblem) {
142
+ log(`The M2 desktop app can't reach this bridge: ${socketProblem}. Not starting.`);
143
+ return 1;
144
+ }
145
+ if (parsed.legacyPair)
146
+ log('Ignoring --pair: pages no longer pair with this bridge; the M2 desktop app authorizes each connection.');
144
147
  const probeEnv = buildProbeEnv();
145
- // 后台模式用私有 Node 自带的 npm(目标用户 PATH 上可能根本没有 npm,R3);终端模式照旧用 PATH 上的
148
+ // 后台模式用私有 Node 自带的 npm(目标用户 PATH 上可能根本没有 npm,R3)
146
149
  const npm = resolveNpmCommand({ background: parsed.background, execPath: process.execPath, platform: process.platform, which });
147
150
  let server = null;
148
- // 配对锁定只能靠重启 bridge 解除:bridge 不读终端(放到后台时读终端会被系统挂起),页面也没有任何途径解锁
149
- const pairing = new PairingManager({
150
- store,
151
- // 后台模式没有终端、输出写进日志文件:不出码(契约 2「码只出现在终端」),配对只剩命令承诺与已有 token(D17 / D18)
152
- issueCodes: !parsed.background,
153
- onCodeIssued: printCode,
154
- onLocked: printLocked,
155
- onProblem: (message) => console.warn(message),
156
- onCommitmentReused: printReused,
157
- });
158
- // 承诺必须在开始监听之前挂上:等待中的页面每 2 秒探测一次,监听一开始就可能连上;晚挂一步它就撞上 no_commitment、落到输码
159
- const pairUntil = parsed.pair ? pairing.armCommitment(parsed.pair) : null;
151
+ // 票据只在本进程内存里(ticket.ts):进程退出全部失效
152
+ const tickets = new TicketBook();
160
153
  // 进度更新(D12)也经 onState 广播,终端只在状态变化时打一行
161
154
  const loggedState = new Map();
162
155
  const adapters = new AdapterManager({
@@ -203,10 +196,8 @@ export async function runBridge(argv) {
203
196
  // 端口被占时认出正在运行的 M2 bridge,给出 pid 与终端,或在 --replace 时接管(D20)。状态文件不含任何密钥
204
197
  const instancePath = instanceFilePath(m2Dir, parsed.port);
205
198
  let alreadyRunning = false;
206
- /** 最近一次因 Origin 不在白名单被拒的页面(desktop-bridge-launcher D15):交接 socket 的状态查询带回给 M2 Mac 客户端 */
207
- let lastRejected = null;
208
199
  // 只有启动器从 ~/.m2canvas 拉起的后台 bridge、且在 macOS 上(后台安装只有 macOS,契约 15)才能卸载自己(D2)。
209
- // 回调里用到的 handoff / stop 在下面才赋值:卸载只会在服务开始监听、它们都已就绪之后被调用
200
+ // 回调里用到的 ticketSocket / stop 在下面才赋值:卸载只会在服务开始监听、它们都已就绪之后被调用
210
201
  const uninstaller = parsed.background && process.platform === 'darwin'
211
202
  ? createSelfUninstaller({
212
203
  scriptPath: uninstallScriptPath(PACKAGE_ROOT),
@@ -218,7 +209,7 @@ export async function runBridge(argv) {
218
209
  stopInstalling: () => adapters.stopInstalling(),
219
210
  killExecs: killActiveExecs,
220
211
  forgetSelf: () => removeInstanceRecord(instancePath, process.pid),
221
- closeHandoff: () => handoff?.close(),
212
+ closeTicketSocket: () => ticketSocket?.close(),
222
213
  exit: () => void stop(),
223
214
  })
224
215
  : undefined;
@@ -233,14 +224,11 @@ export async function runBridge(argv) {
233
224
  node: process.versions.node,
234
225
  ...(parsed.background ? { background: true } : {}),
235
226
  },
236
- pairing,
227
+ tickets,
237
228
  service,
238
229
  agentEnv: buildAgentEnv(),
239
230
  allowedOrigins: origins,
240
231
  uninstall: uninstaller,
241
- onOriginRejected: (origin) => {
242
- lastRejected = { origin, at: Date.now() };
243
- },
244
232
  log,
245
233
  verbose: parsed.verbose,
246
234
  }), {
@@ -257,14 +245,9 @@ export async function runBridge(argv) {
257
245
  alreadyRunning = true;
258
246
  },
259
247
  });
260
- // listenWithReplace 返回 null 的情况都与端口有关(已在运行、被别的程序占着、停不掉旧的);后台模式把「已在运行」与其余分开。
261
- // 已在运行时先把承诺交给它,新页面不用重启 bridge 就能配对(D27);交不过去也照常退出码 3
262
- if (!server && parsed.background && alreadyRunning && parsed.pair) {
263
- const handed = await handOffCommitment({ dir: m2Dir, port: parsed.port, commitment: parsed.pair });
264
- log(handed ? 'Handed the pairing to the M2 bridge already running on this port; the new page pairs with it.' : "Couldn't hand the pairing to the M2 bridge already running on this port; the new page can reconnect with --replace.");
265
- }
248
+ // listenWithReplace 返回 null 的情况都与端口有关(已在运行、被别的程序占着、停不掉旧的):把「已在运行」与其余分开
266
249
  if (!server)
267
- return parsed.background ? (alreadyRunning ? EXIT_ALREADY_RUNNING : EXIT_PORT_UNAVAILABLE) : 1;
250
+ return alreadyRunning ? EXIT_ALREADY_RUNNING : EXIT_PORT_UNAVAILABLE;
268
251
  const running = server;
269
252
  try {
270
253
  // startedAt 必须是进程启动时刻:--replace 时要等旧的退出后才写,取写文件时刻会晚出几百毫秒到几秒,下一次 --replace 就认不出它
@@ -273,8 +256,10 @@ export async function runBridge(argv) {
273
256
  catch (error) {
274
257
  console.warn(`Couldn't write ${instancePath} (${error instanceof Error ? error.message : String(error)}); --replace won't recognize this bridge.`);
275
258
  }
276
- // 交接 socket(D27)在装好信号处理之后才开始监听:中间有 await,这段时间收到 SIGTERM 也要走清理
277
- let handoff = null;
259
+ // 端口已经是自己的:旧版本的 bridge 不会再读配对文件了
260
+ removeLegacyPairings(m2Dir, log);
261
+ // 票据 socket 在装好信号处理之后才开始监听:中间有 await,这段时间收到 SIGTERM 也要走清理
262
+ let ticketSocket = null;
278
263
  // 兜底:bridge 经 process.exit 或未捕获异常退出时,不留下 agent 进程组和仍在运行的命令。
279
264
  // 注意 SIGHUP / SIGINT / SIGTERM 的默认处理会直接终止进程、不触发 'exit',所以这三个信号由 lifecycle.ts 显式接管。
280
265
  process.on('exit', () => {
@@ -286,7 +271,7 @@ export async function runBridge(argv) {
286
271
  // 见上
287
272
  }
288
273
  try {
289
- handoff?.close();
274
+ ticketSocket?.close();
290
275
  }
291
276
  catch {
292
277
  // 残留的 socket 文件由下一次启动删掉
@@ -304,7 +289,7 @@ export async function runBridge(argv) {
304
289
  const exited = new Promise((resolve) => {
305
290
  resolveExit = resolve;
306
291
  });
307
- const stop = async () => {
292
+ const stop = async (code = 0) => {
308
293
  log('Stopping the bridge and its agents…');
309
294
  // 一次性命令(npm ci、预检、检测、授权)没有「正常收尾」可言,先同步杀掉,不等 close。
310
295
  // 授权先标记取消并删临时目录,被杀的授权命令不会被当成失败再去重跑预检
@@ -315,7 +300,7 @@ export async function runBridge(argv) {
315
300
  while (running.processes.size > 0 && Date.now() < deadline)
316
301
  await new Promise((r) => setTimeout(r, 100));
317
302
  running.processes.killAllNow();
318
- resolveExit(0);
303
+ resolveExit(code);
319
304
  };
320
305
  installShutdownSignals(process, {
321
306
  graceful: () => void stop(),
@@ -327,14 +312,12 @@ export async function runBridge(argv) {
327
312
  process.exit(130);
328
313
  },
329
314
  });
330
- if (parsed.background) {
331
- handoff = await listenForHandoff({
332
- dir: m2Dir,
333
- port: parsed.port,
334
- arm: (c) => pairing.armCommitment(c),
335
- status: (c) => ({ ...pairing.commitmentStatus(c), ...(lastRejected ? { rejected: lastRejected } : {}) }),
336
- log,
337
- });
315
+ ticketSocket = await listenForTicketRequests({ dir: m2Dir, port: parsed.port, issue: () => tickets.issue(), log });
316
+ if (!ticketSocket) {
317
+ // 没有 socket 就领不到票据,任何页面都连不上:退出,让启动器与客户端看到「没在运行」而不是「已在运行」(F7)
318
+ log('No page can connect without the ticket socket; stopping.');
319
+ await stop(1);
320
+ return exited;
338
321
  }
339
322
  // 预热(契约 18,用户 2026-11-04 拍板 B-P1):信号处理装好之后再起,收尾时 killActiveExecs 能杀掉它的命令;不等它,页面来查会加入
340
323
  void prewarmPrechecks({
@@ -353,22 +336,10 @@ export async function runBridge(argv) {
353
336
  console.log(`M2 local agent bridge ${version}`);
354
337
  console.log(` Listening on ws://127.0.0.1:${running.port}${BRIDGE_WS_PATH} (this machine only)`);
355
338
  console.log(` Working directory for agents: ${parsed.cwd}`);
356
- console.log(` Accepts pages from: ${origins.join(', ')}`);
357
- if (parsed.dev)
358
- console.log(' --dev: any app on the dev port can ask to pair. Only enter the code into your own M2 dev server.');
359
- // 带 --pair(命令是从 M2 页面复制的):主路径上用户不需要启动码,打印出来反而让人以为要去输码;
360
- // 页面对不上承诺时会以未配对状态连上,那时照常出码并带上发起方 Origin
361
- if (parsed.background)
362
- console.log(' Started in the background by the M2 launcher: no pairing codes are shown; pair from M2 with a new start command.');
339
+ console.log(` Accepts only pages the M2 desktop app authorizes, from: ${origins.join(', ')}`);
363
340
  if (lifetime !== null)
364
341
  console.log(` Dev mode in the background: stops by itself at ${clock(Date.now() + lifetime)}.`);
365
- if (pairUntil !== null)
366
- console.log(` Waiting for the M2 page that gave you this command (until ${clock(pairUntil)})…`);
367
- else if (!parsed.background)
368
- pairing.issueCode('startup');
369
342
  const found = await Promise.all(BRIDGE_AGENT_IDS.map(detect));
370
343
  console.log(` Agents: ${found.map((agent) => `${LABELS[agent.id]} ${agent.installed ? '✓' : '–'}`).join(' ')}`);
371
- if (!parsed.background)
372
- console.log(' Keep this window open while you use local agents in M2. Press Ctrl+C to stop.\n');
373
344
  return exited;
374
345
  }
@@ -11,7 +11,11 @@
11
11
  * bridge 至少同时支持前一个版本。页面必须忽略不认识的字段 / 事件,把不认识的 state 当成 unknown。
12
12
  * - bridge 不翻译 ACP:agent → 页面的 `acp` 行原样转发(不保证是 JSON);页面 → agent 的行只做方法白名单、会话范围与无人值守模式检查(acp-guard.ts),
13
13
  * 翻译全部在页面做。
14
- * - 配对 = 授权该页面以用户的身份驱动本机 agent(包括让它执行命令);页面代码与 token 的保管要按这个级别对待。
14
+ * - 连上本机连接 = 授权该页面以用户的身份驱动本机 agent(包括让它执行命令)。change bridge-client-only 起只有 M2 客户端主进程
15
+ * 替页面加了一次性票据(`BRIDGE_TICKET_HEADER`)的握手才被接受,页面不持有任何本机连接凭证;配对整体删掉(hello 的
16
+ * `token` / `pairSecret`、`pair` 方法、`pairing_*` 错误码、`tooManyUnpaired`、welcome 的 `token`),类型随页面一起删了(1a 阶段 B)。
17
+ * 不升协议版本、也不同时支持前一版本:这是契约 9「同版本只加法」经用户拍板的例外(U5 / F6,不兼容旧版本)。
18
+ * welcome 的 `paired` / `tokenRejected` 本机连接仍按旧形状发出(恒为 true / false),页面不再读。
15
19
  */
16
20
  /** 本 bridge 支持的协议版本(页面在 hello 里给出自己支持的列表,bridge 选双方共有的最大值) */
17
21
  export const BRIDGE_PROTOCOL_VERSION = 1;
@@ -19,6 +23,12 @@ export const BRIDGE_SUPPORTED_PROTOCOLS = [1];
19
23
  /** 默认端口与路径。页面必须连 127.0.0.1(不是 localhost:localhost 可能先解析到 ::1,bridge 不监听 IPv6)。 */
20
24
  export const BRIDGE_DEFAULT_PORT = 47821;
21
25
  export const BRIDGE_WS_PATH = '/bridge';
26
+ /**
27
+ * M2 客户端主进程替页面加进 WebSocket 握手的一次性票据请求头(change bridge-client-only D3 / D4 起)。
28
+ * 票据从后台模式的 `~/.m2canvas/bridge-<port>.sock` 领(请求 `{"type":"ticket"}`)。页面代码不会、也不能设置它:
29
+ * 浏览器的 WebSocket API 加不了自定义请求头。客户端那边各存一份常量,由防漂移单测对照这里(mac-client 契约 11)。
30
+ */
31
+ export const BRIDGE_TICKET_HEADER = 'X-M2-Bridge-Ticket';
22
32
  /** 与前端 src/lib/agent-types 的 AgentType 取值一致(是它的子集),顺序即 agents/list 的返回顺序 */
23
33
  export const BRIDGE_AGENT_IDS = ['claude', 'codex', 'cursor', 'opencode', 'qoder'];
24
34
  /**
@@ -64,7 +74,6 @@ export const BRIDGE_UNATTENDED_MODE_VALUES = {
64
74
  qoder: ['yolo'],
65
75
  };
66
76
  export const BRIDGE_METHODS = [
67
- 'pair',
68
77
  'agents/list',
69
78
  'agent/prepare',
70
79
  'agent/precheck',
@@ -86,5 +95,4 @@ export const BRIDGE_CLOSE_CODES = {
86
95
  badHandshake: 4400,
87
96
  timeout: 4408,
88
97
  protocolUnsupported: 4426,
89
- tooManyUnpaired: 4429,
90
98
  };
@@ -1,22 +1,5 @@
1
- /**
2
- * 本机 ACP bridge 的连接准入判定(纯函数)。在 HTTP upgrade 阶段、完成 WebSocket 握手之前调用。
3
- *
4
- * 这里管的是「谁能连上来」:Origin 白名单与只接受本机连接(第三道「配对 token」在 pairing.ts)。
5
- * 浏览器这层不可依赖:开了本机代理的用户,Chrome 不会弹本地网络访问框(ACP PoC 报告 §3),
6
- * 所以任何网站都能尝试连 127.0.0.1,只能靠 bridge 自己挡。
7
- * 连上并配对之后页面能做什么,见 server.ts / acp-guard.ts 的文件头:配对 = 授权该页面驱动本机 agent。
8
- *
9
- * 维护约束:
10
- * - Origin 必须精确匹配(scheme + host + port 全等)。不要改成后缀匹配、正则或「包含主站主机名」——
11
- * `https://<主站主机>.evil.com`、`https://evil.<主站主机>` 都必须被拒。名单作为参数传入:名单为空即全部拒绝。
12
- * - 默认只放行主站。开发域名只在 `bridge --dev` 时放行:`localhost:5173` 是所有本机 Vite 项目共用的 Origin、
13
- * 共用 localStorage,给开发版 M2 配对过后,任何跑在 5173 的项目(包括它的依赖)都能读走 token 免码连上
14
- * (review r3 B2,用户 2026-10-02 拍板改为 --dev,推翻了最初的「开发域名常开」)。名单写死在包里(主站 origin 在
15
- * `site-config.ts`),不提供任意扩展参数。
16
- * - Host 校验防 DNS rebinding:攻击者域名解析到 127.0.0.1 时,Host 头是攻击者域名。
17
- */
18
1
  import { PROD_ORIGINS } from '../site-config.js';
19
- import { BRIDGE_WS_PATH } from './protocol.js';
2
+ import { BRIDGE_TICKET_HEADER, BRIDGE_WS_PATH } from './protocol.js';
20
3
  export { PROD_ORIGINS };
21
4
  /** 本地开发 M2 用(Vite 默认端口);只有 `bridge --dev` 才放行 */
22
5
  export const DEV_ORIGINS = ['http://localhost:5173', 'http://127.0.0.1:5173'];
@@ -34,13 +17,19 @@ export function isAllowedHost(host, port) {
34
17
  export function isLoopbackAddress(address) {
35
18
  return address === '127.0.0.1' || address === '::ffff:127.0.0.1' || address === '::1';
36
19
  }
37
- const MAX_REPORTED_ORIGIN_CHARS = 200;
20
+ /** Node 把请求头名转成小写;同名头出现多次时自定义头会被拼成一个字符串(再由票据格式挡掉),数组一律当没有 */
21
+ export function readTicketHeader(headers) {
22
+ const value = headers[BRIDGE_TICKET_HEADER.toLowerCase()];
23
+ return typeof value === 'string' ? value : undefined;
24
+ }
38
25
  /**
39
26
  * 拒绝时返回 HTTP 状态,由调用方直接写回并销毁 socket,不完成握手。
40
- * 403 而不是「握手后再关」:不对非白名单页面完成任何握手。注意这挡不住「探测 bridge 是否在运行」——
27
+ * 403 / 401 而不是「握手后再关」:不对非白名单页面、没有票据的连接完成任何握手。注意这挡不住「探测 bridge 是否在运行」——
41
28
  * 任何网站用 no-cors 请求 http://127.0.0.1:<port>/ 都能分辨「有响应」与「连接被拒」,只暴露这一点信息。
29
+ *
30
+ * `redeem` 核对并用掉票据(TicketBook.redeem);只在前三条都通过后调用。
42
31
  */
43
- export function checkUpgrade(info, port, origins) {
32
+ export function checkUpgrade(info, port, origins, redeem) {
44
33
  const pathname = (info.path ?? '').split('?')[0];
45
34
  if (pathname !== BRIDGE_WS_PATH)
46
35
  return { ok: false, status: 404, reason: `unknown path ${pathname}` };
@@ -48,10 +37,11 @@ export function checkUpgrade(info, port, origins) {
48
37
  return { ok: false, status: 403, reason: `remote ${info.remoteAddress ?? 'unknown'}` };
49
38
  if (!isAllowedHost(info.host, port))
50
39
  return { ok: false, status: 403, reason: `host ${info.host ?? 'missing'}` };
51
- if (!isAllowedOrigin(info.origin, origins)) {
52
- if (info.origin === undefined)
53
- return { ok: false, status: 403, reason: 'origin missing' };
54
- return { ok: false, status: 403, reason: `origin ${info.origin}`, rejectedOrigin: info.origin.slice(0, MAX_REPORTED_ORIGIN_CHARS) };
55
- }
56
- return { ok: true };
40
+ if (!isAllowedOrigin(info.origin, origins))
41
+ return { ok: false, status: 403, reason: info.origin === undefined ? 'origin missing' : `origin ${info.origin}` };
42
+ if (info.ticket === undefined)
43
+ return { ok: false, status: 401, reason: 'missing ticket' };
44
+ if (!redeem(info.ticket))
45
+ return { ok: false, status: 401, reason: 'invalid ticket' };
46
+ return { ok: true, auth: 'client-ticket' };
57
47
  }