diff --git a/docs/IOS_COMPANION_PLAN.md b/docs/IOS_COMPANION_PLAN.md index a375f67..17eca0f 100644 --- a/docs/IOS_COMPANION_PLAN.md +++ b/docs/IOS_COMPANION_PLAN.md @@ -376,6 +376,8 @@ GET/POST /api/control/policy # {remoteControl:bool, remoteAdoptExternal:bool} // 不含 prompt/模型回复/完整命令/文件内容/PTY 输出。E2E 时这部分是密文。 ``` +**深链(已落地,ntfy 侧)**:agent 事件推送带一个 `Click` 深链 `lisapocket://session?agent=&id=`(`src/web/push.ts` 的 `agentDeepLink`),点通知直接跳到对应 roster session。iOS 侧需注册 `lisapocket://` scheme 并路由(待建,见 Appendix B `Push/`)。同一 scheme 复用于主屏 Widget 点按。 + --- ## 7. 隐私与安全(守住地板) diff --git a/package-lock.json b/package-lock.json index dd7505a..0c483c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,8 @@ "@anthropic-ai/sdk": "^0.92.0", "@google/genai": "^2.0.1", "@modelcontextprotocol/sdk": "^1.29.0", - "node-pty": "*", "openai": "^6.35.0", + "qrcode-terminal": "^0.12.0", "undici": "^8.2.0" }, "bin": { @@ -21,6 +21,7 @@ }, "devDependencies": { "@types/node": "^22.10.0", + "@types/qrcode-terminal": "^0.12.2", "sharp": "^0.34.5", "tsx": "^4.21.0", "typescript": "^5.7.0" @@ -1153,6 +1154,13 @@ "undici-types": "~6.21.0" } }, + "node_modules/@types/qrcode-terminal": { + "version": "0.12.2", + "resolved": "https://registry.npmjs.org/@types/qrcode-terminal/-/qrcode-terminal-0.12.2.tgz", + "integrity": "sha512-v+RcIEJ+Uhd6ygSQ0u5YYY7ZM+la7GgPbs0V/7l/kFs2uO4S8BcIUEMoP7za4DNIqNnUD5npf0A/7kBhrCKG5Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", @@ -2340,6 +2348,14 @@ "node": ">= 0.10" } }, + "node_modules/qrcode-terminal": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, "node_modules/qs": { "version": "6.15.1", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", diff --git a/package.json b/package.json index 83af806..29e6530 100644 --- a/package.json +++ b/package.json @@ -69,10 +69,12 @@ "@google/genai": "^2.0.1", "@modelcontextprotocol/sdk": "^1.29.0", "openai": "^6.35.0", + "qrcode-terminal": "^0.12.0", "undici": "^8.2.0" }, "devDependencies": { "@types/node": "^22.10.0", + "@types/qrcode-terminal": "^0.12.2", "sharp": "^0.34.5", "tsx": "^4.21.0", "typescript": "^5.7.0" diff --git a/src/cli.ts b/src/cli.ts index 10fd49f..eadc84d 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -66,6 +66,8 @@ INSPECTION revoke-all. lisa sense [list] Recent ambient sense events + granted signals. lisa agents Snapshot of agent sessions across all observers. + lisa pair [--host H] Show a QR to pair a phone (Lisa Pocket) — mints a + per-device token via a running serve (localhost). LIFECYCLE lisa birth Run the birth ritual (auto-runs on first launch). @@ -172,7 +174,8 @@ interface ParsedArgs { | "model" | "consent" | "sense" - | "agents"; + | "agents" + | "pair"; subargs: string[]; serveWeb: boolean; serveImessage: boolean; @@ -294,7 +297,8 @@ function parseArgs(argv: string[]): ParsedArgs { first === "model" || first === "consent" || first === "sense" || - first === "agents" + first === "agents" || + first === "pair" ) { out.subcommand = first; out.subargs = positional.slice(1); @@ -490,6 +494,11 @@ async function main(): Promise { process.exit(await runAgentsCommand(args.subargs)); } + if (args.subcommand === "pair") { + const { runPairCommand } = await import("./cli/pair.js"); + process.exit(await runPairCommand(args.subargs)); + } + if (args.subcommand === "sessions") { const sessions = await listSessionsOnDisk(); for (const s of sessions) { diff --git a/src/cli/pair.test.ts b/src/cli/pair.test.ts new file mode 100644 index 0000000..379a22c --- /dev/null +++ b/src/cli/pair.test.ts @@ -0,0 +1,74 @@ +import { test, describe } from "node:test"; +import assert from "node:assert/strict"; +import { parsePairArgs, detectLanHost, buildPairUrl } from "./pair.js"; +import type os from "node:os"; + +describe("parsePairArgs", () => { + test("defaults: port 5757, name 'phone', host undefined", () => { + const prevPort = process.env.LISA_WEB_PORT; + delete process.env.LISA_WEB_PORT; + assert.deepEqual(parsePairArgs([]), { host: undefined, port: 5757, name: "phone" }); + if (prevPort !== undefined) process.env.LISA_WEB_PORT = prevPort; + }); + + test("--host / --port / --name (space and = forms)", () => { + assert.deepEqual(parsePairArgs(["--host", "mac.tailnet.ts.net", "--port", "6000", "--name", "iPhone"]), { + host: "mac.tailnet.ts.net", + port: 6000, + name: "iPhone", + }); + assert.deepEqual(parsePairArgs(["--host=10.0.0.5", "--port=8080", "--name=Pixel"]), { + host: "10.0.0.5", + port: 8080, + name: "Pixel", + }); + }); + + test("missing value / bad port / unknown arg → error", () => { + assert.ok("error" in parsePairArgs(["--host"])); + assert.ok("error" in parsePairArgs(["--port", "0"])); + assert.ok("error" in parsePairArgs(["--port", "abc"])); + assert.ok("error" in parsePairArgs(["--nope"])); + }); +}); + +describe("detectLanHost", () => { + test("returns the first non-internal IPv4", () => { + const ifaces = { + lo0: [{ family: "IPv4", internal: true, address: "127.0.0.1" }], + en0: [ + { family: "IPv6", internal: false, address: "fe80::1" }, + { family: "IPv4", internal: false, address: "192.168.1.42" }, + ], + } as unknown as NodeJS.Dict; + assert.equal(detectLanHost(ifaces), "192.168.1.42"); + }); + + test("only loopback → undefined", () => { + const ifaces = { + lo0: [{ family: "IPv4", internal: true, address: "127.0.0.1" }], + } as unknown as NodeJS.Dict; + assert.equal(detectLanHost(ifaces), undefined); + }); +}); + +describe("buildPairUrl", () => { + test("encodes host/port/token/name into a lisa-pair:// v1 URL", () => { + const url = buildPairUrl("192.168.1.42", 5757, "abc123", "my phone"); + const u = new URL(url); + assert.equal(u.protocol, "lisa-pair:"); + assert.equal(u.searchParams.get("host"), "192.168.1.42"); + assert.equal(u.searchParams.get("port"), "5757"); + assert.equal(u.searchParams.get("token"), "abc123"); + assert.equal(u.searchParams.get("name"), "my phone"); // spaces survive a round-trip + }); + + test("the app's pairing parser can read it back (host/token/port)", () => { + // Mirrors AppState.applyPairing: read query items, fall back to URL parts. + const url = buildPairUrl("10.0.0.5", 6000, "tok-XYZ", "iPad"); + const u = new URL(url); + assert.equal(u.searchParams.get("host"), "10.0.0.5"); + assert.equal(Number(u.searchParams.get("port")), 6000); + assert.equal(u.searchParams.get("token"), "tok-XYZ"); + }); +}); diff --git a/src/cli/pair.ts b/src/cli/pair.ts new file mode 100644 index 0000000..c87330d --- /dev/null +++ b/src/cli/pair.ts @@ -0,0 +1,128 @@ +/** + * `lisa pair` — mint a per-device token and show a scannable QR so a phone pairs + * without hand-typing host/token (docs/IOS_COMPANION_PLAN.md §5.3). This is the + * Mac-side counterpart to Lisa Pocket's QR scanner. + * + * A thin client to a running `lisa serve --web` (loopback, like `lisa agents + * pty`): it POSTs /api/pair/start — which is loopback-only and mints a per-device + * token, returned ONCE — then builds a `lisa-pair://` URL and renders it as a + * terminal QR. The token rides in the QR (and the printed URL fallback); the + * phone then authenticates with it like any device token. + */ +// qrcode-terminal is CommonJS — Node's ESM loader only exposes its default +// export, so import the module object and call .generate off it. +import qrcodeTerminal from "qrcode-terminal"; +import os from "node:os"; + +const DEFAULT_PORT = 5757; + +export interface PairArgs { + /** Host the phone will reach the Mac at (LAN IP or tailnet name). */ + host?: string; + /** Port of the running serve (loopback target + encoded for the phone). */ + port: number; + /** Device label stored alongside the minted token. */ + name: string; +} + +const USAGE = "usage: lisa pair [--host ] [--port N] [--name