Compare commits
7 Commits
b64aaab40a
...
0604fd7c03
| Author | SHA1 | Date |
|---|---|---|
|
|
0604fd7c03 | |
|
|
8eb8360387 | |
|
|
547df01c21 | |
|
|
2e44a7f286 | |
|
|
4b428df0a4 | |
|
|
8ff635e6f5 | |
|
|
fcfe729d23 |
|
|
@ -93,7 +93,9 @@ export default function remoteControl(pi: ExtensionAPI) {
|
||||||
updateStatus(ctx);
|
updateStatus(ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
pi.on("session_switch", async (_event, ctx) => {
|
// session_switch is not in the ExtensionAPI — use session_start instead
|
||||||
|
// to sync state when a session becomes active.
|
||||||
|
pi.on("session_start", async (_event, ctx) => {
|
||||||
scheduleSync(ctx);
|
scheduleSync(ctx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,72 +28,41 @@ export type StateCallback = (event: StateEvent) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe to pi agent lifecycle events.
|
* Subscribe to pi agent lifecycle events.
|
||||||
* Returns an unsubscribe function.
|
*
|
||||||
|
* Note: `pi.on()` returns void and has no unsubscribe mechanism — event
|
||||||
|
* handlers are scoped to the extension lifetime, not to individual calls.
|
||||||
|
* The returned function is a no-op kept for API compatibility.
|
||||||
*/
|
*/
|
||||||
export function subscribeAgentEvents(
|
export function subscribeAgentEvents(
|
||||||
pi: ExtensionAPI,
|
pi: ExtensionAPI,
|
||||||
onState: StateCallback,
|
onState: StateCallback,
|
||||||
): () => void {
|
): () => void {
|
||||||
const unsubs: Array<() => void> = [];
|
|
||||||
|
|
||||||
// agent_start → thinking
|
// agent_start → thinking
|
||||||
try {
|
pi.on("agent_start", () => {
|
||||||
const off = pi.on("agent_start", () => {
|
onState({ value: "thinking", ts: Date.now() });
|
||||||
onState({ value: "thinking", ts: Date.now() });
|
});
|
||||||
});
|
|
||||||
if (off) unsubs.push(off);
|
|
||||||
} catch {
|
|
||||||
// event may not exist in this pi version
|
|
||||||
}
|
|
||||||
|
|
||||||
// agent_end → idle
|
// agent_end → idle
|
||||||
try {
|
pi.on("agent_end", () => {
|
||||||
const off = pi.on("agent_end", () => {
|
onState({ value: "idle", ts: Date.now() });
|
||||||
onState({ value: "idle", ts: Date.now() });
|
});
|
||||||
});
|
|
||||||
if (off) unsubs.push(off);
|
|
||||||
} catch {
|
|
||||||
// event may not exist
|
|
||||||
}
|
|
||||||
|
|
||||||
// tool_start → tool
|
// tool_execution_start → tool (carries toolName directly on the event)
|
||||||
try {
|
pi.on("tool_execution_start", (event) => {
|
||||||
const off = pi.on("tool_start", (data: unknown) => {
|
onState({ value: "tool", tool: event.toolName, ts: Date.now() });
|
||||||
const toolName =
|
});
|
||||||
data &&
|
|
||||||
typeof data === "object" &&
|
|
||||||
"name" in data &&
|
|
||||||
typeof (data as { name: unknown }).name === "string"
|
|
||||||
? (data as { name: string }).name
|
|
||||||
: undefined;
|
|
||||||
onState({ value: "tool", tool: toolName, ts: Date.now() });
|
|
||||||
});
|
|
||||||
if (off) unsubs.push(off);
|
|
||||||
} catch {
|
|
||||||
// event may not exist
|
|
||||||
}
|
|
||||||
|
|
||||||
// tool_end → thinking (agent is still running after tool)
|
// tool_execution_end → thinking (agent loop continues after tool completes)
|
||||||
try {
|
pi.on("tool_execution_end", () => {
|
||||||
const off = pi.on("tool_end", () => {
|
onState({ value: "thinking", ts: Date.now() });
|
||||||
onState({ value: "thinking", ts: Date.now() });
|
});
|
||||||
});
|
|
||||||
if (off) unsubs.push(off);
|
|
||||||
} catch {
|
|
||||||
// event may not exist
|
|
||||||
}
|
|
||||||
|
|
||||||
// awaiting_input → awaiting-input
|
// input → awaiting-input (fired when pi pauses to wait for user input)
|
||||||
try {
|
pi.on("input", () => {
|
||||||
const off = pi.on("awaiting_input", () => {
|
onState({ value: "awaiting-input", ts: Date.now() });
|
||||||
onState({ value: "awaiting-input", ts: Date.now() });
|
});
|
||||||
});
|
|
||||||
if (off) unsubs.push(off);
|
|
||||||
} catch {
|
|
||||||
// event may not exist
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
for (const off of unsubs) off();
|
// No-op: pi event subscriptions cannot be cancelled.
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import { readChunks } from "../../buffer/reader.js";
|
||||||
import type { StateEvent } from "../../pi/events.js";
|
import type { StateEvent } from "../../pi/events.js";
|
||||||
import { SequenceCounter } from "../../sequence.js";
|
import { SequenceCounter } from "../../sequence.js";
|
||||||
import { ControlClient } from "../../tmux/control.js";
|
import { ControlClient } from "../../tmux/control.js";
|
||||||
|
import { resizeSession } from "../../tmux/manager.js";
|
||||||
import { capturePane } from "../../tmux/snapshot.js";
|
import { capturePane } from "../../tmux/snapshot.js";
|
||||||
import type { WsClient, WsServer } from "../types.js";
|
import type { WsClient, WsServer } from "../types.js";
|
||||||
|
|
||||||
|
|
@ -155,8 +156,14 @@ function handleStreamConnection(
|
||||||
if (ws.readyState !== 1) break;
|
if (ws.readyState !== 1) break;
|
||||||
sendBinary(ws, buildBinaryFrame(chunk.seq, chunk.data));
|
sendBinary(ws, buildBinaryFrame(chunk.seq, chunk.data));
|
||||||
}
|
}
|
||||||
|
} else if (m.type === "resize") {
|
||||||
|
// IC-1 extension: client reports its actual terminal dimensions.
|
||||||
|
// Resize the tmux window so line-wrapping matches what the client sees.
|
||||||
|
const cols = typeof m.cols === "number" ? m.cols : 80;
|
||||||
|
const rows = typeof m.rows === "number" ? m.rows : 24;
|
||||||
|
resizeSession(sessionId, cols, rows).catch(() => {});
|
||||||
} else if (m.type === "snapshot-request") {
|
} else if (m.type === "snapshot-request") {
|
||||||
capturePane({ session: sessionId })
|
capturePane({ session: sessionId, escapes: true })
|
||||||
.then((text) => {
|
.then((text) => {
|
||||||
const data = Buffer.from(text).toString("base64");
|
const data = Buffer.from(text).toString("base64");
|
||||||
const s = seq.next();
|
const s = seq.next();
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,16 @@ export async function spawnSession(opts: {
|
||||||
command?: string;
|
command?: string;
|
||||||
}): Promise<string> {
|
}): Promise<string> {
|
||||||
await checkTmuxVersion();
|
await checkTmuxVersion();
|
||||||
const { name, width = 120, height = 40, command = "" } = opts;
|
const { name, width = 80, height = 24, command = "" } = opts;
|
||||||
|
|
||||||
|
// Set default-terminal globally so programs inside tmux get xterm-256color
|
||||||
|
// and emit the escape sequences that SwiftTerm / xterm-compatible clients expect.
|
||||||
|
await execFileAsync("tmux", [
|
||||||
|
"set-option",
|
||||||
|
"-g",
|
||||||
|
"default-terminal",
|
||||||
|
"xterm-256color",
|
||||||
|
]).catch(() => {}); // best-effort; older tmux may not support all options
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
"new-session",
|
"new-session",
|
||||||
|
|
@ -73,9 +82,42 @@ export async function spawnSession(opts: {
|
||||||
if (command) args.push(command);
|
if (command) args.push(command);
|
||||||
|
|
||||||
await execFileAsync("tmux", args);
|
await execFileAsync("tmux", args);
|
||||||
|
|
||||||
|
// Mark as sidecar-managed so listSessions() can filter out unrelated
|
||||||
|
// tmux sessions (e.g. the pi-sidecar launcher session itself).
|
||||||
|
await execFileAsync("tmux", [
|
||||||
|
"set-option",
|
||||||
|
"-t",
|
||||||
|
name,
|
||||||
|
"@pi-remote-managed",
|
||||||
|
"1",
|
||||||
|
]).catch(() => {});
|
||||||
|
|
||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resize an existing session's window.
|
||||||
|
* Safe to call at any time; silently ignores unknown sessions.
|
||||||
|
*/
|
||||||
|
export async function resizeSession(
|
||||||
|
name: string,
|
||||||
|
cols: number,
|
||||||
|
rows: number,
|
||||||
|
): Promise<void> {
|
||||||
|
const c = Math.max(1, Math.min(Math.round(cols), 500));
|
||||||
|
const r = Math.max(1, Math.min(Math.round(rows), 200));
|
||||||
|
await execFileAsync("tmux", [
|
||||||
|
"resize-window",
|
||||||
|
"-t",
|
||||||
|
name,
|
||||||
|
"-x",
|
||||||
|
String(c),
|
||||||
|
"-y",
|
||||||
|
String(r),
|
||||||
|
]).catch(() => {}); // session may not exist yet; ignore
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List all tmux sessions with metadata.
|
* List all tmux sessions with metadata.
|
||||||
*/
|
*/
|
||||||
|
|
@ -113,6 +155,20 @@ export async function listSessions(): Promise<TmuxSession[]> {
|
||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
const [id, createdAt, lastActivityAt, w, h] = line.split(SEP);
|
const [id, createdAt, lastActivityAt, w, h] = line.split(SEP);
|
||||||
|
|
||||||
|
// Only include sessions created by the sidecar.
|
||||||
|
try {
|
||||||
|
const r = await execFileAsync("tmux", [
|
||||||
|
"show-options",
|
||||||
|
"-t",
|
||||||
|
id,
|
||||||
|
"-qv",
|
||||||
|
"@pi-remote-managed",
|
||||||
|
]);
|
||||||
|
if (!r.stdout.trim()) continue; // unmanaged session — skip
|
||||||
|
} catch {
|
||||||
|
continue; // can't read options → skip
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch @description option separately (may not be set)
|
// Fetch @description option separately (may not be set)
|
||||||
let description: string | undefined;
|
let description: string | undefined;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -22,7 +22,8 @@
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"smoke": "node --test scripts/smoke/smoke.mjs",
|
"smoke": "node --test scripts/smoke/smoke.mjs",
|
||||||
"smoke:stream": "node --test scripts/smoke/stream.test.mjs",
|
"smoke:stream": "node --test scripts/smoke/stream.test.mjs",
|
||||||
"smoke:all": "node --test scripts/smoke/smoke.mjs scripts/smoke/stream.test.mjs"
|
"smoke:pair": "node --test scripts/smoke/pair.test.mjs",
|
||||||
|
"smoke:all": "node --test scripts/smoke/smoke.mjs scripts/smoke/stream.test.mjs scripts/smoke/pair.test.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.12",
|
"@biomejs/biome": "^2.4.12",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
/**
|
||||||
|
* T-1.3 Integration smoke: POST /pair pairing flow.
|
||||||
|
*
|
||||||
|
* Requires a running pi process with remote-control (spawned by the before() hook).
|
||||||
|
* Re-uses the helpers from helpers.mjs.
|
||||||
|
*
|
||||||
|
* Tests:
|
||||||
|
* 1. GET /pair-qr → 200 text/plain containing a pi-remote:// URL
|
||||||
|
* 2. POST /pair → 200 { bearerToken, sidecarId } with valid pairingToken
|
||||||
|
* 3. GET /sessions?token=<bearerToken> → 200 (bearer token authenticates)
|
||||||
|
* 4. POST /pair (invalid) → 403 invalid_pairing_token
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import path from "node:path";
|
||||||
|
import { after, before, describe, it } from "node:test";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
baseUrl,
|
||||||
|
createSmokeHome,
|
||||||
|
fetchText,
|
||||||
|
killPi,
|
||||||
|
removeSmokeHome,
|
||||||
|
spawnPi,
|
||||||
|
waitForPort,
|
||||||
|
} from "./helpers.mjs";
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const EXTENSION_PATH = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../extensions/remote-control",
|
||||||
|
);
|
||||||
|
|
||||||
|
const SMOKE_PORT = Number(process.env.SMOKE_PORT_PAIR ?? 19878);
|
||||||
|
const SMOKE_TOKEN = "pair-test-token-deterministic-789";
|
||||||
|
const BASE = baseUrl(SMOKE_PORT);
|
||||||
|
const AUTH = `?token=${SMOKE_TOKEN}`;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: fetch JSON from a URL
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
async function fetchJson(url, init) {
|
||||||
|
const res = await fetch(url, init);
|
||||||
|
let body;
|
||||||
|
try {
|
||||||
|
body = await res.json();
|
||||||
|
} catch {
|
||||||
|
body = null;
|
||||||
|
}
|
||||||
|
return { res, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helper: extract a query-param value from a URL string
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
function extractParam(urlStr, param) {
|
||||||
|
// Find the pi-remote:// URL in a potentially multi-line body
|
||||||
|
const match = urlStr.match(/pi-remote:\/\/[^\s]+/);
|
||||||
|
if (!match) return null;
|
||||||
|
// URLSearchParams doesn't parse custom schemes, so strip the scheme+host
|
||||||
|
const raw = match[0];
|
||||||
|
const qIdx = raw.indexOf("?");
|
||||||
|
if (qIdx === -1) return null;
|
||||||
|
const params = new URLSearchParams(raw.slice(qIdx + 1));
|
||||||
|
return params.get(param);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Lifecycle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
let piProc = null;
|
||||||
|
let tmpHome = null;
|
||||||
|
|
||||||
|
describe("T-1.3 pair smoke", () => {
|
||||||
|
before(async () => {
|
||||||
|
tmpHome = await createSmokeHome({ port: SMOKE_PORT, token: SMOKE_TOKEN });
|
||||||
|
const { proc } = spawnPi({
|
||||||
|
extensionPath: EXTENSION_PATH,
|
||||||
|
fakeHome: tmpHome,
|
||||||
|
});
|
||||||
|
piProc = proc;
|
||||||
|
await waitForPort({ port: SMOKE_PORT });
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
if (piProc) await killPi(piProc);
|
||||||
|
if (tmpHome) await removeSmokeHome(tmpHome);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 1: GET /pair-qr ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it("GET /pair-qr returns a pairing URL", async () => {
|
||||||
|
const { res, body } = await fetchText(`${BASE}/pair-qr${AUTH}`);
|
||||||
|
assert.equal(res.status, 200, `Expected 200, got ${res.status}: ${body}`);
|
||||||
|
assert.ok(
|
||||||
|
body.includes("pi-remote://"),
|
||||||
|
`Response should contain pi-remote:// URL. Got:\n${body}`,
|
||||||
|
);
|
||||||
|
const token = extractParam(body, "pair");
|
||||||
|
assert.ok(
|
||||||
|
token && token.length > 0,
|
||||||
|
`Should be able to extract pair token from URL. Body:\n${body}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 2: POST /pair with valid pairingToken ────────────────────────────
|
||||||
|
|
||||||
|
it("POST /pair with valid pairingToken returns bearerToken", async () => {
|
||||||
|
// Step 1: obtain a fresh pairing token via GET /pair-qr
|
||||||
|
const { res: qrRes, body: qrBody } = await fetchText(
|
||||||
|
`${BASE}/pair-qr${AUTH}`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
qrRes.status,
|
||||||
|
200,
|
||||||
|
`GET /pair-qr failed: ${qrRes.status} ${qrBody}`,
|
||||||
|
);
|
||||||
|
const pairingToken = extractParam(qrBody, "pair");
|
||||||
|
assert.ok(pairingToken, "Could not extract pairingToken from /pair-qr");
|
||||||
|
|
||||||
|
// Step 2: exchange pairingToken for bearerToken
|
||||||
|
const { res, body } = await fetchJson(`${BASE}/pair`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
pairingToken,
|
||||||
|
deviceToken: "fake-device-token-smoke",
|
||||||
|
environment: "sandbox",
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(res.status, 200, `Expected 200, got ${res.status}: ${JSON.stringify(body)}`);
|
||||||
|
assert.ok(body, "Response body should be JSON");
|
||||||
|
assert.ok(
|
||||||
|
typeof body.bearerToken === "string" && body.bearerToken.length > 0,
|
||||||
|
`Response should have bearerToken string. Got: ${JSON.stringify(body)}`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
typeof body.sidecarId === "string" && body.sidecarId.length > 0,
|
||||||
|
`Response should have sidecarId string. Got: ${JSON.stringify(body)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 3: bearerToken authenticates GET /sessions ──────────────────────
|
||||||
|
|
||||||
|
it("bearerToken from pair can authenticate GET /sessions", async () => {
|
||||||
|
// Obtain pairing token
|
||||||
|
const { res: qrRes, body: qrBody } = await fetchText(
|
||||||
|
`${BASE}/pair-qr${AUTH}`,
|
||||||
|
);
|
||||||
|
assert.equal(qrRes.status, 200, `GET /pair-qr failed: ${qrRes.status}`);
|
||||||
|
const pairingToken = extractParam(qrBody, "pair");
|
||||||
|
assert.ok(pairingToken, "Could not extract pairingToken");
|
||||||
|
|
||||||
|
// Exchange for bearer token
|
||||||
|
const { res: pairRes, body: pairBody } = await fetchJson(`${BASE}/pair`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ pairingToken }),
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
pairRes.status,
|
||||||
|
200,
|
||||||
|
`POST /pair failed: ${pairRes.status} ${JSON.stringify(pairBody)}`,
|
||||||
|
);
|
||||||
|
const { bearerToken } = pairBody;
|
||||||
|
assert.ok(bearerToken, "Must have bearerToken to test auth");
|
||||||
|
|
||||||
|
// Use bearerToken to call GET /sessions
|
||||||
|
const { res: sessRes, body: sessBody } = await fetchJson(
|
||||||
|
`${BASE}/sessions?token=${encodeURIComponent(bearerToken)}`,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
sessRes.status,
|
||||||
|
200,
|
||||||
|
`GET /sessions with bearerToken should return 200, got ${sessRes.status}: ${JSON.stringify(sessBody)}`,
|
||||||
|
);
|
||||||
|
assert.ok(Array.isArray(sessBody), "GET /sessions should return an array");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Test 4: POST /pair with invalid pairingToken → 403 ───────────────────
|
||||||
|
|
||||||
|
it("POST /pair with invalid pairingToken returns 403", async () => {
|
||||||
|
const { res, body } = await fetchJson(`${BASE}/pair`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ pairingToken: "invalid-xxx" }),
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
res.status,
|
||||||
|
403,
|
||||||
|
`Expected 403 for invalid token, got ${res.status}: ${JSON.stringify(body)}`,
|
||||||
|
);
|
||||||
|
assert.ok(body, "Should return JSON error body");
|
||||||
|
assert.equal(body.error, "invalid_pairing_token");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue