From 14eb533647b4a8b9d03e5e7506d14ecb0a8b472a Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 8 Oct 2026 13:52:39 +0800 Subject: [PATCH 1/2] fix(pi): include the public control console carrier --- packages/pi-taskflow/package.json | 3 ++- pnpm-lock.yaml | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/pi-taskflow/package.json b/packages/pi-taskflow/package.json index 6929bc1d..27c4b63d 100644 --- a/packages/pi-taskflow/package.json +++ b/packages/pi-taskflow/package.json @@ -53,7 +53,8 @@ "image": "https://raw.githubusercontent.com/heggria/taskflow/main/assets/social-preview.png" }, "dependencies": { - "taskflow-core": "workspace:*" + "taskflow-core": "workspace:*", + "taskflow-mcp-core": "workspace:*" }, "peerDependencies": { "@earendil-works/pi-agent-core": "*", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4f08f85..3c20fec1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,6 +126,9 @@ importers: taskflow-core: specifier: workspace:* version: link:../taskflow-core + taskflow-mcp-core: + specifier: workspace:* + version: link:../taskflow-mcp-core devDependencies: '@earendil-works/pi-agent-core': specifier: 1.0.0 From 88c728c67d7afdaae96a74e95756ef673aead8fb Mon Sep 17 00:00:00 2001 From: heggria Date: Thu, 8 Oct 2026 14:12:53 +0800 Subject: [PATCH 2/2] fix(pi): complete local web console entry and acceptance --- CHANGELOG.md | 2 + README.md | 8 + README.zh-CN.md | 8 + docs/internal/1.0.0-release-plan.md | 4 +- docs/internal/1.0.0-spec-closure.md | 12 +- docs/pi-compatibility.md | 54 ++++ packages/pi-taskflow/src/index.ts | 15 +- packages/pi-taskflow/src/web-command.ts | 242 ++++++++++++++++++ packages/pi-taskflow/test/web-command.test.ts | 221 ++++++++++++++++ scripts/smoke-packed-packages.mjs | 68 +++++ 10 files changed, 628 insertions(+), 6 deletions(-) create mode 100644 docs/pi-compatibility.md create mode 100644 packages/pi-taskflow/src/web-command.ts create mode 100644 packages/pi-taskflow/test/web-command.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f622fcf8..c270fce4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ All notable changes to taskflow are documented here. This project follows [Keep ### Added +- Pi `/tf web` launches the public local console, opens the browser with a safe URL and provides manual fallback, reuses the owned service, and cleans it up on session exit. The command retains private one-use token login and exposes existing Control Plane evidence. +- Restore the public Pi SDK compatibility guide and its reproducible matrix checks. - Authenticated multi-project Control Plane: durable project/domain registry, real runtime admission, global concurrency reservations, command-result replay, scoped artifacts, terminal Receipts and evidence explanations. - Durable approval park/CAS, output and plan edits, restart readmission, and local WebUI for runs, approvals and evidence. Plan edits preserve the DAG. - Public `taskflow-control` and `taskflow-project-admin` binaries bundled in `taskflow-mcp-core`, including fresh default-auto bootstrap and an installed guide. The release remains ten public packages; the private control source workspace is not a runtime dependency. diff --git a/README.md b/README.md index 22356bcb..7bbfc121 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,14 @@ pnpm exec node --conditions=development --experimental-strip-types --test \ This exercises the checked-in `examples/trusted-effects-write.json` path without a live LLM. For an interactive run, use the host guide for the adapter you already run. The [release guide](./RELEASE.md) covers packed-consumer validation and release gates. +### Local Control Console + +In Pi, run `/tf web` to start the bundled local Control Console and open the default browser. If browser opening fails, the command shows a manual loopback URL. Use the one-use token in the reported private (0600) handoff file to log in; tokens are never included in the URL or command notifications. + +The console shows Control Plane runs, approvals, Receipts and existing evidence. Legacy Pi run transcripts are outside this view. Repeating the command in the same project reuses its service; changing projects or leaving the Pi session stops only the console process started by that session. Unix local transport is required. If another host already owns the Control home, stop that owner explicitly before launching this console. + +See [Pi compatibility](./docs/pi-compatibility.md) for the tested SDK matrix and the distinction between real-process fixtures and live-provider acceptance. + ## Declare an effect Effects are part of the flow contract, not a free-form prompt promise: diff --git a/README.zh-CN.md b/README.zh-CN.md index 2a1db508..4bf3511c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -91,6 +91,14 @@ pnpm exec node --conditions=development --experimental-strip-types --test \ 它会在没有 live LLM 的情况下执行仓库内的 `examples/trusted-effects-write.json` 路径。要交互式运行,请按当前使用的宿主查看对应指南。[发布指南](./RELEASE.md)说明打包消费验证和正式发布闸门。 +### 本地 Control Console + +在 Pi 中运行 `/tf web`,启动公开包内附的本地 Control Console 并打开默认浏览器。浏览器打开失败时会显示可手工访问的 loopback URL。使用提示中私有(0600)handoff 文件的一次性令牌登录;URL 和命令通知不会包含令牌。 + +页面展示 Control Plane 的 runs、审批、Receipt 和已有 evidence;不展示旧 Pi run 的原始 transcript。同一项目重复执行会复用服务;切换项目或退出 Pi 会话时,只清理该会话启动的 Console 进程。需要 Unix 本地传输;已有其他 Host 占用 Control home 时,需先显式停止该 Host。 + +[Pi 兼容性说明](./docs/pi-compatibility.md)列出已验证的 SDK 矩阵,并区分真实进程 fixture 与 live-provider 验收。 + ## 声明一个 effect Effect 是 flow 合同的一部分,不是 prompt 里的自由文本承诺: diff --git a/docs/internal/1.0.0-release-plan.md b/docs/internal/1.0.0-release-plan.md index 063dbff6..1a861f21 100644 --- a/docs/internal/1.0.0-release-plan.md +++ b/docs/internal/1.0.0-release-plan.md @@ -10,14 +10,14 @@ the checked-in workflow and final-commit gates, with one release executor. The owner's subsequent 2026-10-02 instruction makes closure of previously committed documents/specifications a hard pre-publication gate, alongside real -Pi 1.0 compatibility. No merge, tag or npm publication has occurred. The earlier +Pi 1.0 compatibility. PR #142 merged on 2026-10-08 at `f0a82b5`; tag and npm publication remain pending. The earlier stable/experimental packaging boundary below does not waive an accepted spec. Approved Control Plane S3-S7 and accepted ADR requirements must be traced to implementation and meaningful acceptance evidence before the release resumes. The owner explicitly selected scope A: the complete approved Control Plane is required for 1.0. Missing implementation remains blocked; relabeling it experimental is not closure. -## Release track reconciliation (2026-10-07) +## Historical release track reconciliation (2026-10-07) | Surface | Meaning | |---|---| diff --git a/docs/internal/1.0.0-spec-closure.md b/docs/internal/1.0.0-spec-closure.md index 8d0c1f10..cabef92a 100644 --- a/docs/internal/1.0.0-spec-closure.md +++ b/docs/internal/1.0.0-spec-closure.md @@ -1,6 +1,6 @@ # 1.0 specification closure ledger -**Track: unreleased 1.0.0, Draft PR #142. Updated 2026-10-08.** The retained +**Track: unreleased 1.0.0; PR #142 merged at `f0a82b5`. Updated 2026-10-08.** The retained `rc/0.3.0-beta.2` branch name is historical. The owner authorized full closure through publication on 2026-10-08; final release checks and registry verification remain mandatory. @@ -95,3 +95,13 @@ MCP opt-in semantics; this filter is not an OS sandbox. - Registry artifact verification, authorized main/tag/npm/GitHub Release and the single original publisher handoff remain publication gates. See the [current scoreboard](./1.0.0-ga-scoreboard.md). + +## Final command and documentation follow-up + +Closure review found that the existing Console did not yet provide the promised +Pi `/tf web` entry, and the #181 public compatibility guide had not been carried +into main. The follow-up adds the direct slash command using the existing public +carrier, safe browser handoff/fallback, bounded owned-process lifecycle, and a +packed-consumer regression. It restores the [compatibility guide](../pi-compatibility.md). +Final follow-up CI and registry publication remain separate gates. #95 OS authority +isolation remains explicitly deferred and is not claimed by this command. diff --git a/docs/pi-compatibility.md b/docs/pi-compatibility.md new file mode 100644 index 00000000..99824e5c --- /dev/null +++ b/docs/pi-compatibility.md @@ -0,0 +1,54 @@ +# Pi compatibility + +The CI `pi-peers` job tests exact official Pi SDK versions **0.80.3, 0.85.1, and 1.0.4** on Node 22. The four adapter-local `@earendil-works/pi-*` resolutions are asserted before testing; checking only the root SDK is insufficient with pnpm. + +The public peer ranges remain `*`. This is installation flexibility, not a promise that every Pi version has been tested. The matrix changes only each disposable job’s adapter dependencies. Normal source and packed-consumer checks retain the committed Pi 1.0.0 development pins. + +## Coverage + +Each version runs the complete adapter unit suite, compiles the core and Pi extension, and launches the actual installed Pi CLI with the built extension. A local deterministic provider supplies model responses, without credentials or live model requests. + +- Extension loading and actual `taskflow` tool dispatch. +- A foreground Taskflow launches `pi --mode json` and the child returns the owning run's ID. Direct runner identity and the shared-context `ctx_*` extension/allowlist are also exercised. +- A genuine Pi retry after a synthetic provider 429 recovers successfully. Event-stream regressions additionally cover exhaustion, overflow/threshold compaction, legacy clean exits, terminal reaping, cancellation and idle timeout. +- RPC approve/reject/cancel/edit/edit-cancel/abort paths, plus JSON-mode rejection that prevents the downstream script. Unit coverage retains the TUI overlay behavior, including missing or failed dialogs. +- Pi 1.0.4 additionally starts a harmless local stdio MCP server and exercises actual codemode calls, explicit MCP wildcard inclusion, and read-only tool-list filtering. Older matrix entries do not claim native MCP/codemode integration coverage. + +The compatibility fixture uses compiled workspace output. Separate packed-consumer checks test the installed Pi 1.0.0 extension and crash recovery; the 1.0.4 matrix also runs lifecycle and crash recovery. These deterministic-provider checks do not establish live-provider authentication or interactive terminal visual acceptance. + +## Pi 1.0 behavior handled by this adapter + +An assistant `message_end` error can represent a failed attempt before Pi retries or compacts. Taskflow waits for recovery or final settlement instead of killing the process immediately. Explicit protocol errors and aborts remain fatal, and the existing idle watchdog remains active. Compaction revokes premature terminal reaping while preserving successful legacy clean-exit evidence when no retry follows. + +RPC supports dialogs but not custom TUI overlays. Taskflow uses `ui.select` for RPC, retains the overlay for TUI, and rejects unavailable/cancelled dialogs and cancelled edits. JSON/print execution does not silently approve a human approval phase. + +[Pi 1.0.4](https://github.com/earendil-works/pi/releases/tag/v1.0.4) preserves registered MCP tools when `--tools` contains no `mcp__` entry. That includes tools callable indirectly through codemode. Taskflow preserves its explicit tool-list contract by passing exclusions for ambient MCP server and native resource tools unless explicitly selected. `*` and explicit `mcp__...` selectors preserve native MCP opt-in behavior; native resource selectors are handled individually. For example: + +| Taskflow tools | MCP behavior | +| --- | --- | +| `read` | MCP excluded, including indirect calls | +| `codemode` | Ambient server and resource tools excluded, even if an MCP extension was loaded | +| `read,codemode` | Ambient MCP resource reads remain excluded | +| `codemode,mcp__compat__*` | Pi filters MCP to the explicitly requested namespace | +| No tool list | Existing Pi/profile behavior retained | + +This filter is not an OS sandbox. The default isolated child profile still disables ambient extensions; an allowlist or inherited profile can load explicitly authorized extensions. + +## Reproduce + +In a disposable checkout, after `pnpm install --frozen-lockfile`: + +```sh +PI_VERSION=1.0.4 # Repeat with 0.80.3 and 0.85.1. +pnpm --filter pi-taskflow add --save-dev --save-exact \ + @earendil-works/pi-agent-core@$PI_VERSION \ + @earendil-works/pi-ai@$PI_VERSION \ + @earendil-works/pi-coding-agent@$PI_VERSION \ + @earendil-works/pi-tui@$PI_VERSION +PI_TEST_PEER_VERSION=$PI_VERSION pnpm run test:pi:peers +pnpm --filter taskflow-core build +pnpm --filter pi-taskflow build +node --experimental-strip-types packages/pi-taskflow/test/e2e-pi-compat.mts +``` + +The installation modifies that checkout's adapter manifest and lockfile only for the test. Restore those dependency changes before committing unrelated work. diff --git a/packages/pi-taskflow/src/index.ts b/packages/pi-taskflow/src/index.ts index 9b76499e..b3b3f35b 100644 --- a/packages/pi-taskflow/src/index.ts +++ b/packages/pi-taskflow/src/index.ts @@ -3,7 +3,7 @@ * * Registers: * - tool `taskflow` : run inline / saved flows, save, resume (LLM-callable) - * - command `/tf` : list | run | show | save | resume | runs (user) + * - command `/tf` : list | run | show | save | resume | runs | web (user) * - command `/tf:` : per-saved-flow shortcut (registered on session_start) * * Intermediate phase outputs are held in the runtime and never pushed into the @@ -32,6 +32,7 @@ import { renderRunResult, summarizeRun } from "./render.ts"; import { createPiSubagentRunner, PI_TASKFLOW_PI_ENTRY_ENV, resolveParentPiCliEntry, runnerModulePath } from "./runner.ts"; import { RunHistoryComponent, type RunHistoryResult } from "./runs-view.ts"; import { createApprovalRequester } from "./approval-view.ts"; +import { createWebCommand } from "./web-command.ts"; import { executeTaskflow, recomputeTaskflow, @@ -630,6 +631,8 @@ export default function (pi: ExtensionAPI) { registerCtxTools(pi, ctxDir, nodeId); return; } + const webCommand = createWebCommand(); + pi.on("session_shutdown", async () => { await webCommand.close(); }); // ---- Register per-saved-flow shortcut commands on session start ---- // Cached for /tf argument completions (getArgumentCompletions has no ctx). @@ -1672,9 +1675,9 @@ export default function (pi: ExtensionAPI) { // ---- The /tf user command ---- pi.registerCommand("tf", { - description: "Taskflow: list | run | show | verify | compile | plan | runs | peek [phaseId] | reconcile-workspace --ack | init", + description: "Taskflow: list | run | show | verify | compile | plan | runs | web (Control Plane console) | peek [phaseId] | reconcile-workspace --ack | init", getArgumentCompletions: (prefix) => { - const subs = ["list", "run", "show", "runs", "peek", "resume", "init", "save", "verify", "compile", "plan", "analytics", "ir", "provenance", "trace", "replay", "why-stale", "recompute", "reconcile-workspace", "version"]; + const subs = ["list", "run", "show", "runs", "web", "peek", "resume", "init", "save", "verify", "compile", "plan", "analytics", "ir", "provenance", "trace", "replay", "why-stale", "recompute", "reconcile-workspace", "version"]; // Name-taking subcommands: complete saved flow names after the sub (cwd from session_start). const nameSubs = new Set(["run", "show", "verify", "compile", "plan", "analytics", "ir"]); const trimmed = prefix.trimStart(); @@ -1704,6 +1707,12 @@ export default function (pi: ExtensionAPI) { const arg = separator < 0 ? "" : trimmedArg.slice(separator).trim(); const rest = arg ? arg.split(/\s+/u) : []; + if (sub === "web") { + if (arg) { ctx.ui.notify("Usage: /tf web", "warning"); return; } + await webCommand.run(ctx); + return; + } + if (!sub || sub === "list") { const flows = listFlows(ctx.cwd); if (flows.length === 0) { diff --git a/packages/pi-taskflow/src/web-command.ts b/packages/pi-taskflow/src/web-command.ts new file mode 100644 index 00000000..8ba47bd9 --- /dev/null +++ b/packages/pi-taskflow/src/web-command.ts @@ -0,0 +1,242 @@ +/** Direct user entry to the public Control Console. Never reads login secrets. */ +import { spawn, type ChildProcess } from "node:child_process"; +import { lstatSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { basename, dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import { StringDecoder } from "node:string_decoder"; +import { fileURLToPath } from "node:url"; + +const OUTPUT_LIMIT = 64 * 1024; + +export interface ConsoleLocation { + consoleUrl: string; + browserHandoffFile: string; +} + +export interface WebCommandContext { + cwd: string; + ui: { notify(message: string, kind?: "info" | "warning" | "error"): void }; +} + +/** Injection is for local process tests; production always uses the installed carrier. */ +export interface WebCommandOptions { + resolveCli?: () => string; + openBrowser?: (url: string) => Promise; + platform?: NodeJS.Platform; + startupTimeoutMs?: number; + stopTimeoutMs?: number; +} + +class WebCommandError extends Error {} + +/** Resolve through the public export, then its package's declared executable. + * This works with hoisted/nested installs and source development conditions. */ +export function resolveControlCli(): string { + let directory = dirname(fileURLToPath(import.meta.resolve("taskflow-mcp-core"))); + for (;;) { + try { + const manifest = JSON.parse(readFileSync(resolve(directory, "package.json"), "utf8")) as { + name?: string; bin?: Record; + }; + if (manifest.name === "taskflow-mcp-core") { + const entry = manifest.bin?.["taskflow-control"]; + if (typeof entry !== "string") break; + const cli = realpathSync(resolve(directory, entry)); + const suffix = relative(realpathSync(directory), cli); + if (isAbsolute(suffix) || suffix === ".." || suffix.startsWith(`..${sep}`)) break; + if (!statSync(cli).isFile()) break; + return cli; + } + } catch { /* continue to the owning package manifest */ } + const parent = dirname(directory); + if (parent === directory) break; + directory = parent; + } + throw new WebCommandError("Installed taskflow-mcp-core Control CLI is missing; rebuild or repair the Pi package installation."); +} + +function exited(child: ChildProcess): boolean { + return child.exitCode !== null || child.signalCode !== null || child.pid === undefined; +} + +function waitForExit(child: ChildProcess, timeoutMs: number): Promise { + if (exited(child)) return Promise.resolve(true); + return new Promise((done) => { + const finish = (result: boolean) => { + clearTimeout(timer); + child.off("exit", onExit); + done(result); + }; + const onExit = () => finish(true); + const timer = setTimeout(() => finish(false), timeoutMs); + child.once("exit", onExit); + }); +} + +/** Only signal the ChildProcess we spawned, never a PID from a registry. */ +async function stopChild(child: ChildProcess, timeoutMs: number): Promise { + if (exited(child)) return; + child.kill("SIGTERM"); + if (await waitForExit(child, timeoutMs)) return; + child.kill("SIGKILL"); + if (!await waitForExit(child, timeoutMs)) throw new WebCommandError("Owned Control Console process did not exit after SIGKILL."); +} + +export async function openDefaultBrowser(url: string, platform = process.platform, timeoutMs = 5_000): Promise { + const executable = platform === "darwin" ? "open" : "xdg-open"; + const child = spawn(executable, [url], { stdio: "ignore", shell: false }); + try { + await new Promise((done, reject) => { + const finish = (error?: Error) => { + clearTimeout(timer); + child.off("error", onError); + child.off("exit", onExit); + if (error) reject(error); else done(); + }; + const onError = () => finish(new WebCommandError("Browser opener unavailable.")); + const onExit = (code: number | null) => finish(code === 0 ? undefined : new WebCommandError("Browser opener failed.")); + const timer = setTimeout(() => finish(new WebCommandError("Browser opener timed out.")), timeoutMs); + child.once("error", onError); + child.once("exit", onExit); + }); + } finally { await stopChild(child, 250); } +} + +function locationFrom(value: unknown): ConsoleLocation | undefined { + if (!value || typeof value !== "object") return; + const data = value as Partial; + if (typeof data.consoleUrl !== "string" || typeof data.browserHandoffFile !== "string") return; + const url = new URL(data.consoleUrl); + if (url.protocol !== "http:" || url.hostname !== "127.0.0.1" || !url.port || url.username || url.password || + url.pathname !== "/" || url.search || url.hash) throw new WebCommandError("Console returned an unsafe browser URL."); + const file = data.browserHandoffFile; + if (!isAbsolute(file) || /[\x00-\x1f\x7f]/u.test(file) || !/^console-handoff-[a-f0-9-]+\.json$/u.test(basename(file))) { + throw new WebCommandError("Console returned an invalid private handoff path."); + } + const stat = lstatSync(file); + if (!stat.isFile() || (stat.mode & 0o777) !== 0o600 || (process.getuid && stat.uid !== process.getuid())) { + throw new WebCommandError("Console login handoff must be a private same-user file (0600)."); + } + return { consoleUrl: url.origin, browserHandoffFile: file }; +} + +/** Deliberately expose only known error codes, never child output or exception text. */ +function safeFailure(error: unknown): string { + if (error instanceof WebCommandError) return error.message; + const code = error && typeof error === "object" && "code" in error ? String(error.code) : ""; + const safeCode = ["ENOENT", "EACCES", "EPERM", "EADDRINUSE"].includes(code) ? ` (${code})` : ""; + return `Control Console could not start${safeCode}; check the installed carrier and local Control service access.`; +} + +interface OwnedConsole { + child: ChildProcess; + identity: string; + location?: ConsoleLocation; +} + +export function createWebCommand(options: WebCommandOptions = {}) { + const platform = options.platform ?? process.platform; + const stopTimeout = options.stopTimeoutMs ?? 1_000; + let owned: OwnedConsole | undefined; + let closed = false; + let queue = Promise.resolve(); + const shutdown = new AbortController(); + const serialize = (action: () => Promise) => { + const next = queue.then(action); + queue = next.catch(() => {}); + return next; + }; + const stopOwned = async () => { + if (!owned) return; + await stopChild(owned.child, stopTimeout); + owned = undefined; + }; + + async function start(root: string, identity: string): Promise { + const cli = (options.resolveCli ?? resolveControlCli)(); + const child = spawn(process.execPath, [cli, "serve", "--root", root, "--console"], { + cwd: root, stdio: ["ignore", "pipe", "pipe"], shell: false, + }); + const record: OwnedConsole = { child, identity }; + owned = record; + try { + record.location = await new Promise((done, reject) => { + let buffer = "", bytes = 0, diagnostics = ""; + const stdoutDecoder = new StringDecoder("utf8"), stderrDecoder = new StringDecoder("utf8"); + let settled = false; + const finish = (error?: Error, location?: ConsoleLocation) => { + if (settled) return; + settled = true; + clearTimeout(timer); + shutdown.signal.removeEventListener("abort", onAbort); + child.off("error", onError); + child.off("exit", onExit); + // Keep the pipes draining without retaining any further output. + child.stdout?.off("data", onOutput); + child.stderr?.off("data", onStderr); + child.stdout?.resume(); child.stderr?.resume(); + if (error) reject(error); else done(location!); + }; + const count = (chunk: Buffer) => { + bytes += chunk.length; + if (bytes > OUTPUT_LIMIT) { finish(new WebCommandError("Console startup output exceeded 64 KiB.")); return false; } + return true; + }; + const onOutput = (chunk: Buffer) => { + if (!count(chunk)) return; + buffer += stdoutDecoder.write(chunk); + let newline: number; + while ((newline = buffer.indexOf("\n")) >= 0 && !settled) { + const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1); + let value: unknown; + try { value = JSON.parse(line); } catch { continue; } + try { const location = locationFrom(value); if (location) finish(undefined, location); } + catch (error) { finish(new WebCommandError(safeFailure(error))); } + } + }; + const onStderr = (chunk: Buffer) => { if (count(chunk)) diagnostics += stderrDecoder.write(chunk); }; + const onError = (error: Error) => finish(new WebCommandError(safeFailure(error))); + const onExit = (code: number | null, signal: NodeJS.Signals | null) => { + const reason = [...new Set(diagnostics.match(/\b(?:EPERM|EACCES|EADDRINUSE|ENOENT|TF_BOOTSTRAP_FAILED)\b/gu) ?? [])].join(", "); + const hint = diagnostics.includes("serve requires ownership") ? " Another host owns this Control home; stop it before launching a console." : ""; + finish(new WebCommandError(`Console exited before readiness (${signal ?? code ?? "unknown"})${reason ? `: ${reason}` : ""}.${hint}`)); + }; + const onAbort = () => finish(new WebCommandError("Console startup cancelled by session shutdown.")); + const timer = setTimeout(() => finish(new WebCommandError("Console startup timed out.")), options.startupTimeoutMs ?? 15_000); + child.stdout!.on("data", onOutput); child.stderr!.on("data", onStderr); + child.once("error", onError); child.once("exit", onExit); + shutdown.signal.addEventListener("abort", onAbort, { once: true }); + if (shutdown.signal.aborted) onAbort(); + }); + if (exited(child)) throw new WebCommandError("Console exited immediately after readiness."); + return record; + } catch (error) { await stopOwned(); throw error; } + } + + return { + async run(ctx: WebCommandContext): Promise { + return serialize(async () => { + try { + if (closed) throw new WebCommandError("Control Console session is already shut down."); + if (platform === "win32") throw new WebCommandError("/tf web requires Unix local Control transport; Windows is unsupported."); + const root = realpathSync(ctx.cwd), stat = statSync(root); + const identity = `${root}:${stat.dev}:${stat.ino}`; + if (owned && (owned.identity !== identity || exited(owned.child))) await stopOwned(); + const console = owned ?? await start(root, identity); + const location = console.location!; + // Always make the safe URL/file visible, including when opening fails. + ctx.ui.notify(`Control Console: ${location.consoleUrl}\nPrivate login handoff: ${location.browserHandoffFile}\nSame-user local access: use this private file's one-use token in the console login form.\nViews Control Plane runs and evidence; legacy raw transcripts are outside this view.`, "info"); + try { + await (options.openBrowser ?? ((url) => openDefaultBrowser(url, platform)))(location.consoleUrl); + } catch { + ctx.ui.notify(`Browser launch failed. Open ${location.consoleUrl} manually; use the private login handoff file shown above.`, "warning"); + } + } catch (error) { ctx.ui.notify(safeFailure(error), "error"); } + }); + }, + async close(): Promise { + closed = true; + shutdown.abort(); + return serialize(stopOwned); + }, + }; +} diff --git a/packages/pi-taskflow/test/web-command.test.ts b/packages/pi-taskflow/test/web-command.test.ts new file mode 100644 index 00000000..668322c2 --- /dev/null +++ b/packages/pi-taskflow/test/web-command.test.ts @@ -0,0 +1,221 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { test } from "node:test"; +import type { ExtensionAPI, ExtensionCommandContext, RegisteredCommand, ToolDefinition } from "@earendil-works/pi-coding-agent"; +import registerTaskflow from "../src/index.ts"; +import { createWebCommand, openDefaultBrowser, type WebCommandOptions } from "../src/web-command.ts"; + +const SECRET = `test-${randomUUID()}`; +const pause = (ms: number) => new Promise((done) => setTimeout(done, ms)); + +function fixture(mode = "ready") { + // Short Unix socket paths also work on macOS. + const temporary = realpathSync(mkdtempSync("/tmp/tfw-")); + const root = join(temporary, "project with spaces;$literal"); + mkdirSync(root); + const pidFile = join(temporary, "pid"); + const argvFile = join(temporary, "argv.json"); + const handoff = join(temporary, "console-handoff-aaaa.json"); + const cli = join(temporary, "child.mjs"); + writeFileSync(cli, ` +import fs from 'node:fs'; +fs.writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); +fs.writeFileSync(${JSON.stringify(argvFile)}, JSON.stringify({argv:process.argv.slice(2),cwd:process.cwd()})); +const mode = ${JSON.stringify(mode)}; +if (mode === 'exit') { process.stderr.write('private ${SECRET} TF_BOOTSTRAP_FAILED EPERM'); process.exit(7); } +process.on('SIGTERM', () => { if (mode !== 'hang') process.exit(0); }); +setInterval(() => {}, 1000); +if (mode === 'hang') { /* no readiness, ignores graceful termination */ } +else if (mode === 'flood') process.stderr.write('x'.repeat(70 * 1024) + '${SECRET}'); +else { + fs.writeFileSync(${JSON.stringify(handoff)}, JSON.stringify({url:'http://127.0.0.1:12345',token:'${SECRET}'}), {mode: mode === 'permissions' ? 0o644 : 0o600}); + const url = mode === 'unsafe' ? 'http://127.0.0.1:12345/?token=${SECRET}' : 'http://127.0.0.1:12345'; + const line = JSON.stringify({consoleUrl:url,browserHandoffFile:${JSON.stringify(handoff)},token:'${SECRET}'}) + '\\n'; + process.stdout.write(line.slice(0,12)); setTimeout(() => process.stdout.write(line.slice(12)), 10); +} +`); + const messages: { text: string; kind?: string }[] = []; + const context = { cwd: root, ui: { notify(text: string, kind?: string) { messages.push({ text, kind }); } } }; + const options: WebCommandOptions = { resolveCli: () => cli, openBrowser: async () => {}, startupTimeoutMs: 1_000, stopTimeoutMs: 100 }; + return { temporary, root, cli, handoff, pidFile, argvFile, messages, context, options, + clean() { rmSync(temporary, { recursive: true, force: true }); } }; +} + +function alive(pid: number): boolean { + try { process.kill(pid, 0); return true; } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ESRCH") return false; + throw error; + } +} + +function assertPrivate(messages: { text: string }[]) { + assert.ok(messages.length); + assert.doesNotMatch(messages.map((message) => message.text).join("\n"), new RegExp(SECRET)); +} + +test("web lifecycle: concurrent invocation and symlink cwd reuse one owned child, then shutdown", async () => { + const f = fixture(); + let opens = 0; + const web = createWebCommand({ ...f.options, openBrowser: async (url) => { assert.equal(url, "http://127.0.0.1:12345"); opens++; } }); + const unrelated = spawn(process.execPath, ["-e", "setInterval(()=>{},1000)"], { stdio: "ignore" }); + try { + await Promise.all([web.run(f.context), web.run(f.context)]); + const pid = Number(readFileSync(f.pidFile, "utf8")); + const alias = join(f.temporary, "alias"); symlinkSync(f.root, alias); + await web.run({ ...f.context, cwd: alias }); + assert.equal(Number(readFileSync(f.pidFile, "utf8")), pid); + assert.equal(opens, 3); + assert.deepEqual(JSON.parse(readFileSync(f.argvFile, "utf8")), { + argv: ["serve", "--root", f.root, "--console"], cwd: f.root, + }); + assertPrivate(f.messages); + assert.match(f.messages[0]!.text, /Control Plane runs and evidence/); + assert.match(f.messages[0]!.text, /Private login handoff:/); + await web.close(); + assert.equal(alive(pid), false); + assert.equal(alive(unrelated.pid!), true, "never signal an unrelated process"); + await web.close(); + await web.run(f.context); + assert.match(f.messages.at(-1)!.text, /already shut down/); + } finally { await web.close(); unrelated.kill("SIGKILL"); f.clean(); } +}); + +test("web lifecycle: changed canonical cwd stops old child before replacement; dead child restarts", async () => { + const f = fixture(); const web = createWebCommand(f.options); + try { + await web.run(f.context); + const first = Number(readFileSync(f.pidFile, "utf8")); + const other = join(f.temporary, "other"); mkdirSync(other); + await web.run({ ...f.context, cwd: other }); + const second = Number(readFileSync(f.pidFile, "utf8")); + assert.notEqual(first, second); assert.equal(alive(first), false); + process.kill(second, "SIGTERM"); + for (let i = 0; i < 100 && alive(second); i++) await pause(10); + assert.equal(alive(second), false); + await web.run({ ...f.context, cwd: other }); + assert.notEqual(Number(readFileSync(f.pidFile, "utf8")), second); + assertPrivate(f.messages); + } finally { await web.close(); f.clean(); } +}); + +for (const [mode, expected] of [ + ["exit", /before readiness \(7\).*TF_BOOTSTRAP_FAILED.*EPERM/u], + ["hang", /startup timed out/u], + ["flood", /exceeded 64 KiB/u], + ["unsafe", /unsafe browser URL/u], + ["permissions", /private same-user file/u], +] as const) { + test(`web startup failure: ${mode} is bounded, private and reaps its real child`, async () => { + const f = fixture(mode); const web = createWebCommand(f.options); + try { + await web.run(f.context); + assert.equal(f.messages.at(-1)!.kind, "error"); + assert.match(f.messages.at(-1)!.text, expected); + assert.equal(alive(Number(readFileSync(f.pidFile, "utf8"))), false); + assertPrivate(f.messages); + } finally { await web.close(); f.clean(); } + }); +} + +test("web lifecycle: shutdown aborts an in-flight startup instead of waiting for readiness timeout", async () => { + const f = fixture("hang"); const web = createWebCommand({ ...f.options, startupTimeoutMs: 60_000 }); + try { + const starting = web.run(f.context); + for (let i = 0; i < 100 && !existsSync(f.pidFile); i++) await pause(10); + assert.ok(existsSync(f.pidFile)); + await web.close(); await starting; + assert.equal(alive(Number(readFileSync(f.pidFile, "utf8"))), false); + assert.match(f.messages.at(-1)!.text, /cancelled by session shutdown/u); + } finally { await web.close(); f.clean(); } +}); + +test("web lifecycle: a failed startup can be repaired and retried in the same session", async () => { + const failed = fixture("exit"), ready = fixture(); + let cli = failed.cli; + const web = createWebCommand({ ...failed.options, resolveCli: () => cli }); + try { + await web.run(failed.context); + assert.equal(failed.messages.at(-1)!.kind, "error"); + assert.equal(alive(Number(readFileSync(failed.pidFile, "utf8"))), false); + cli = ready.cli; + await web.run(failed.context); + assert.equal(failed.messages.at(-1)!.kind, "info"); + const pid = Number(readFileSync(ready.pidFile, "utf8")); + assert.equal(alive(pid), true); + await web.close(); assert.equal(alive(pid), false); + assertPrivate(failed.messages); + } finally { await web.close(); failed.clean(); ready.clean(); } +}); + +test("web startup failure: missing carrier and unsupported platform fail without spawning", async () => { + const f = fixture(); + try { + for (const options of [ + { ...f.options, resolveCli: () => join(f.temporary, "missing.js") }, + { ...f.options, platform: "win32" as const, resolveCli: () => assert.fail("must not resolve on unsupported platform") }, + ]) { + const web = createWebCommand(options); + try { await web.run(f.context); } finally { await web.close(); } + assert.equal(f.messages.at(-1)!.kind, "error"); + } + assert.match(f.messages[0]!.text, /before readiness/u); + assert.match(f.messages[1]!.text, /Windows is unsupported/u); + assert.equal(existsSync(f.pidFile), false); + } finally { f.clean(); } +}); + +test("web browser fallback: opener errors never disclose exception text or stop the console", async () => { + const f = fixture(); const web = createWebCommand({ ...f.options, openBrowser: async () => { throw new Error(SECRET); } }); + try { + await web.run(f.context); + assert.equal(f.messages.at(-1)!.kind, "warning"); + assert.match(f.messages.at(-1)!.text, /Open http:\/\/127\.0\.0\.1:12345 manually/u); + assert.equal(alive(Number(readFileSync(f.pidFile, "utf8"))), true); + assertPrivate(f.messages); + } finally { await web.close(); f.clean(); } +}); + +test("web browser opener: harmless PATH executable receives only URL and launch timeout reaps it", async () => { + const f = fixture(); const previousPath = process.env.PATH; + try { + const bin = join(f.temporary, "bin"); mkdirSync(bin); + const opener = join(bin, process.platform === "darwin" ? "open" : "xdg-open"); + writeFileSync(opener, `#!${process.execPath}\nimport fs from 'node:fs';fs.writeFileSync(${JSON.stringify(f.argvFile)},JSON.stringify(process.argv.slice(2)));\n`); + chmodSync(opener, 0o755); process.env.PATH = `${bin}:${previousPath ?? ""}`; + await openDefaultBrowser("http://127.0.0.1:12345"); + assert.deepEqual(JSON.parse(readFileSync(f.argvFile, "utf8")), ["http://127.0.0.1:12345"]); + writeFileSync(opener, `#!${process.execPath}\nprocess.exit(1);\n`); + const web = createWebCommand({ ...f.options, openBrowser: undefined }); + try { + await web.run(f.context); + assert.equal(f.messages.at(-1)!.kind, "warning"); + assert.match(f.messages.at(-1)!.text, /Open http:\/\/127\.0\.0\.1:12345 manually/u); + } finally { await web.close(); } + writeFileSync(opener, `#!${process.execPath}\nimport fs from 'node:fs';fs.writeFileSync(${JSON.stringify(f.pidFile)},String(process.pid));process.on('SIGTERM',()=>{});setInterval(()=>{},1000);\n`); + await assert.rejects(openDefaultBrowser("http://127.0.0.1:12345", process.platform, 500), /timed out/u); + assert.equal(alive(Number(readFileSync(f.pidFile, "utf8"))), false); + } finally { if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; f.clean(); } +}); + +// Actual installed startup/login/reuse/shutdown is covered by the packed consumer +// smoke. Source tests run before dist exists in a clean CI checkout. +test("registered /tf web is completed, validates arguments, and stays outside model tools", async () => { + const messages: string[] = []; + let command: RegisteredCommand | undefined; + let shutdown: (() => Promise) | undefined; + let tool: ToolDefinition | undefined; + registerTaskflow({ + registerCommand(name: string, definition: RegisteredCommand) { if (name === "tf") command = definition; }, + registerTool(definition: ToolDefinition) { tool = definition; }, + on(event: string, handler: () => Promise) { if (event === "session_shutdown") shutdown = handler; }, + } as unknown as ExtensionAPI); + assert.ok(command); assert.ok(shutdown); assert.ok(tool); + assert.ok((await command.getArgumentCompletions?.("we"))?.some(item => item.value === "web")); + assert.doesNotMatch(JSON.stringify(tool.parameters), /"web"/u); + await command.handler("web extra", { cwd: process.cwd(), ui: { notify(text: string) { messages.push(text); } } } as unknown as ExtensionCommandContext); + assert.deepEqual(messages, ["Usage: /tf web"]); + await shutdown(); +}); diff --git a/scripts/smoke-packed-packages.mjs b/scripts/smoke-packed-packages.mjs index 6d5dd1b0..0bbe531b 100644 --- a/scripts/smoke-packed-packages.mjs +++ b/scripts/smoke-packed-packages.mjs @@ -315,6 +315,7 @@ try { smokeMcpBin(binName, serverName); } smokeControlBundle(consumerDir); + await smokePiWebConsole(consumerDir); process.stdout.write( `packed consumer smoke passed: ${packageNames.length} packages, ${publicImports.length} explicit imports, ${wildcardExports} wildcard exports, 8 bins\n`, @@ -323,6 +324,73 @@ try { rmSync(temporaryRoot, { recursive: true, force: true }); } +/** Exercise the registered public Pi slash command, not a source-only launcher. + * Keep the browser harmless while testing the real installed console process. */ +async function smokePiWebConsole(installedConsumer) { + const temporary = realpathSync(mkdtempSync("/tmp/tfwp-")); + const previousHome = process.env.TASKFLOW_HOME; + const previousPath = process.env.PATH; + let shutdown; + try { + const project = join(temporary, "project with spaces"), home = join(temporary, "home"), bin = join(temporary, "bin"); + mkdirSync(project); mkdirSync(bin); + const openerCapture = join(temporary, "browser-argv.json"); + const opener = join(bin, process.platform === "darwin" ? "open" : "xdg-open"); + writeFileSync(opener, `#!${process.execPath}\nimport fs from 'node:fs';fs.writeFileSync(${JSON.stringify(openerCapture)},JSON.stringify(process.argv.slice(2)));process.exit(1);\n`); + chmodSync(opener, 0o755); + process.env.TASKFLOW_HOME = home; + process.env.PATH = `${bin}:${previousPath ?? ""}`; + const installedRequire = createRequire(join(installedConsumer, "pi-web-consumer.mjs")); + const piEntry = installedRequire.resolve("pi-taskflow"); + assert.ok(realpathSync(piEntry).startsWith(realpathSync(join(installedConsumer, "node_modules", "pi-taskflow")) + "/")); + assert.equal(realpathSync(piEntry).endsWith("/dist/index.js"), true); + assert.doesNotMatch(piEntry, /\/src\//); + assert.doesNotMatch(readFileSync(join(installedConsumer, "node_modules", "pi-taskflow", "package.json"), "utf8"), /workspace:/); + const extension = (await import(pathToFileURL(piEntry).href)).default; + let tf, tool; + extension({ + registerCommand(name, definition) { if (name === "tf") tf = definition; }, + registerTool(definition) { if (definition.name === "taskflow") tool = definition; }, + on(event, handler) { if (event === "session_shutdown") shutdown = handler; }, + }); + assert.ok(tf); assert.ok(shutdown); assert.ok(tool); + assert.ok((await tf.getArgumentCompletions("we")).some((item) => item.value === "web")); + assert.doesNotMatch(JSON.stringify(tool.parameters), /"web"/); + const messages = []; + const ctx = { cwd: project, ui: { notify(text, kind) { messages.push({ text, kind }); } } }; + await tf.handler("web", ctx); + assert.equal(messages.some((message) => message.kind === "error"), false, JSON.stringify(messages)); + assert.equal(messages.at(-1).kind, "warning", "failed opener must show a manual URL"); + const info = messages.find((message) => message.text.startsWith("Control Console:")).text; + assert.match(info, /Control Plane runs and evidence/); + const url = info.match(/Control Console: (http:\/\/127\.0\.0\.1:\d+)/)[1]; + const handoffFile = info.match(/Private login handoff: (.+)/)[1]; + const handoff = JSON.parse(readFileSync(handoffFile, "utf8")); + assert.equal(handoff.url, url); + assert.ok(handoff.token); + assert.equal(messages.some((message) => message.text.includes(handoff.token)), false, "notifications must not disclose the login token"); + assert.deepEqual(JSON.parse(readFileSync(openerCapture, "utf8")), [url], "browser receives no bootstrap secret"); + assert.equal((await fetch(url, { signal: AbortSignal.timeout(5_000) })).status, 200); + assert.equal((await fetch(`${url}/api/projects`, { signal: AbortSignal.timeout(5_000) })).status, 401); + const lockFile = join(home, "control", "singleton.lock.json"); + const before = readFileSync(lockFile, "utf8"); + await tf.handler("web", ctx); + assert.equal(readFileSync(lockFile, "utf8"), before, "repeat command must reuse its owned carrier"); + assert.equal(messages.some((message) => message.text.includes(handoff.token)), false); + await shutdown(); + assert.equal(globSync("console-handoff-*.json", { cwd: join(home, "control") }).length, 0); + await assert.rejects(fetch(url, { signal: AbortSignal.timeout(5_000) }), "session shutdown must close the installed console"); + process.stdout.write("packed Pi /tf web passed: public carrier, routing/completions, browser fallback, login boundary, reuse and owned shutdown\n"); + } finally { + try { await shutdown?.(); } + finally { + if (previousHome === undefined) delete process.env.TASKFLOW_HOME; else process.env.TASKFLOW_HOME = previousHome; + if (previousPath === undefined) delete process.env.PATH; else process.env.PATH = previousPath; + rmSync(temporary, { recursive: true, force: true }); + } + } +} + function exportTargets(exportsValue, condition) { const targets = []; for (const entry of Object.values(exportsValue ?? {})) {