From cf2d1a768e28b60c558cd4da06db77153bc7dc4f Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Sun, 17 May 2026 20:05:53 +0200 Subject: [PATCH] feat(sf): route server control through rpc --- bin/sf-from-source | 3 + .../coding-agent/src/modes/rpc/rpc-client.ts | 24 ++++ .../coding-agent/src/modes/rpc/rpc-mode.ts | 106 ++++++++++++++ .../src/modes/rpc/rpc-protocol-v2.test.ts | 2 + .../coding-agent/src/modes/rpc/rpc-types.ts | 24 ++++ packages/rpc-client/src/rpc-client.ts | 24 ++++ packages/rpc-client/src/rpc-types.ts | 24 ++++ src/headless-server-forward.ts | 131 ++++++++++++++++++ src/headless.ts | 19 +++ .../extensions/sf/tests/resolve-ts.mjs | 9 +- src/tests/integration/web-auth-token.test.ts | 84 ++++++++++- .../integration/web-bridge-contract.test.ts | 85 ++++++++++++ .../web-bridge-package-root.test.ts | 12 ++ src/tests/integration/web-mode-cli.test.ts | 83 ++++++++++- src/web-mode.ts | 39 ++++-- src/web/bridge-service.ts | 130 ++++++++++++++++- web/components/sf/Login.tsx | 17 ++- web/next-env.d.ts | 2 +- web/pages/api/login.ts | 13 +- web/proxy.ts | 4 + 20 files changed, 807 insertions(+), 28 deletions(-) create mode 100644 src/headless-server-forward.ts diff --git a/bin/sf-from-source b/bin/sf-from-source index b49872f9e..7dee9aa81 100755 --- a/bin/sf-from-source +++ b/bin/sf-from-source @@ -106,6 +106,9 @@ sf_cleanup_dead_lock_holder "$SF_PROJECT_LOCK_FILE" # 2026-05-17 when `sf headless query` / `feedback list` / --help were # rejected with "Another sf is already running" despite being pure reads). case "${1:-} ${2:-}" in + "server "*|"serve "*|"web "*) + : # server owns its own lifecycle; do not hold the project writer lock forever + ;; "logs "*|"status "*|"dash "*|"sessions "*|"list "*|"--version "*|"-v "*|"--help "*|"-h "*) : # top-level read-only — no lock needed ;; diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 2757b4136..a3b5a4655 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -395,6 +395,30 @@ export class RpcClient { await this.send({ type: "prompt", message, images }); } + /** + * Start the SF autonomous workflow through a typed RPC command. + */ + async startAutonomous(): Promise { + await this.send({ type: "start_autonomous" }); + } + + /** + * Run an SF self-feedback write through the active RPC process. + */ + async sfFeedback( + subcommand: "add" | "resolve", + args: string[], + json = false, + ): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const response = await this.send({ + type: "sf_feedback", + subcommand, + args, + json, + }); + return this.getData(response); + } + /** * Queue a steering message for the agent at the next safe turn. */ diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 9830ea50d..df1cbecfe 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -13,6 +13,8 @@ import * as crypto from "node:crypto"; import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import type { WriteStream } from "node:tty"; +import { pathToFileURL } from "node:url"; import { dirname, join, resolve } from "node:path"; import type { AgentSession } from "../../core/agent-session.js"; import type { @@ -40,6 +42,58 @@ const RUNTIME_HEARTBEAT_INTERVAL_MS = Number( process.env.SF_RUNTIME_HEARTBEAT_INTERVAL_MS ?? 10_000, ); +async function captureProcessWrites( + run: () => Promise, +): Promise<{ result: T; stdout: string; stderr: string }> { + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + let stdout = ""; + let stderr = ""; + process.stdout.write = ((chunk: string | Uint8Array) => { + stdout += + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + return true; + }) as WriteStream["write"]; + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr += + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + return true; + }) as WriteStream["write"]; + try { + const result = await run(); + return { result, stdout, stderr }; + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + } +} + +async function loadHeadlessFeedbackHandler(): Promise<{ + handleFeedback: ( + basePath: string, + options: { + subcommand: "add" | "list" | "resolve"; + args: string[]; + json: boolean; + }, + ) => Promise<{ exitCode: number }>; +}> { + const root = findRuntimeSourceRoot(); + const sourcePath = join(root, "src", "headless-feedback.ts"); + const distPath = join(root, "dist", "headless-feedback.js"); + const modulePath = existsSync(sourcePath) ? sourcePath : distPath; + return (await import(pathToFileURL(modulePath).href)) as { + handleFeedback: ( + basePath: string, + options: { + subcommand: "add" | "list" | "resolve"; + args: string[]; + json: boolean; + }, + ) => Promise<{ exitCode: number }>; + }; +} + function findRuntimeSourceRoot(): string { const explicit = process.env.SF_RUNTIME_SOURCE_ROOT ?? process.env.SF_SOURCE_ROOT; @@ -791,6 +845,58 @@ export async function runRpcMode(session: AgentSession): Promise { } as RpcResponse; } + case "start_autonomous": { + const runId = protocolVersion === 2 ? crypto.randomUUID() : undefined; + if (runId) currentRunId = runId; + await extensionsReadyPromise; + void (async () => { + const previousHeadless = process.env.SF_HEADLESS; + process.env.SF_HEADLESS = "1"; + try { + await session.prompt("/autonomous", { + source: "rpc", + }); + } catch (e) { + output( + error( + id, + "start_autonomous", + e instanceof Error ? e.message : String(e), + ), + ); + } finally { + if (previousHeadless === undefined) { + delete process.env.SF_HEADLESS; + } else { + process.env.SF_HEADLESS = previousHeadless; + } + } + })(); + return { + id, + type: "response", + command: "start_autonomous", + success: true, + ...(runId && { runId }), + } as RpcResponse; + } + + case "sf_feedback": { + const { handleFeedback } = await loadHeadlessFeedbackHandler(); + const captured = await captureProcessWrites(() => + handleFeedback(process.cwd(), { + subcommand: command.subcommand, + args: command.args, + json: command.json === true, + }), + ); + return success(id, "sf_feedback", { + exitCode: captured.result.exitCode, + stdout: captured.stdout, + stderr: captured.stderr, + }); + } + case "steer": { // v2: generate runId for execution tracking const runId = protocolVersion === 2 ? crypto.randomUUID() : undefined; diff --git a/packages/coding-agent/src/modes/rpc/rpc-protocol-v2.test.ts b/packages/coding-agent/src/modes/rpc/rpc-protocol-v2.test.ts index c2c16e63f..054c698e3 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-protocol-v2.test.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-protocol-v2.test.ts @@ -308,10 +308,12 @@ describe("v2 type shapes", () => { type: "subscribe", events: ["agent_end"], }; + const startAutonomousCmd: RpcCommand = { type: "start_autonomous" }; assert.equal(initCmd.type, "init"); assert.equal(shutdownCmd.type, "shutdown"); assert.equal(subscribeCmd.type, "subscribe"); + assert.equal(startAutonomousCmd.type, "start_autonomous"); }); it("init command supports optional clientId", () => { diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index 2bfcb8f8b..8fe2d3e80 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -39,6 +39,16 @@ export type RpcCommand = | { id?: string; type: "abort" } | { id?: string; type: "new_session"; parentSession?: string } + // SF workflow control + | { id?: string; type: "start_autonomous" } + | { + id?: string; + type: "sf_feedback"; + subcommand: "add" | "resolve"; + args: string[]; + json?: boolean; + } + // State | { id?: string; type: "get_state" } @@ -163,6 +173,20 @@ export type RpcResponse = runId?: string; } | { id?: string; type: "response"; command: "abort"; success: true } + | { + id?: string; + type: "response"; + command: "start_autonomous"; + success: true; + runId?: string; + } + | { + id?: string; + type: "response"; + command: "sf_feedback"; + success: true; + data: { exitCode: number; stdout: string; stderr: string }; + } | { id?: string; type: "response"; diff --git a/packages/rpc-client/src/rpc-client.ts b/packages/rpc-client/src/rpc-client.ts index cfee291ae..0e510af94 100644 --- a/packages/rpc-client/src/rpc-client.ts +++ b/packages/rpc-client/src/rpc-client.ts @@ -468,6 +468,30 @@ export class RpcClient { await this.send({ type: "prompt", message, images }); } + /** + * Start the SF autonomous workflow through a typed RPC command. + */ + async startAutonomous(): Promise { + await this.send({ type: "start_autonomous" }); + } + + /** + * Run an SF self-feedback write through the active RPC process. + */ + async sfFeedback( + subcommand: "add" | "resolve", + args: string[], + json = false, + ): Promise<{ exitCode: number; stdout: string; stderr: string }> { + const response = await this.send({ + type: "sf_feedback", + subcommand, + args, + json, + }); + return this.getData(response); + } + /** * Queue a steering message for the agent at the next safe turn. */ diff --git a/packages/rpc-client/src/rpc-types.ts b/packages/rpc-client/src/rpc-types.ts index 2d0779b8a..d4832db2f 100644 --- a/packages/rpc-client/src/rpc-types.ts +++ b/packages/rpc-client/src/rpc-types.ts @@ -105,6 +105,16 @@ export type RpcCommand = | { id?: string; type: "abort" } | { id?: string; type: "new_session"; parentSession?: string } + // SF workflow control + | { id?: string; type: "start_autonomous" } + | { + id?: string; + type: "sf_feedback"; + subcommand: "add" | "resolve"; + args: string[]; + json?: boolean; + } + // State | { id?: string; type: "get_state" } @@ -229,6 +239,20 @@ export type RpcResponse = runId?: string; } | { id?: string; type: "response"; command: "abort"; success: true } + | { + id?: string; + type: "response"; + command: "start_autonomous"; + success: true; + runId?: string; + } + | { + id?: string; + type: "response"; + command: "sf_feedback"; + success: true; + data: { exitCode: number; stdout: string; stderr: string }; + } | { id?: string; type: "response"; diff --git a/src/headless-server-forward.ts b/src/headless-server-forward.ts new file mode 100644 index 000000000..f1ad28499 --- /dev/null +++ b/src/headless-server-forward.ts @@ -0,0 +1,131 @@ +/** + * headless-server-forward.ts — forward CLI write commands to an active SF server. + * + * Purpose: keep the repo server as the single active writer while preserving CLI + * ergonomics for operator commands such as `sf headless feedback resolve`. + * + * Consumer: headless.ts before falling back to direct in-process write handlers. + */ + +import { request as httpRequest } from "node:http"; +import { resolve } from "node:path"; +import { readInstanceRegistry, type WebInstanceEntry } from "./web-mode.js"; + +export interface ForwardedHeadlessResult { + exitCode: number; + stdout: string; + stderr: string; +} + +type SfFeedbackResponse = + | { + type: "response"; + command: "sf_feedback"; + success: true; + data: ForwardedHeadlessResult; + } + | { + type: "response"; + command: string; + success: false; + error: string; + }; + +function pidIsAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return !( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ESRCH" + ); + } +} + +function activeServerForProject(basePath: string): WebInstanceEntry | null { + const entry = readInstanceRegistry()[resolve(basePath)]; + if (!entry || !entry.authToken || !pidIsAlive(entry.pid)) return null; + return entry; +} + +function postJson( + url: string, + token: string, + body: unknown, +): Promise<{ statusCode: number; body: string }> { + return new Promise((resolveResult, reject) => { + const payload = JSON.stringify(body); + const req = httpRequest( + url, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(payload), + }, + }, + (response) => { + let responseBody = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { + responseBody += chunk; + }); + response.on("end", () => + resolveResult({ + statusCode: response.statusCode ?? 0, + body: responseBody, + }), + ); + }, + ); + req.setTimeout(30_000, () => { + req.destroy(new Error("server forward timed out after 30000ms")); + }); + req.once("error", reject); + req.end(payload); + }); +} + +export async function forwardFeedbackToActiveServer( + basePath: string, + options: { + subcommand: "add" | "resolve"; + args: string[]; + json: boolean; + }, +): Promise { + if (process.env.SF_NO_SERVER_FORWARD === "1") return null; + const server = activeServerForProject(basePath); + if (!server) return null; + + const response = await postJson( + `${server.url}/api/session/command?project=${encodeURIComponent(resolve(basePath))}`, + server.authToken!, + { + type: "sf_feedback", + subcommand: options.subcommand, + args: options.args, + json: options.json, + }, + ); + if (response.statusCode === 404) return null; + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new Error( + `active server rejected feedback command: http ${response.statusCode}: ${response.body.slice(0, 500)}`, + ); + } + const parsed = JSON.parse(response.body) as SfFeedbackResponse; + if (!parsed.success) { + throw new Error(parsed.error); + } + const command = (parsed as { command?: string }).command; + if (command !== "sf_feedback") { + throw new Error( + `active server returned unexpected command ${command ?? "(missing)"}`, + ); + } + return parsed.data; +} diff --git a/src/headless.ts b/src/headless.ts index d0c3ce7eb..12850f56e 100644 --- a/src/headless.ts +++ b/src/headless.ts @@ -958,6 +958,25 @@ async function runHeadlessOnce( ); return { exitCode: 2, interrupted: false, timedOut: false }; } + if (sub === "add" || sub === "resolve") { + const { forwardFeedbackToActiveServer } = await import( + "./headless-server-forward.js" + ); + const forwarded = await forwardFeedbackToActiveServer(process.cwd(), { + subcommand: sub, + args: options.commandArgs.slice(1), + json: options.json, + }); + if (forwarded) { + if (forwarded.stdout) process.stdout.write(forwarded.stdout); + if (forwarded.stderr) process.stderr.write(forwarded.stderr); + return { + exitCode: forwarded.exitCode, + interrupted: false, + timedOut: false, + }; + } + } const { handleFeedback } = await import("./headless-feedback.js"); const result = await handleFeedback(process.cwd(), { subcommand: sub, diff --git a/src/resources/extensions/sf/tests/resolve-ts.mjs b/src/resources/extensions/sf/tests/resolve-ts.mjs index 170174acc..4fb739a3a 100644 --- a/src/resources/extensions/sf/tests/resolve-ts.mjs +++ b/src/resources/extensions/sf/tests/resolve-ts.mjs @@ -1,5 +1,6 @@ -import { register } from "node:module"; -import { pathToFileURL } from "node:url"; +import { registerHooks } from "node:module"; +import { load, resolve } from "./dist-redirect.mjs"; -// Register hook to redirect imports to the dist directory -register(new URL("./dist-redirect.mjs", import.meta.url), pathToFileURL("./")); +// Register synchronously so Node 26+ avoids the deprecated module.register() +// path while preserving the same source-to-dist redirect hooks. +registerHooks({ resolve, load }); diff --git a/src/tests/integration/web-auth-token.test.ts b/src/tests/integration/web-auth-token.test.ts index 1ef6592cf..44e3a332c 100644 --- a/src/tests/integration/web-auth-token.test.ts +++ b/src/tests/integration/web-auth-token.test.ts @@ -8,7 +8,7 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; import { join } from "node:path"; -import { test } from "vitest"; +import { test, vi } from "vitest"; const projectRoot = process.cwd(); @@ -149,3 +149,85 @@ test("proxy.ts skips auth when SF_WEB_AUTH_TOKEN is not set", () => { "proxy should pass through when no token is configured", ); }); + +test("proxy.ts lets login bootstrap before bearer auth", () => { + assert.match( + proxySource, + /pathname === "\/api\/login"/, + "login must be reachable before the browser has a bearer token", + ); +}); + +// ─── login route contract tests ───────────────────────────────────────────── + +type MockResponse = { + statusCode: number; + body: unknown; + ended: boolean; + status(code: number): MockResponse; + json(body: unknown): MockResponse; + end(): MockResponse; +}; + +function createMockResponse(): MockResponse { + return { + statusCode: 200, + body: undefined, + ended: false, + status(code: number) { + this.statusCode = code; + return this; + }, + json(body: unknown) { + this.body = body; + return this; + }, + end() { + this.ended = true; + return this; + }, + }; +} + +async function loadLoginHandler() { + vi.resetModules(); + const module = await import("../../../web/pages/api/login"); + return module.default as (req: unknown, res: MockResponse) => void; +} + +test("login route accepts configured username and password", async () => { + process.env.SF_WEB_USERNAME = "mhugo"; + process.env.SF_WEB_PASSWORD = "correct-password"; + process.env.SF_WEB_AUTH_TOKEN = "issued-token"; + const handler = await loadLoginHandler(); + const res = createMockResponse(); + + handler( + { + method: "POST", + body: { username: "mhugo", password: "correct-password" }, + }, + res, + ); + + assert.equal(res.statusCode, 200); + assert.deepEqual(res.body, { token: "issued-token" }); +}); + +test("login route rejects wrong username", async () => { + process.env.SF_WEB_USERNAME = "mhugo"; + process.env.SF_WEB_PASSWORD = "correct-password"; + process.env.SF_WEB_AUTH_TOKEN = "issued-token"; + const handler = await loadLoginHandler(); + const res = createMockResponse(); + + handler( + { + method: "POST", + body: { username: "other", password: "correct-password" }, + }, + res, + ); + + assert.equal(res.statusCode, 401); +}); diff --git a/src/tests/integration/web-bridge-contract.test.ts b/src/tests/integration/web-bridge-contract.test.ts index e4e68ef82..faf7a0afe 100644 --- a/src/tests/integration/web-bridge-contract.test.ts +++ b/src/tests/integration/web-bridge-contract.test.ts @@ -379,6 +379,91 @@ test("/api/boot returns current-project workspace data, resumable sessions, onbo assert.equal(harness.spawnCalls, 1); }); +test("/api/boot starts autonomous workflow when server opts in", async (_t) => { + const fixture = makeWorkspaceFixture(); + const sessionPath = createSessionFile( + fixture.projectCwd, + fixture.sessionsDir, + "sess-auto-start", + "Auto Start", + ); + const harness = createHarness((command, current) => { + if (command.type === "get_state") { + current.emit({ + id: command.id, + type: "response", + command: "get_state", + success: true, + data: { + sessionId: "sess-auto-start", + sessionFile: sessionPath, + thinkingLevel: "off", + isStreaming: false, + isCompacting: false, + steeringMode: "all", + followUpMode: "all", + autoCompactionEnabled: false, + autoRetryEnabled: false, + retryInProgress: false, + retryAttempt: 0, + messageCount: 0, + pendingMessageCount: 0, + }, + }); + return; + } + + if (command.type === "start_autonomous") { + current.emit({ + id: command.id, + type: "response", + command: "start_autonomous", + success: true, + }); + return; + } + + assert.fail(`unexpected command during boot: ${command.type}`); + }); + + bridge.configureBridgeServiceForTests({ + env: { + ...process.env, + SF_WEB_PROJECT_CWD: fixture.projectCwd, + SF_WEB_PROJECT_SESSIONS_DIR: fixture.sessionsDir, + SF_WEB_PACKAGE_ROOT: repoRoot, + SF_WEB_AUTO_START_AUTONOMOUS: "1", + }, + spawn: harness.spawn, + indexWorkspace: async () => fakeWorkspaceIndex(), + getAutoDashboardData: () => fakeAutoDashboardData(), + getOnboardingNeeded: () => false, + }); + + afterEach(async () => { + await bridge.resetBridgeServiceForTests(); + fixture.cleanup(); + }); + + const response = await bootRoute.GET(); + assert.equal(response.status, 200); + const payload = (await response.json()) as any; + + assert.equal(payload.bridge.phase, "ready"); + assert.equal(payload.bridge.lastCommandType, "start_autonomous"); + assert.ok( + harness.commands.some((command) => command.type === "start_autonomous"), + "server boot must use the typed RPC workflow command", + ); + assert.ok( + !harness.commands.some( + (command) => + command.type === "prompt" && command.message === "/autonomous", + ), + "server boot must not use browser slash-command compatibility", + ); +}); + test("/api/boot uses the authoritative auto helper by default and stays snapshot-shaped", async (_t) => { const fixture = makeWorkspaceFixture(); const sessionPath = createSessionFile( diff --git a/src/tests/integration/web-bridge-package-root.test.ts b/src/tests/integration/web-bridge-package-root.test.ts index 2dbeb220e..6141d3eb1 100644 --- a/src/tests/integration/web-bridge-package-root.test.ts +++ b/src/tests/integration/web-bridge-package-root.test.ts @@ -20,10 +20,22 @@ test("resolveBridgeRuntimeConfig uses SF_WEB_PACKAGE_ROOT when set", () => { const env = { SF_WEB_PACKAGE_ROOT: "/custom/package/root", SF_WEB_PROJECT_CWD: "/some/project", + SF_WEB_AUTO_START_AUTONOMOUS: "1", } as unknown as NodeJS.ProcessEnv; const config = bridge.resolveBridgeRuntimeConfig(env); assert.equal(config.packageRoot, "/custom/package/root"); + assert.equal(config.autoStartAutonomous, true); +}); + +test("resolveBridgeRuntimeConfig leaves autonomous startup disabled unless server opts in", () => { + const env = { + SF_WEB_PACKAGE_ROOT: "/custom/package/root", + SF_WEB_PROJECT_CWD: "/some/project", + } as unknown as NodeJS.ProcessEnv; + + const config = bridge.resolveBridgeRuntimeConfig(env); + assert.equal(config.autoStartAutonomous, false); }); test("resolveBridgeRuntimeConfig falls back to lazy default when SF_WEB_PACKAGE_ROOT is absent", () => { diff --git a/src/tests/integration/web-mode-cli.test.ts b/src/tests/integration/web-mode-cli.test.ts index f4a7ff779..19c2eb1dd 100644 --- a/src/tests/integration/web-mode-cli.test.ts +++ b/src/tests/integration/web-mode-cli.test.ts @@ -237,6 +237,7 @@ test("launchWebMode uses packaged standalone host when no source web host exists "/tmp/.sf/sessions/--tmp-current-project--", SF_WEB_PACKAGE_ROOT: tmp, SF_WEB_HOST_KIND: "packaged-standalone", + SF_WEB_AUTO_START_AUTONOMOUS: "1", }, }, }); @@ -245,9 +246,14 @@ test("launchWebMode uses packaged standalone host when no source web host exists // PID file must be written with the spawned process's PID assert.deepEqual(writtenPid, { path: pidFilePath, pid: 99999 }); assert.equal(webMode.readPidFile(pidFilePath), 99999); + assert.equal( + webMode.readInstanceRegistry(registryPath)[resolve("/tmp/current-project")] + ?.authToken, + authToken, + ); }); -test("launchWebMode prefers source web host over stale standalone output", async (_t) => { +test("launchWebMode prefers packaged standalone over source web host by default", async (_t) => { const tmp = mkdtempSync(join(tmpdir(), "sf-web-source-preferred-")); const standaloneRoot = join(tmp, "dist", "web", "standalone"); const serverPath = join(standaloneRoot, "server.js"); @@ -296,6 +302,71 @@ test("launchWebMode prefers source web host over stale standalone output", async }, ); + assert.equal(status.ok, true); + if (!status.ok) throw new Error("expected successful web launch status"); + assert.equal(status.hostKind, "packaged-standalone"); + assert.equal(status.hostPath, serverPath); + assert.equal(status.hostRoot, standaloneRoot); + assert.equal(spawnInvocation?.command, process.execPath); + assert.deepEqual(spawnInvocation?.args, [serverPath]); + assert.equal(spawnInvocation?.options.cwd, standaloneRoot); + assert.equal( + spawnInvocation?.options.env.SF_WEB_HOST_KIND, + "packaged-standalone", + ); + assert.equal(spawnInvocation?.options.env.SF_WEB_AUTO_START_AUTONOMOUS, "1"); + assert.equal(spawnInvocation?.options.env.NEXT_PUBLIC_SF_DEV, undefined); +}); + +test("launchWebMode can opt into source web host explicitly", async (_t) => { + const tmp = mkdtempSync(join(tmpdir(), "sf-web-source-opt-in-")); + const standaloneRoot = join(tmp, "dist", "web", "standalone"); + const serverPath = join(standaloneRoot, "server.js"); + const sourceWebRoot = join(tmp, "web"); + const sourceManifest = join(sourceWebRoot, "package.json"); + mkdirSync(standaloneRoot, { recursive: true }); + mkdirSync(sourceWebRoot, { recursive: true }); + writeFileSync(serverPath, 'console.log("standalone")\n'); + writeFileSync(sourceManifest, '{"scripts":{"dev":"next dev"}}\n'); + + let spawnInvocation: + | { command: string; args: readonly string[]; options: Record } + | undefined; + + afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); + }); + + const status = await webMode.launchWebMode( + { + cwd: "/tmp/current-project", + projectSessionsDir: "/tmp/.sf/sessions/--tmp-current-project--", + agentDir: "/tmp/.sf/agent", + packageRoot: tmp, + port: 45124, + }, + { + initResources: () => undefined, + env: { SF_WEB_PREFER_SOURCE: "1" }, + spawn: (command, args, options) => { + spawnInvocation = { + command, + args, + options: options as Record, + }; + return { + pid: 99998, + once: () => undefined, + unref: () => undefined, + } as any; + }, + waitForBootReady: async () => undefined, + openBrowser: () => undefined, + writePidFile: () => undefined, + registryPath: join(tmp, "web-instances.json"), + }, + ); + assert.equal(status.ok, true); if (!status.ok) throw new Error("expected successful web launch status"); assert.equal(status.hostKind, "source-dev"); @@ -314,6 +385,7 @@ test("launchWebMode prefers source web host over stale standalone output", async ]); assert.equal(spawnInvocation?.options.cwd, sourceWebRoot); assert.equal(spawnInvocation?.options.env.SF_WEB_HOST_KIND, "source-dev"); + assert.equal(spawnInvocation?.options.env.SF_WEB_AUTO_START_AUTONOMOUS, "1"); assert.equal(spawnInvocation?.options.env.NEXT_PUBLIC_SF_DEV, "1"); }); @@ -366,6 +438,7 @@ test("launchWebMode defaults to fixed port 4000 when no port is specified", asyn assert.match(openedUrl, /^http:\/\/127\.0\.0\.1:4000\/#token=[a-f0-9]{64}$/); assert.equal(spawnEnv?.PORT, "4000"); assert.equal(spawnEnv?.SF_WEB_PORT, "4000"); + assert.equal(spawnEnv?.SF_WEB_AUTO_START_AUTONOMOUS, "1"); }); test("stopWebMode kills process by PID and removes PID file", (_t) => { @@ -705,7 +778,12 @@ test("registerInstance and readInstanceRegistry round-trip", (_t) => { webMode.registerInstance( "/tmp/project-a", - { pid: 1001, port: 3000, url: "http://127.0.0.1:3000" }, + { + pid: 1001, + port: 3000, + url: "http://127.0.0.1:3000", + authToken: "token-a", + }, registryPath, ); webMode.registerInstance( @@ -717,6 +795,7 @@ test("registerInstance and readInstanceRegistry round-trip", (_t) => { const registry = webMode.readInstanceRegistry(registryPath); assert.equal(Object.keys(registry).length, 2); assert.equal(registry[resolve("/tmp/project-a")]?.pid, 1001); + assert.equal(registry[resolve("/tmp/project-a")]?.authToken, "token-a"); assert.equal(registry[resolve("/tmp/project-b")]?.port, 3001); assert.ok(registry[resolve("/tmp/project-a")]?.startedAt); }); diff --git a/src/web-mode.ts b/src/web-mode.ts index d5c1bc014..7e6bb2a70 100644 --- a/src/web-mode.ts +++ b/src/web-mode.ts @@ -152,6 +152,7 @@ export interface WebInstanceEntry { pid: number; port: number; url: string; + authToken?: string; cwd: string; startedAt: string; } @@ -506,13 +507,23 @@ export function resolveWebHostBootstrap( options: { packageRoot?: string; existsSync?: (path: string) => boolean; + preferSource?: boolean; } = {}, ): WebHostBootstrap { const packageRoot = options.packageRoot ?? DEFAULT_PACKAGE_ROOT; const checkExists = options.existsSync ?? existsSync; const sourceWebRoot = join(packageRoot, "web"); const sourceManifest = join(sourceWebRoot, "package.json"); - if (checkExists(sourceManifest)) { + + const packagedStandaloneServer = join( + packageRoot, + "dist", + "web", + "standalone", + "server.js", + ); + + if (options.preferSource && checkExists(sourceManifest)) { return { ok: true, kind: "source-dev", @@ -522,13 +533,6 @@ export function resolveWebHostBootstrap( }; } - const packagedStandaloneServer = join( - packageRoot, - "dist", - "web", - "standalone", - "server.js", - ); if (checkExists(packagedStandaloneServer)) { return { ok: true, @@ -539,6 +543,16 @@ export function resolveWebHostBootstrap( }; } + if (checkExists(sourceManifest)) { + return { + ok: true, + kind: "source-dev", + packageRoot, + hostRoot: sourceWebRoot, + entryPath: sourceManifest, + }; + } + return { ok: false, packageRoot, @@ -912,6 +926,8 @@ export async function launchWebMode( const resolution = resolveWebHostBootstrap({ packageRoot: options.packageRoot, existsSync: deps.existsSync, + preferSource: + ((deps.env ?? process.env).SF_WEB_PREFER_SOURCE ?? "") === "1", }); if (!resolution.ok) { @@ -969,6 +985,7 @@ export async function launchWebMode( SF_WEB_PROJECT_SESSIONS_DIR: options.projectSessionsDir, SF_WEB_PACKAGE_ROOT: resolution.packageRoot, SF_WEB_HOST_KIND: resolution.kind, + SF_WEB_AUTO_START_AUTONOMOUS: "1", ...(resolution.kind === "source-dev" ? { NEXT_PUBLIC_SF_DEV: "1" } : {}), ...(options.allowedOrigins?.length ? { SF_WEB_ALLOWED_ORIGINS: options.allowedOrigins.join(",") } @@ -1075,7 +1092,11 @@ export async function launchWebMode( const pidFilePath = deps.pidFilePath ?? defaultWebPidFilePath; (deps.writePidFile ?? writePidFile)(pidFilePath, pid); // Register in multi-instance registry - registerInstance(options.cwd, { pid, port, url }, deps.registryPath); + registerInstance( + options.cwd, + { pid, port, url, authToken }, + deps.registryPath, + ); } const authenticatedUrl = `${url}/#token=${authToken}`; try { diff --git a/src/web/bridge-service.ts b/src/web/bridge-service.ts index 926363928..d58f883be 100644 --- a/src/web/bridge-service.ts +++ b/src/web/bridge-service.ts @@ -490,6 +490,7 @@ export interface BridgeRuntimeConfig { projectCwd: string; projectSessionsDir: string; packageRoot: string; + autoStartAutonomous: boolean; } export interface BootResumableSession { @@ -806,6 +807,8 @@ const defaultBridgeServiceDeps: BridgeServiceDeps = { let bridgeServiceOverrides: Partial | null = null; const projectBridgeRegistry = new Map(); +const BOOT_BRIDGE_START_TIMEOUT_MS = 2_000; +const BOOT_WORKSPACE_INDEX_TIMEOUT_MS = 2_000; const workspaceIndexCache = new Map(); async function loadSessionBrowserSessionsViaChildProcess( @@ -1098,6 +1101,76 @@ async function loadCachedWorkspaceIndex( return cloneWorkspaceIndex(await promise); } +function createPendingWorkspaceIndex( + projectCwd: string, + reason: string, +): SFWorkspaceIndex { + return { + milestones: [], + active: { + phase: "loading", + }, + scopes: [ + { + scope: "project", + label: projectCwd, + kind: "project", + }, + ], + validationIssues: [ + { + kind: "workspace-index-pending", + message: reason, + }, + ], + }; +} + +async function resolveBootWorkspaceIndex( + projectCwd: string, + workspacePromise: Promise, +): Promise { + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + workspacePromise, + new Promise((resolveResult) => { + timeout = setTimeout( + () => + resolveResult( + createPendingWorkspaceIndex( + projectCwd, + `workspace index exceeded ${BOOT_WORKSPACE_INDEX_TIMEOUT_MS}ms boot budget`, + ), + ), + BOOT_WORKSPACE_INDEX_TIMEOUT_MS, + ); + }), + ]); + } catch (error) { + return createPendingWorkspaceIndex( + projectCwd, + `workspace index failed: ${sanitizeErrorMessage(error)}`, + ); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +async function waitForBootBridgeStart(bridge: BridgeService): Promise { + let timeout: ReturnType | undefined; + try { + await Promise.race([ + bridge.ensureStarted(), + new Promise((resolveResult) => { + timeout = setTimeout(resolveResult, BOOT_BRIDGE_START_TIMEOUT_MS); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + async function loadWorkspaceIndexViaChildProcess( basePath: string, packageRoot: string, @@ -1291,7 +1364,12 @@ export function resolveBridgeRuntimeConfig( const projectSessionsDir = env.SF_WEB_PROJECT_SESSIONS_DIR || getProjectSessionsDir(projectCwd); const packageRoot = env.SF_WEB_PACKAGE_ROOT || getDefaultPackageRoot(); - return { projectCwd, projectSessionsDir, packageRoot }; + return { + projectCwd, + projectSessionsDir, + packageRoot, + autoStartAutonomous: env.SF_WEB_AUTO_START_AUTONOMOUS === "1", + }; } function resolveBridgeCliEntry( @@ -1572,6 +1650,7 @@ export class BridgeService { private startPromise: Promise | null = null; private refreshPromise: Promise | null = null; private authRefreshPromise: Promise | null = null; + private autonomousAutoStarted = false; private requestCounter = 0; private stderrBuffer = ""; private snapshot: BridgeRuntimeSnapshot; @@ -1700,6 +1779,7 @@ export class BridgeService { this.detachStdoutReader?.(); this.detachStdoutReader = null; this.stderrBuffer = ""; + this.autonomousAutoStarted = false; for (const pending of this.pendingRequests.values()) { clearTimeout(pending.timeout); @@ -1773,6 +1853,7 @@ export class BridgeService { this.detachStdoutReader?.(); this.detachStdoutReader = null; this.terminalSubscribers.clear(); + this.autonomousAutoStarted = false; for (const pending of this.pendingRequests.values()) { clearTimeout(pending.timeout); pending.reject(new Error("RPC bridge disposed")); @@ -1848,6 +1929,7 @@ export class BridgeService { this.snapshot.updatedAt = nowIso(); this.snapshot.lastError = null; this.broadcastStatus(); + this.startAutonomousWorkflowInBackground(); } catch (error) { this.snapshot.phase = "failed"; this.recordError(error, "starting"); @@ -1860,6 +1942,47 @@ export class BridgeService { } } + private startAutonomousWorkflowInBackground(): void { + void this.startAutonomousWorkflowIfEnabled(); + } + + private async startAutonomousWorkflowIfEnabled(): Promise { + if (!this.config.autoStartAutonomous || this.autonomousAutoStarted) return; + this.autonomousAutoStarted = true; + + try { + const response = sanitizeRpcResponse( + await this.requestResponse({ + type: "start_autonomous", + }), + ); + this.snapshot.lastCommandType = "start_autonomous"; + this.snapshot.updatedAt = nowIso(); + + if (!response.success) { + this.recordError(response.error, this.snapshot.phase, { + commandType: "start_autonomous", + }); + this.broadcastStatus(); + return; + } + + const liveStateInvalidation = createLiveStateInvalidationFromCommand( + { type: "start_autonomous" }, + response, + ); + if (liveStateInvalidation) { + this.publishLiveStateInvalidation(liveStateInvalidation); + } + this.broadcastStatus(); + } catch (error) { + this.recordError(error, this.snapshot.phase, { + commandType: "start_autonomous", + }); + this.broadcastStatus(); + } + } + private async queueStateRefresh(): Promise { if (this.refreshPromise) return await this.refreshPromise; this.refreshPromise = this.refreshState(false) @@ -2007,6 +2130,7 @@ export class BridgeService { this.detachStdoutReader?.(); this.detachStdoutReader = null; this.process = null; + this.autonomousAutoStarted = false; const exitError = new Error( buildExitMessage(code, signal, this.stderrBuffer), @@ -2631,14 +2755,14 @@ export async function collectBootPayload( const sessionsPromise = listSessions(config.projectSessionsDir); try { - await bridge.ensureStarted(); + await waitForBootBridgeStart(bridge); } catch { // Boot still returns the bridge failure snapshot for inspection. } const bridgeSnapshot = bridge.getSnapshot(); const [workspace, auto, sessions] = await Promise.all([ - workspacePromise, + resolveBootWorkspaceIndex(config.projectCwd, workspacePromise), autoPromise, sessionsPromise, ]); diff --git a/web/components/sf/Login.tsx b/web/components/sf/Login.tsx index aed3fb6ce..bdecea535 100644 --- a/web/components/sf/Login.tsx +++ b/web/components/sf/Login.tsx @@ -1,24 +1,24 @@ import { useState } from "react"; export default function Login() { + const [username, setUsername] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setError(""); - // POST to /api/login with password const res = await fetch("/api/login", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ password }), + body: JSON.stringify({ username, password }), }); if (res.ok) { const { token } = await res.json(); localStorage.setItem("sf-auth-token", token); window.location.href = "/"; } else { - setError("Invalid password"); + setError("Invalid credentials"); } }; @@ -31,13 +31,22 @@ export default function Login() {

