feat(sf): route server control through rpc
This commit is contained in:
parent
1f7fa1222c
commit
cf2d1a768e
20 changed files with 807 additions and 28 deletions
|
|
@ -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
|
||||
;;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
131
src/headless-server-forward.ts
Normal file
131
src/headless-server-forward.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -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
2
web/next-env.d.ts
vendored
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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" });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue