Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/IOS_COMPANION_PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. 隐私与安全(守住地板)
Expand Down
18 changes: 17 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
13 changes: 11 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -172,7 +174,8 @@ interface ParsedArgs {
| "model"
| "consent"
| "sense"
| "agents";
| "agents"
| "pair";
subargs: string[];
serveWeb: boolean;
serveImessage: boolean;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -490,6 +494,11 @@ async function main(): Promise<void> {
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) {
Expand Down
74 changes: 74 additions & 0 deletions src/cli/pair.test.ts
Original file line number Diff line number Diff line change
@@ -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<os.NetworkInterfaceInfo[]>;
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<os.NetworkInterfaceInfo[]>;
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");
});
});
128 changes: 128 additions & 0 deletions src/cli/pair.ts
Original file line number Diff line number Diff line change
@@ -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 <ip-or-tailnet>] [--port N] [--name <label>]";

/** Parse `lisa pair` argv. Pure (no I/O). */
export function parsePairArgs(argv: string[]): PairArgs | { error: string } {
let host: string | undefined;
let port = Number(process.env.LISA_WEB_PORT) || DEFAULT_PORT;
let name = "phone";
for (let i = 0; i < argv.length; i++) {
const a = argv[i]!;
const valOf = (flag: string): string | undefined =>
a === flag ? argv[++i] : a.startsWith(flag + "=") ? a.slice(flag.length + 1) : undefined;
if (a === "--host" || a.startsWith("--host=")) {
const v = valOf("--host");
if (!v) return { error: "--host needs a value (an IP or tailnet name)" };
host = v;
} else if (a === "--port" || a.startsWith("--port=")) {
const v = valOf("--port");
if (!v) return { error: "--port needs a value" };
port = Number(v);
} else if (a === "--name" || a.startsWith("--name=")) {
const v = valOf("--name");
if (!v) return { error: "--name needs a value" };
name = v;
} else {
return { error: `unknown argument "${a}"\n${USAGE}` };
}
}
if (!Number.isInteger(port) || port <= 0) return { error: "--port must be a positive integer" };
return { host, port, name };
}

/** First non-internal IPv4 — a reasonable default host when --host is omitted. Pure. */
export function detectLanHost(
ifaces: NodeJS.Dict<os.NetworkInterfaceInfo[]> = os.networkInterfaces(),
): string | undefined {
for (const addrs of Object.values(ifaces)) {
for (const a of addrs ?? []) {
if (a.family === "IPv4" && !a.internal) return a.address;
}
}
return undefined;
}

/** Build the `lisa-pair://` deep-link the phone scans/pastes. Pure. */
export function buildPairUrl(host: string, port: number, token: string, name: string): string {
const q = new URLSearchParams({ host, port: String(port), token, name });
return `lisa-pair://v1?${q.toString()}`;
}

export async function runPairCommand(argv: string[]): Promise<number> {
const parsed = parsePairArgs(argv);
if ("error" in parsed) {
console.error(parsed.error);
return 2;
}
const { port, name } = parsed;
const host = parsed.host ?? detectLanHost();
if (!host) {
console.error("Couldn't detect a LAN IP — pass --host <ip-or-tailnet-name>.");
return 1;
}

const base = `http://127.0.0.1:${port}`;
let res: Response;
try {
res = await fetch(`${base}/api/pair/start`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ name, platform: "ios" }),
});
} catch (err) {
console.error(
`Could not reach LISA at ${base} — is \`lisa serve --web\` running? (${(err as Error).message})`,
);
return 1;
}
if (res.status === 403) {
console.error("Pairing can only be started on the Mac itself (localhost).");
return 1;
}
if (!res.ok) {
console.error(`pair failed (${res.status}): ${(await res.text().catch(() => "")).trim()}`);
return 1;
}

const body = (await res.json().catch(() => ({}))) as { token?: string; id?: string; port?: number };
if (!body.token) {
console.error("server returned no token");
return 1;
}
const url = buildPairUrl(host, body.port ?? port, body.token, name);

console.log(`\nScan in Lisa Pocket → Settings → Scan QR code:\n`);
await new Promise<void>((resolve) => qrcodeTerminal.generate(url, { small: true }, () => resolve()));
console.log(`\nOr paste this in Settings → Pair:\n ${url}\n`);
console.log(`Paired device "${name}" (id ${body.id ?? "?"}). Revoke it later from the app or POST /api/devices/revoke.`);
console.log(
`The phone must reach http://${host}:${port} — run serve with --host 0.0.0.0 on the same Wi-Fi, or use a Tailscale name as --host.`,
);
return 0;
}
24 changes: 24 additions & 0 deletions src/web/push.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const {
defaultPushPrefs,
normalizePushPrefs,
agentPushEvents,
agentDeepLink,
sendNtfy,
PushBridge,
registerPush,
Expand Down Expand Up @@ -68,6 +69,15 @@ describe("agentPushEvents (pure trigger)", () => {
test("first sight already done (prev undefined) → fires", () => {
assert.equal(agentPushEvents(undefined, sess({ state: "done" }))[0]!.pref, "done");
});
test("events carry a lisapocket:// deep-link to the session", () => {
const [e] = agentPushEvents(sess({ state: "working" }), sess({ state: "done", agent: "codex", sessionId: "s9" }));
assert.equal(e!.click, agentDeepLink("codex", "s9"));
const u = new URL(e!.click!);
assert.equal(u.protocol, "lisapocket:");
assert.equal(u.host, "session");
assert.equal(u.searchParams.get("agent"), "codex");
assert.equal(u.searchParams.get("id"), "s9");
});
});

describe("sendNtfy", () => {
Expand All @@ -87,6 +97,20 @@ describe("sendNtfy", () => {
assert.equal(captured!.init.body, "B");
assert.equal(captured!.init.headers.Title, "T");
assert.equal(captured!.init.headers.Priority, "high");
assert.equal(captured!.init.headers.Click, undefined); // omitted when no click
});
test("sets the Click header when the event has a deep-link", async () => {
let headers: Record<string, string> = {};
await sendNtfy(
"https://ntfy.sh",
"t",
{ title: "T", body: "B", priority: "default", click: "lisapocket://session?agent=codex&id=s9" },
async (_url, init) => {
headers = init.headers;
return { ok: true };
},
);
assert.equal(headers.Click, "lisapocket://session?agent=codex&id=s9");
});
test("network throw → false", async () => {
const ok = await sendNtfy("https://x", "t", { title: "a", body: "b", priority: "default" }, async () => {
Expand Down
Loading