feat(sf): route server control through rpc

This commit is contained in:
Mikael Hugo 2026-05-17 20:05:53 +02:00
parent 1f7fa1222c
commit cf2d1a768e
20 changed files with 807 additions and 28 deletions

View file

@ -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
;;

View file

@ -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<void> {
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.
*/

View file

@ -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<T>(
run: () => Promise<T>,
): 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<never> {
} 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;

View file

@ -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", () => {

View file

@ -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";

View file

@ -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<void> {
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.
*/

View file

@ -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";

View file

@ -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<ForwardedHeadlessResult | null> {
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;
}

View file

@ -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,

View file

@ -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 });

View file

@ -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);
});

View file

@ -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(

View file

@ -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", () => {

View file

@ -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<string, any> }
| 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<string, any>,
};
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);
});

View file

@ -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 {

View file

@ -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<BridgeServiceDeps> | null = null;
const projectBridgeRegistry = new Map<string, BridgeService>();
const BOOT_BRIDGE_START_TIMEOUT_MS = 2_000;
const BOOT_WORKSPACE_INDEX_TIMEOUT_MS = 2_000;
const workspaceIndexCache = new Map<string, WorkspaceIndexCacheEntry>();
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<SFWorkspaceIndex>,
): Promise<SFWorkspaceIndex> {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
workspacePromise,
new Promise<SFWorkspaceIndex>((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<void> {
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
bridge.ensureStarted(),
new Promise<void>((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<void> | null = null;
private refreshPromise: Promise<void> | null = null;
private authRefreshPromise: Promise<void> | 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<void> {
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<void> {
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,
]);

View file

@ -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() {
<div>
<h1 className="text-lg font-semibold">Sign in to SF</h1>
<p className="mt-1 text-sm text-muted-foreground">
Enter the local web password for this server.
Enter the local web credentials for this server.
</p>
</div>
<input
type="text"
placeholder="Username"
value={username}
autoComplete="username"
onChange={(e) => 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"
/>
<input
type="password"
placeholder="Password"
value={password}
autoComplete="current-password"
onChange={(e) => 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"
/>

2
web/next-env.d.ts vendored
View file

@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference types="next/navigation-types/compat/navigation" />
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.

View file

@ -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" });
}
}

View file

@ -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;