Sign in to SF

- Enter the local web password for this server. + Enter the local web credentials for this server.

+ setUsername(e.target.value)} + className="h-10 w-full rounded border border-input bg-background px-3 text-sm outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" + /> setPassword(e.target.value)} className="h-10 w-full rounded border border-input bg-background px-3 text-sm outline-none ring-offset-background placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring" /> diff --git a/web/next-env.d.ts b/web/next-env.d.ts index 0c7fad710..2d5420eba 100644 --- a/web/next-env.d.ts +++ b/web/next-env.d.ts @@ -1,7 +1,7 @@ /// /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/web/pages/api/login.ts b/web/pages/api/login.ts index df39416db..977a78d8a 100644 --- a/web/pages/api/login.ts +++ b/web/pages/api/login.ts @@ -1,15 +1,20 @@ -// Simple /api/login route for password auth import type { NextApiRequest, NextApiResponse } from "next"; +const USERNAME = process.env.SF_WEB_USERNAME; const PASSWORD = process.env.SF_WEB_PASSWORD || "devpass"; const TOKEN = process.env.SF_WEB_AUTH_TOKEN || "dev-token"; +function isValidUsername(username: unknown): boolean { + if (!USERNAME) return true; + return typeof username === "string" && username === USERNAME; +} + export default function handler(req: NextApiRequest, res: NextApiResponse) { if (req.method !== "POST") return res.status(405).end(); - const { password } = req.body; - if (password === PASSWORD) { + const { password, username } = req.body ?? {}; + if (isValidUsername(username) && password === PASSWORD) { res.status(200).json({ token: TOKEN }); } else { - res.status(401).json({ error: "Invalid password" }); + res.status(401).json({ error: "Invalid credentials" }); } } diff --git a/web/proxy.ts b/web/proxy.ts index 4c66a8823..ba4fcd60d 100644 --- a/web/proxy.ts +++ b/web/proxy.ts @@ -51,6 +51,10 @@ export function proxy(request: NextRequest): NextResponse | undefined { } } + // Login must stay reachable before the browser has a bearer token. Origin + // validation above still protects it from cross-site credential posts. + if (pathname === "/api/login") return NextResponse.next(); + // ── Bearer token check ───────────────────────────────────────────── let token: string | null = null;