- commit
- 69fc561
- parent
- ebbf8ac
- author
- im_wower
- date
- 2026-04-02 08:13:04 +0800 CST
feat: add conductor ui session auth
20 files changed,
+1216,
-26
Raw patch view.
1diff --git a/apps/conductor-daemon/package.json b/apps/conductor-daemon/package.json
2index 8bd7d3c69f8d32d056bdd76e5490cb7f59495c48..36333c883dd97858fcc3127bd70bb3c3ced7ff48 100644
3--- a/apps/conductor-daemon/package.json
4+++ b/apps/conductor-daemon/package.json
5@@ -4,16 +4,17 @@
6 "type": "module",
7 "main": "dist/index.js",
8 "dependencies": {
9+ "@baa-conductor/auth": "workspace:*",
10 "@baa-conductor/artifact-db": "workspace:*",
11 "@baa-conductor/d1-client": "workspace:*",
12 "@baa-conductor/db": "workspace:*",
13 "@baa-conductor/host-ops": "workspace:*"
14 },
15 "scripts": {
16- "build": "pnpm -C ../.. -F @baa-conductor/db build && pnpm -C ../.. -F @baa-conductor/artifact-db build && pnpm -C ../.. -F @baa-conductor/d1-client build && pnpm -C ../.. -F @baa-conductor/host-ops build && pnpm -C ../.. -F @baa-conductor/status-api build && pnpm -C ../.. -F @baa-conductor/conductor-ui build && pnpm exec tsc -p tsconfig.json",
17+ "build": "pnpm -C ../.. -F @baa-conductor/auth build && pnpm -C ../.. -F @baa-conductor/db build && pnpm -C ../.. -F @baa-conductor/artifact-db build && pnpm -C ../.. -F @baa-conductor/d1-client build && pnpm -C ../.. -F @baa-conductor/host-ops build && pnpm -C ../.. -F @baa-conductor/status-api build && pnpm -C ../.. -F @baa-conductor/conductor-ui build && pnpm exec tsc -p tsconfig.json",
18 "dev": "pnpm run build && node dist/index.js",
19 "start": "node dist/index.js",
20 "test": "pnpm run build && node --test src/index.test.js",
21- "typecheck": "pnpm -C ../.. -F @baa-conductor/db build && pnpm -C ../.. -F @baa-conductor/artifact-db build && pnpm -C ../.. -F @baa-conductor/d1-client build && pnpm -C ../.. -F @baa-conductor/host-ops build && pnpm -C ../.. -F @baa-conductor/status-api build && pnpm -C ../.. -F @baa-conductor/conductor-ui typecheck && pnpm exec tsc --noEmit -p tsconfig.json"
22+ "typecheck": "pnpm -C ../.. -F @baa-conductor/auth build && pnpm -C ../.. -F @baa-conductor/db build && pnpm -C ../.. -F @baa-conductor/artifact-db build && pnpm -C ../.. -F @baa-conductor/d1-client build && pnpm -C ../.. -F @baa-conductor/host-ops build && pnpm -C ../.. -F @baa-conductor/status-api build && pnpm -C ../.. -F @baa-conductor/conductor-ui typecheck && pnpm exec tsc --noEmit -p tsconfig.json"
23 }
24 }
25diff --git a/apps/conductor-daemon/src/index.test.js b/apps/conductor-daemon/src/index.test.js
26index 287b190aa40c2fac71f6436f2aa7cf8c03433ee8..69b3966b4064d25ce8ba73cccf671d4a05be562d 100644
27--- a/apps/conductor-daemon/src/index.test.js
28+++ b/apps/conductor-daemon/src/index.test.js
29@@ -7602,6 +7602,8 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
30 codexdLocalApiBase: codexd.baseUrl,
31 localApiBase: "http://127.0.0.1:0",
32 sharedToken: "replace-me",
33+ uiBrowserAdminPassword: "admin-secret",
34+ uiReadonlyPassword: "readonly-secret",
35 paths: {
36 runsDir: "/tmp/runs",
37 stateDir
38@@ -7689,6 +7691,10 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
39 const conductorUiControlHtml = await conductorUiControlResponse.text();
40 assert.match(conductorUiControlHtml, /<title>BAA Conductor UI<\/title>/u);
41
42+ const conductorUiLoginResponse = await fetch(`${baseUrl}/app/login`);
43+ assert.equal(conductorUiLoginResponse.status, 200);
44+ assert.equal(conductorUiLoginResponse.headers.get("content-type"), "text/html; charset=utf-8");
45+
46 const conductorUiAssetMatch = conductorUiHtml.match(/\/app\/assets\/[^"' )]+\.(?:css|js)/u);
47 assert.ok(conductorUiAssetMatch);
48
49@@ -7696,6 +7702,96 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
50 assert.equal(conductorUiAssetResponse.status, 200);
51 assert.equal(conductorUiAssetResponse.headers.get("cache-control"), "public, max-age=31536000, immutable");
52
53+ const uiSessionMeResponse = await fetch(`${baseUrl}/v1/ui/session/me`);
54+ assert.equal(uiSessionMeResponse.status, 200);
55+ const uiSessionMePayload = await uiSessionMeResponse.json();
56+ assert.equal(uiSessionMePayload.ok, true);
57+ assert.equal(uiSessionMePayload.data.authenticated, false);
58+ assert.deepEqual(uiSessionMePayload.data.available_roles, ["browser_admin", "readonly"]);
59+ assert.equal(uiSessionMePayload.data.session, null);
60+
61+ const invalidUiLoginResponse = await fetch(`${baseUrl}/v1/ui/session/login`, {
62+ method: "POST",
63+ headers: {
64+ "content-type": "application/json"
65+ },
66+ body: JSON.stringify({
67+ password: "wrong-secret",
68+ role: "readonly"
69+ })
70+ });
71+ assert.equal(invalidUiLoginResponse.status, 401);
72+ const invalidUiLoginPayload = await invalidUiLoginResponse.json();
73+ assert.equal(invalidUiLoginPayload.ok, false);
74+ assert.equal(invalidUiLoginPayload.error, "unauthorized");
75+
76+ const readonlyUiLoginResponse = await fetch(`${baseUrl}/v1/ui/session/login`, {
77+ method: "POST",
78+ headers: {
79+ "content-type": "application/json"
80+ },
81+ body: JSON.stringify({
82+ password: "readonly-secret",
83+ role: "readonly"
84+ })
85+ });
86+ assert.equal(readonlyUiLoginResponse.status, 200);
87+ const readonlyUiLoginPayload = await readonlyUiLoginResponse.json();
88+ assert.equal(readonlyUiLoginPayload.data.authenticated, true);
89+ assert.equal(readonlyUiLoginPayload.data.session.role, "readonly");
90+ const readonlySetCookie = readonlyUiLoginResponse.headers.get("set-cookie");
91+ assert.ok(readonlySetCookie);
92+ assert.match(readonlySetCookie, /baa_ui_session=/u);
93+ assert.match(readonlySetCookie, /HttpOnly/u);
94+ assert.match(readonlySetCookie, /SameSite=Lax/u);
95+ assert.match(readonlySetCookie, /Path=\//u);
96+ const readonlyCookie = readonlySetCookie.split(";", 1)[0];
97+
98+ const readonlyUiMeResponse = await fetch(`${baseUrl}/v1/ui/session/me`, {
99+ headers: {
100+ cookie: readonlyCookie
101+ }
102+ });
103+ assert.equal(readonlyUiMeResponse.status, 200);
104+ const readonlyUiMePayload = await readonlyUiMeResponse.json();
105+ assert.equal(readonlyUiMePayload.data.authenticated, true);
106+ assert.equal(readonlyUiMePayload.data.session.role, "readonly");
107+ assert.match(readonlyUiMeResponse.headers.get("set-cookie") ?? "", /baa_ui_session=/u);
108+
109+ const readonlyUiLogoutResponse = await fetch(`${baseUrl}/v1/ui/session/logout`, {
110+ method: "POST",
111+ headers: {
112+ cookie: readonlyCookie
113+ }
114+ });
115+ assert.equal(readonlyUiLogoutResponse.status, 200);
116+ assert.equal((await readonlyUiLogoutResponse.json()).data.authenticated, false);
117+ assert.match(readonlyUiLogoutResponse.headers.get("set-cookie") ?? "", /Max-Age=0/u);
118+
119+ const staleReadonlyMeResponse = await fetch(`${baseUrl}/v1/ui/session/me`, {
120+ headers: {
121+ cookie: readonlyCookie
122+ }
123+ });
124+ assert.equal(staleReadonlyMeResponse.status, 200);
125+ const staleReadonlyMePayload = await staleReadonlyMeResponse.json();
126+ assert.equal(staleReadonlyMePayload.data.authenticated, false);
127+ assert.match(staleReadonlyMeResponse.headers.get("set-cookie") ?? "", /Max-Age=0/u);
128+
129+ const browserAdminLoginResponse = await fetch(`${baseUrl}/v1/ui/session/login`, {
130+ method: "POST",
131+ headers: {
132+ "content-type": "application/json"
133+ },
134+ body: JSON.stringify({
135+ password: "admin-secret",
136+ role: "browser_admin"
137+ })
138+ });
139+ assert.equal(browserAdminLoginResponse.status, 200);
140+ const browserAdminLoginPayload = await browserAdminLoginResponse.json();
141+ assert.equal(browserAdminLoginPayload.data.session.role, "browser_admin");
142+
143 const codexStatusResponse = await fetch(`${baseUrl}/v1/codex`);
144 assert.equal(codexStatusResponse.status, 200);
145 const codexStatusPayload = await codexStatusResponse.json();
146diff --git a/apps/conductor-daemon/src/index.ts b/apps/conductor-daemon/src/index.ts
147index 8bbf934e0f7899725ae43847d55546a8734c8bbf..4ce41e879209bda3265d989b57daae6df6efeedb 100644
148--- a/apps/conductor-daemon/src/index.ts
149+++ b/apps/conductor-daemon/src/index.ts
150@@ -51,6 +51,10 @@ import {
151 ConductorLocalControlPlane,
152 resolveDefaultConductorStateDir
153 } from "./local-control-plane.js";
154+import {
155+ DEFAULT_UI_SESSION_TTL_SEC,
156+ UiSessionManager
157+} from "./ui-session.js";
158 import { createRenewalDispatcherRunner } from "./renewal/dispatcher.js";
159 import { createRenewalProjectorRunner } from "./renewal/projector.js";
160 import { ConductorTimedJobs } from "./timed-jobs/index.js";
161@@ -190,6 +194,9 @@ export interface ConductorRuntimeConfig extends ConductorConfig {
162 timedJobsMaxMessagesPerTick?: number;
163 timedJobsMaxTasksPerTick?: number;
164 timedJobsSettleDelayMs?: number;
165+ uiBrowserAdminPassword?: string | null;
166+ uiReadonlyPassword?: string | null;
167+ uiSessionTtlSec?: number | null;
168 }
169
170 export interface ResolvedConductorRuntimeConfig
171@@ -215,6 +222,9 @@ export interface ResolvedConductorRuntimeConfig
172 timedJobsMaxMessagesPerTick: number;
173 timedJobsMaxTasksPerTick: number;
174 timedJobsSettleDelayMs: number;
175+ uiBrowserAdminPassword: string | null;
176+ uiReadonlyPassword: string | null;
177+ uiSessionTtlSec: number;
178 version: string | null;
179 }
180
181@@ -762,6 +772,7 @@ class ConductorLocalHttpServer {
182 private readonly repository: ControlPlaneRepository;
183 private readonly sharedToken: string | null;
184 private readonly snapshotLoader: () => ConductorRuntimeSnapshot;
185+ private readonly uiSessionManager: UiSessionManager;
186 private readonly version: string | null;
187 private resolvedBaseUrl: string;
188 private server: Server | null = null;
189@@ -778,6 +789,9 @@ class ConductorLocalHttpServer {
190 sharedToken: string | null,
191 version: string | null,
192 now: () => number,
193+ uiBrowserAdminPassword: string | null,
194+ uiReadonlyPassword: string | null,
195+ uiSessionTtlSec: number,
196 artifactInlineThreshold: number,
197 artifactSummaryLength: number,
198 browserRequestPolicyOptions: BrowserRequestPolicyControllerOptions = {},
199@@ -799,6 +813,12 @@ class ConductorLocalHttpServer {
200 this.repository = repository;
201 this.sharedToken = sharedToken;
202 this.snapshotLoader = snapshotLoader;
203+ this.uiSessionManager = new UiSessionManager({
204+ browserAdminPassword: uiBrowserAdminPassword,
205+ now: () => this.now() * 1000,
206+ readonlyPassword: uiReadonlyPassword,
207+ ttlSec: uiSessionTtlSec
208+ });
209 this.version = version;
210 this.resolvedBaseUrl = localApiBase;
211 const nowMs = () => this.now() * 1000;
212@@ -810,6 +830,7 @@ class ConductorLocalHttpServer {
213 repository: this.repository,
214 sharedToken: this.sharedToken,
215 snapshotLoader: this.snapshotLoader,
216+ uiSessionManager: this.uiSessionManager,
217 version: this.version
218 };
219 const instructionIngest = new BaaLiveInstructionIngest({
220@@ -902,6 +923,7 @@ class ConductorLocalHttpServer {
221 repository: this.repository,
222 sharedToken: this.sharedToken,
223 snapshotLoader: this.snapshotLoader,
224+ uiSessionManager: this.uiSessionManager,
225 version: this.version
226 }
227 );
228@@ -1738,6 +1760,7 @@ export function resolveConductorRuntimeConfig(
229 config.timedJobsMaxTasksPerTick ?? DEFAULT_TIMED_JOBS_MAX_TASKS_PER_TICK;
230 const timedJobsSettleDelayMs =
231 config.timedJobsSettleDelayMs ?? DEFAULT_TIMED_JOBS_SETTLE_DELAY_MS;
232+ const uiSessionTtlSec = config.uiSessionTtlSec ?? DEFAULT_UI_SESSION_TTL_SEC;
233 const priority = config.priority ?? (config.role === "primary" ? 100 : 50);
234
235 if (heartbeatIntervalMs <= 0) {
236@@ -1780,6 +1803,10 @@ export function resolveConductorRuntimeConfig(
237 throw new Error("Conductor timedJobsSettleDelayMs must be a non-negative integer.");
238 }
239
240+ if (!Number.isInteger(uiSessionTtlSec) || uiSessionTtlSec <= 0) {
241+ throw new Error("Conductor uiSessionTtlSec must be a positive integer.");
242+ }
243+
244 return {
245 ...config,
246 artifactInlineThreshold,
247@@ -1806,6 +1833,9 @@ export function resolveConductorRuntimeConfig(
248 timedJobsMaxMessagesPerTick,
249 timedJobsMaxTasksPerTick,
250 timedJobsSettleDelayMs,
251+ uiBrowserAdminPassword: normalizeOptionalString(config.uiBrowserAdminPassword),
252+ uiReadonlyPassword: normalizeOptionalString(config.uiReadonlyPassword),
253+ uiSessionTtlSec,
254 version: normalizeOptionalString(config.version)
255 };
256 }
257@@ -1918,6 +1948,11 @@ function resolveRuntimeConfigFromSources(
258 localApiAllowedHosts: overrides.localApiAllowedHosts ?? env.BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS,
259 localApiBase: normalizeOptionalString(overrides.localApiBase ?? env.BAA_CONDUCTOR_LOCAL_API),
260 sharedToken: normalizeOptionalString(overrides.sharedToken ?? env.BAA_SHARED_TOKEN),
261+ uiBrowserAdminPassword: normalizeOptionalString(env.BAA_UI_BROWSER_ADMIN_PASSWORD),
262+ uiReadonlyPassword: normalizeOptionalString(env.BAA_UI_READONLY_PASSWORD),
263+ uiSessionTtlSec: parseIntegerValue("Conductor UI session TTL", env.BAA_UI_SESSION_TTL_SEC, {
264+ minimum: 1
265+ }),
266 paths: {
267 logsDir: normalizeOptionalString(overrides.logsDir ?? env.BAA_LOGS_DIR),
268 runsDir: normalizeOptionalString(overrides.runsDir ?? env.BAA_RUNS_DIR),
269@@ -2121,6 +2156,10 @@ function buildRuntimeWarnings(config: ResolvedConductorRuntimeConfig): string[]
270 warnings.push("BAA_SHARED_TOKEN is still set to replace-me; replace it before production rollout.");
271 }
272
273+ if (config.uiBrowserAdminPassword == null && config.uiReadonlyPassword == null) {
274+ warnings.push("No UI session password is configured; /app/login cannot authenticate any browser session.");
275+ }
276+
277 if (config.localApiBase == null) {
278 warnings.push("BAA_CONDUCTOR_LOCAL_API is not configured; only the in-process snapshot interface is available.");
279 }
280@@ -2251,6 +2290,9 @@ function getUsageText(): string {
281 " BAA_CONDUCTOR_LOCAL_API",
282 " BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS",
283 " BAA_SHARED_TOKEN",
284+ " BAA_UI_BROWSER_ADMIN_PASSWORD",
285+ " BAA_UI_READONLY_PASSWORD",
286+ " BAA_UI_SESSION_TTL_SEC",
287 " BAA_ARTIFACT_INLINE_THRESHOLD",
288 " BAA_ARTIFACT_SUMMARY_LENGTH",
289 " BAA_CONDUCTOR_PRIORITY",
290@@ -2362,6 +2404,9 @@ export class ConductorRuntime {
291 this.config.sharedToken,
292 this.config.version,
293 this.now,
294+ this.config.uiBrowserAdminPassword,
295+ this.config.uiReadonlyPassword,
296+ this.config.uiSessionTtlSec,
297 this.config.artifactInlineThreshold,
298 this.config.artifactSummaryLength,
299 options.browserRequestPolicyOptions,
300diff --git a/apps/conductor-daemon/src/local-api.ts b/apps/conductor-daemon/src/local-api.ts
301index 3702ce105a840b5a925da4715508864af1edd709..b4e0147148ece3555995c4cbd3e6e4c4822c6d34 100644
302--- a/apps/conductor-daemon/src/local-api.ts
303+++ b/apps/conductor-daemon/src/local-api.ts
304@@ -2,6 +2,11 @@ import * as nodeFs from "node:fs";
305 import { randomUUID } from "node:crypto";
306 import * as nodePath from "node:path";
307 import { fileURLToPath } from "node:url";
308+import {
309+ isBrowserSessionRole,
310+ type AuthPrincipal,
311+ type BrowserSessionRole
312+} from "../../../packages/auth/dist/index.js";
313 import {
314 buildArtifactRelativePath,
315 buildArtifactPublicUrl,
316@@ -84,6 +89,11 @@ import {
317 setRenewalConversationAutomationStatus
318 } from "./renewal/conversations.js";
319 import type { BaaInstructionPolicyConfig } from "./instructions/policy.js";
320+import {
321+ UI_SESSION_COOKIE_NAME,
322+ UiSessionManager,
323+ type UiSessionRecord
324+} from "./ui-session.js";
325
326 interface FileStatsLike {
327 isDirectory(): boolean;
328@@ -330,6 +340,7 @@ interface LocalApiRequestContext {
329 requestId: string;
330 sharedToken: string | null;
331 snapshotLoader: () => ConductorRuntimeApiSnapshot;
332+ uiSessionManager: UiSessionManager;
333 url: URL;
334 }
335
336@@ -382,6 +393,7 @@ export interface ConductorLocalApiContext {
337 repository: ControlPlaneRepository | null;
338 sharedToken?: string | null;
339 snapshotLoader: () => ConductorRuntimeApiSnapshot;
340+ uiSessionManager?: UiSessionManager | null;
341 version?: string | null;
342 }
343
344@@ -483,6 +495,30 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
345 pathPattern: "/app/:app_path*",
346 summary: "返回 conductor Web 工作台 SPA history fallback"
347 },
348+ {
349+ id: "ui.session.me",
350+ exposeInDescribe: false,
351+ kind: "read",
352+ method: "GET",
353+ pathPattern: "/v1/ui/session/me",
354+ summary: "读取当前 Web UI 会话与可登录角色"
355+ },
356+ {
357+ id: "ui.session.login",
358+ exposeInDescribe: false,
359+ kind: "write",
360+ method: "POST",
361+ pathPattern: "/v1/ui/session/login",
362+ summary: "创建当前 Web UI session cookie"
363+ },
364+ {
365+ id: "ui.session.logout",
366+ exposeInDescribe: false,
367+ kind: "write",
368+ method: "POST",
369+ pathPattern: "/v1/ui/session/logout",
370+ summary: "销毁当前 Web UI session cookie"
371+ },
372 // Keep the repo route ahead of the generic artifact route so repo root URLs
373 // can fall back to log.html instead of being claimed by the generic matcher.
374 {
375@@ -4381,6 +4417,42 @@ function readHeaderValue(request: ConductorHttpRequest, headerName: string): str
376 return undefined;
377 }
378
379+function buildUiSessionResponseData(
380+ context: LocalApiRequestContext,
381+ session: UiSessionRecord | null
382+): JsonObject {
383+ return {
384+ authenticated: session != null,
385+ available_roles: context.uiSessionManager.getAvailableRoles(),
386+ session: UiSessionManager.toSnapshot(session) as unknown as JsonValue
387+ };
388+}
389+
390+function buildUiSessionSuccessResponse(
391+ context: LocalApiRequestContext,
392+ session: UiSessionRecord | null,
393+ headers: Record<string, string> = {}
394+): ConductorHttpResponse {
395+ return buildSuccessEnvelope(context.requestId, 200, buildUiSessionResponseData(context, session), headers);
396+}
397+
398+function buildUiSessionCookieHeader(context: LocalApiRequestContext, session: UiSessionRecord): string {
399+ return context.uiSessionManager.buildSessionCookieHeader(session.sessionId, context.url.protocol);
400+}
401+
402+function buildUiSessionClearCookieHeader(context: LocalApiRequestContext): string {
403+ return context.uiSessionManager.buildClearCookieHeader(context.url.protocol);
404+}
405+
406+function hasUiSessionCookie(request: ConductorHttpRequest): boolean {
407+ const cookieHeader = readHeaderValue(request, "cookie");
408+ return typeof cookieHeader === "string" && cookieHeader.includes(`${UI_SESSION_COOKIE_NAME}=`);
409+}
410+
411+function readUiSessionPrincipal(context: LocalApiRequestContext): AuthPrincipal | null {
412+ return context.uiSessionManager.resolvePrincipal(readHeaderValue(context.request, "cookie"));
413+}
414+
415 function extractBearerToken(
416 authorizationHeader: string | undefined
417 ): { ok: true; token: string } | { ok: false; reason: SharedTokenAuthFailureReason } {
418@@ -6849,6 +6921,76 @@ async function handleConductorUiSpaRead(): Promise<ConductorHttpResponse> {
419 return readConductorUiIndexFile(getConductorUiDistDir());
420 }
421
422+async function handleUiSessionMe(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
423+ const session = context.uiSessionManager.touch(readHeaderValue(context.request, "cookie"));
424+
425+ if (!session && hasUiSessionCookie(context.request)) {
426+ return buildUiSessionSuccessResponse(context, null, {
427+ "set-cookie": buildUiSessionClearCookieHeader(context)
428+ });
429+ }
430+
431+ if (!session) {
432+ return buildUiSessionSuccessResponse(context, null);
433+ }
434+
435+ return buildUiSessionSuccessResponse(context, session, {
436+ "set-cookie": buildUiSessionCookieHeader(context, session)
437+ });
438+}
439+
440+async function handleUiSessionLogin(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
441+ if (!context.uiSessionManager.hasConfiguredRoles()) {
442+ throw new LocalApiHttpError(
443+ 503,
444+ "ui_session_not_configured",
445+ "No UI session role is configured on this conductor instance."
446+ );
447+ }
448+
449+ const body = readBodyObject(context.request);
450+ const role = readOptionalStringBodyField(body, "role");
451+ const password = readOptionalStringBodyField(body, "password");
452+
453+ if (!role || !isBrowserSessionRole(role)) {
454+ throw new LocalApiHttpError(400, "invalid_request", 'Field "role" must be "browser_admin" or "readonly".', {
455+ field: "role"
456+ });
457+ }
458+
459+ if (!password) {
460+ throw new LocalApiHttpError(400, "invalid_request", 'Field "password" is required.', {
461+ field: "password"
462+ });
463+ }
464+
465+ const result = context.uiSessionManager.login(role, password);
466+
467+ if (!result.ok) {
468+ if (result.reason === "role_not_enabled") {
469+ throw new LocalApiHttpError(
470+ 403,
471+ "forbidden",
472+ `UI session role "${role}" is not enabled on this conductor instance.`
473+ );
474+ }
475+
476+ throw new LocalApiHttpError(401, "unauthorized", "Invalid UI session credentials.");
477+ }
478+
479+ return buildUiSessionSuccessResponse(context, result.session, {
480+ "set-cookie": buildUiSessionCookieHeader(context, result.session)
481+ });
482+}
483+
484+async function handleUiSessionLogout(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
485+ context.uiSessionManager.logout(readHeaderValue(context.request, "cookie"));
486+
487+ return buildUiSessionSuccessResponse(context, null, {
488+ "set-cookie": buildUiSessionClearCookieHeader(context)
489+ });
490+}
491+
492 function buildCodeAccessForbiddenError(): LocalApiHttpError {
493 return new LocalApiHttpError(403, "forbidden", "Requested code path is not allowed.");
494 }
495@@ -7929,6 +8071,12 @@ async function dispatchBusinessRoute(
496 return handleConductorUiAssetRead(context);
497 case "service.ui.spa":
498 return handleConductorUiSpaRead();
499+ case "ui.session.me":
500+ return handleUiSessionMe(context);
501+ case "ui.session.login":
502+ return handleUiSessionLogin(context);
503+ case "ui.session.logout":
504+ return handleUiSessionLogout(context);
505 case "service.artifact.read":
506 return handleArtifactRead(context);
507 case "service.artifact.repo":
508@@ -8178,6 +8326,14 @@ export async function handleConductorHttpRequest(
509 const matchedRoute = matchRoute(request.method.toUpperCase(), url.pathname);
510 const requestId = crypto.randomUUID();
511 const version = context.version?.trim() || "dev";
512+ const now = context.now ?? (() => Math.floor(Date.now() / 1000));
513+ const uiSessionManager =
514+ context.uiSessionManager ??
515+ new UiSessionManager({
516+ browserAdminPassword: null,
517+ now: () => now() * 1000,
518+ readonlyPassword: null
519+ });
520
521 if (!matchedRoute) {
522 const allowedMethods = findAllowedMethods(url.pathname);
523@@ -8225,13 +8381,14 @@ export async function handleConductorHttpRequest(
524 codexdLocalApiBase:
525 normalizeOptionalString(context.codexdLocalApiBase) ?? getSnapshotCodexdLocalApiBase(context.snapshotLoader()),
526 fetchImpl: context.fetchImpl ?? globalThis.fetch,
527- now: context.now ?? (() => Math.floor(Date.now() / 1000)),
528+ now,
529 params: matchedRoute.params,
530 repository: context.repository,
531 request,
532 requestId,
533 sharedToken: normalizeOptionalString(context.sharedToken),
534 snapshotLoader: context.snapshotLoader,
535+ uiSessionManager,
536 url
537 },
538 version
539diff --git a/apps/conductor-daemon/src/ui-session.ts b/apps/conductor-daemon/src/ui-session.ts
540new file mode 100644
541index 0000000000000000000000000000000000000000..0e36db681192be51616bef989f56f77f4718f83d
542--- /dev/null
543+++ b/apps/conductor-daemon/src/ui-session.ts
544@@ -0,0 +1,273 @@
545+import { randomUUID } from "node:crypto";
546+import {
547+ createBrowserSessionPrincipal,
548+ type AuthPrincipal,
549+ type BrowserSessionRole
550+} from "../../../packages/auth/dist/index.js";
551+
552+export const UI_SESSION_COOKIE_NAME = "baa_ui_session";
553+export const DEFAULT_UI_SESSION_TTL_SEC = 60 * 60 * 8;
554+
555+export interface UiSessionManagerOptions {
556+ browserAdminPassword: string | null;
557+ now?: () => number;
558+ readonlyPassword: string | null;
559+ ttlSec?: number;
560+}
561+
562+export interface UiSessionRecord {
563+ createdAtMs: number;
564+ expiresAtMs: number;
565+ lastSeenAtMs: number;
566+ role: BrowserSessionRole;
567+ sessionId: string;
568+ subject: string;
569+}
570+
571+export interface UiSessionSnapshot {
572+ expires_at: string;
573+ issued_at: string;
574+ role: BrowserSessionRole;
575+ session_id: string;
576+ subject: string;
577+}
578+
579+export type UiSessionLoginFailureReason = "invalid_credentials" | "role_not_enabled";
580+
581+export type UiSessionLoginResult =
582+ | {
583+ ok: true;
584+ session: UiSessionRecord;
585+ }
586+ | {
587+ ok: false;
588+ reason: UiSessionLoginFailureReason;
589+ };
590+
591+export class UiSessionManager {
592+ private readonly browserAdminPassword: string | null;
593+ private readonly now: () => number;
594+ private readonly readonlyPassword: string | null;
595+ private readonly sessions = new Map<string, UiSessionRecord>();
596+ private readonly ttlSec: number;
597+
598+ constructor(options: UiSessionManagerOptions) {
599+ this.browserAdminPassword = normalizeOptionalString(options.browserAdminPassword);
600+ this.now = options.now ?? Date.now;
601+ this.readonlyPassword = normalizeOptionalString(options.readonlyPassword);
602+ this.ttlSec = normalizeTtlSec(options.ttlSec);
603+ }
604+
605+ buildClearCookieHeader(requestProtocol: string): string {
606+ return buildSessionCookieHeader("", 0, requestProtocol);
607+ }
608+
609+ buildSessionCookieHeader(sessionId: string, requestProtocol: string): string {
610+ return buildSessionCookieHeader(sessionId, this.ttlSec, requestProtocol);
611+ }
612+
613+ getAvailableRoles(): BrowserSessionRole[] {
614+ const roles: BrowserSessionRole[] = [];
615+
616+ if (this.browserAdminPassword != null) {
617+ roles.push("browser_admin");
618+ }
619+
620+ if (this.readonlyPassword != null) {
621+ roles.push("readonly");
622+ }
623+
624+ return roles;
625+ }
626+
627+ hasConfiguredRoles(): boolean {
628+ return this.getAvailableRoles().length > 0;
629+ }
630+
631+ login(role: BrowserSessionRole, password: string): UiSessionLoginResult {
632+ const expectedPassword = this.getExpectedPassword(role);
633+
634+ if (expectedPassword == null) {
635+ return {
636+ ok: false,
637+ reason: "role_not_enabled"
638+ };
639+ }
640+
641+ if (password !== expectedPassword) {
642+ return {
643+ ok: false,
644+ reason: "invalid_credentials"
645+ };
646+ }
647+
648+ const session = this.createSession(role);
649+ return {
650+ ok: true,
651+ session
652+ };
653+ }
654+
655+ logout(cookieHeader: string | undefined): boolean {
656+ const sessionId = readCookieValue(cookieHeader, UI_SESSION_COOKIE_NAME);
657+
658+ if (!sessionId) {
659+ return false;
660+ }
661+
662+ return this.sessions.delete(sessionId);
663+ }
664+
665+ resolvePrincipal(cookieHeader: string | undefined): AuthPrincipal | null {
666+ const session = this.readSession(cookieHeader);
667+
668+ if (!session) {
669+ return null;
670+ }
671+
672+ return createBrowserSessionPrincipal({
673+ expiresAt: toIsoString(session.expiresAtMs),
674+ issuedAt: toIsoString(session.createdAtMs),
675+ role: session.role,
676+ sessionId: session.sessionId,
677+ subject: session.subject
678+ });
679+ }
680+
681+ touch(cookieHeader: string | undefined): UiSessionRecord | null {
682+ const sessionId = readCookieValue(cookieHeader, UI_SESSION_COOKIE_NAME);
683+
684+ if (!sessionId) {
685+ return null;
686+ }
687+
688+ const current = this.sessions.get(sessionId);
689+
690+ if (!current) {
691+ return null;
692+ }
693+
694+ if (current.expiresAtMs <= this.now()) {
695+ this.sessions.delete(sessionId);
696+ return null;
697+ }
698+
699+ const nowMs = this.now();
700+ const updated: UiSessionRecord = {
701+ ...current,
702+ expiresAtMs: nowMs + this.ttlSec * 1000,
703+ lastSeenAtMs: nowMs
704+ };
705+ this.sessions.set(updated.sessionId, updated);
706+ return updated;
707+ }
708+
709+ static toSnapshot(session: UiSessionRecord | null): UiSessionSnapshot | null {
710+ if (!session) {
711+ return null;
712+ }
713+
714+ return {
715+ expires_at: toIsoString(session.expiresAtMs),
716+ issued_at: toIsoString(session.createdAtMs),
717+ role: session.role,
718+ session_id: session.sessionId,
719+ subject: session.subject
720+ };
721+ }
722+
723+ private createSession(role: BrowserSessionRole): UiSessionRecord {
724+ const nowMs = this.now();
725+ const session: UiSessionRecord = {
726+ createdAtMs: nowMs,
727+ expiresAtMs: nowMs + this.ttlSec * 1000,
728+ lastSeenAtMs: nowMs,
729+ role,
730+ sessionId: randomUUID(),
731+ subject: `ui:${role}`
732+ };
733+ this.sessions.set(session.sessionId, session);
734+ return session;
735+ }
736+
737+ private getExpectedPassword(role: BrowserSessionRole): string | null {
738+ if (role === "browser_admin") {
739+ return this.browserAdminPassword;
740+ }
741+
742+ return this.readonlyPassword;
743+ }
744+
745+ private readSession(cookieHeader: string | undefined): UiSessionRecord | null {
746+ const sessionId = readCookieValue(cookieHeader, UI_SESSION_COOKIE_NAME);
747+
748+ if (!sessionId) {
749+ return null;
750+ }
751+
752+ const current = this.sessions.get(sessionId);
753+
754+ if (!current) {
755+ return null;
756+ }
757+
758+ if (current.expiresAtMs <= this.now()) {
759+ this.sessions.delete(sessionId);
760+ return null;
761+ }
762+
763+ return current;
764+ }
765+}
766+
767+function buildSessionCookieHeader(value: string, maxAgeSec: number, requestProtocol: string): string {
768+ const parts = [`${UI_SESSION_COOKIE_NAME}=${encodeURIComponent(value)}`, "Path=/", "HttpOnly", "SameSite=Lax"];
769+
770+ if (requestProtocol === "https:") {
771+ parts.push("Secure");
772+ }
773+
774+ parts.push(`Max-Age=${Math.max(0, Math.trunc(maxAgeSec))}`);
775+ return parts.join("; ");
776+}
777+
778+function normalizeOptionalString(value: string | null | undefined): string | null {
779+ if (value == null) {
780+ return null;
781+ }
782+
783+ const normalized = value.trim();
784+ return normalized === "" ? null : normalized;
785+}
786+
787+function normalizeTtlSec(value: number | undefined): number {
788+ if (!Number.isInteger(value) || value == null || value <= 0) {
789+ return DEFAULT_UI_SESSION_TTL_SEC;
790+ }
791+
792+ return value;
793+}
794+
795+function readCookieValue(cookieHeader: string | undefined, name: string): string | null {
796+ if (!cookieHeader) {
797+ return null;
798+ }
799+
800+ for (const part of cookieHeader.split(";")) {
801+ const [rawName, ...rawValueParts] = part.split("=");
802+ const cookieName = rawName?.trim();
803+
804+ if (!cookieName || cookieName !== name) {
805+ continue;
806+ }
807+
808+ const rawValue = rawValueParts.join("=").trim();
809+ return rawValue === "" ? null : decodeURIComponent(rawValue);
810+ }
811+
812+ return null;
813+}
814+
815+function toIsoString(value: number): string {
816+ return new Date(value).toISOString();
817+}
818diff --git a/apps/conductor-ui/src/App.vue b/apps/conductor-ui/src/App.vue
819index 543cfdbab78c08f07ccec245e955b5d84ad0f7ab..0481736ad9918419586d59207410003b3768adc3 100644
820--- a/apps/conductor-ui/src/App.vue
821+++ b/apps/conductor-ui/src/App.vue
822@@ -1,7 +1,17 @@
823 <template>
824- <RouterView />
825+ <div class="app-root">
826+ <RouterView />
827+ <div v-if="uiSessionState.pending && !uiSessionState.initialized" class="boot-overlay">
828+ <div class="panel boot-panel">
829+ <p class="eyebrow">BAA Conductor / Boot</p>
830+ <p class="boot-copy">Checking UI session…</p>
831+ </div>
832+ </div>
833+ </div>
834 </template>
835
836 <script setup lang="ts">
837 import { RouterView } from "vue-router";
838+
839+import { uiSessionState } from "./auth/session";
840 </script>
841diff --git a/apps/conductor-ui/src/auth/session.ts b/apps/conductor-ui/src/auth/session.ts
842new file mode 100644
843index 0000000000000000000000000000000000000000..2a32732a9b2bc8dbb7d4a3c0f9d3670d9beebbf6
844--- /dev/null
845+++ b/apps/conductor-ui/src/auth/session.ts
846@@ -0,0 +1,144 @@
847+import { reactive, readonly } from "vue";
848+
849+export type UiSessionRole = "browser_admin" | "readonly";
850+
851+export interface UiSessionSnapshot {
852+ expires_at: string;
853+ issued_at: string;
854+ role: UiSessionRole;
855+ session_id: string;
856+ subject: string;
857+}
858+
859+interface UiSessionEnvelope {
860+ authenticated: boolean;
861+ available_roles: UiSessionRole[];
862+ session: UiSessionSnapshot | null;
863+}
864+
865+interface UiSessionResponseEnvelope {
866+ data: UiSessionEnvelope;
867+ error?: string;
868+ message?: string;
869+ ok: boolean;
870+}
871+
872+interface UiSessionState {
873+ authenticated: boolean;
874+ availableRoles: UiSessionRole[];
875+ initialized: boolean;
876+ pending: boolean;
877+ session: UiSessionSnapshot | null;
878+}
879+
880+const state = reactive<UiSessionState>({
881+ authenticated: false,
882+ availableRoles: [],
883+ initialized: false,
884+ pending: false,
885+ session: null
886+});
887+
888+let pendingRequest: Promise<UiSessionState> | null = null;
889+
890+export const uiSessionState = readonly(state);
891+
892+export async function ensureUiSessionLoaded(force = false): Promise<UiSessionState> {
893+ if (!force && state.initialized) {
894+ return state;
895+ }
896+
897+ if (pendingRequest) {
898+ return pendingRequest;
899+ }
900+
901+ pendingRequest = refreshUiSession(force);
902+
903+ try {
904+ return await pendingRequest;
905+ } finally {
906+ pendingRequest = null;
907+ }
908+}
909+
910+export async function loginUiSession(input: {
911+ password: string;
912+ role: UiSessionRole;
913+}): Promise<UiSessionState> {
914+ return applySessionMutation("/v1/ui/session/login", {
915+ body: JSON.stringify(input),
916+ headers: {
917+ "content-type": "application/json"
918+ },
919+ method: "POST"
920+ });
921+}
922+
923+export async function logoutUiSession(): Promise<UiSessionState> {
924+ return applySessionMutation("/v1/ui/session/logout", {
925+ method: "POST"
926+ });
927+}
928+
929+export function normalizeRedirectTarget(value: unknown): string {
930+ if (typeof value !== "string") {
931+ return "/control";
932+ }
933+
934+ if (!value.startsWith("/") || value.startsWith("//")) {
935+ return "/control";
936+ }
937+
938+ return value;
939+}
940+
941+async function applySessionMutation(path: string, init: RequestInit): Promise<UiSessionState> {
942+ state.pending = true;
943+
944+ try {
945+ const response = await fetch(path, {
946+ ...init,
947+ credentials: "same-origin"
948+ });
949+ const payload = (await response.json()) as UiSessionResponseEnvelope;
950+
951+ if (!response.ok || payload.ok !== true) {
952+ throw new Error(payload.message ?? "UI session request failed.");
953+ }
954+
955+ applyEnvelope(payload.data);
956+ return state;
957+ } finally {
958+ state.pending = false;
959+ state.initialized = true;
960+ }
961+}
962+
963+async function refreshUiSession(force: boolean): Promise<UiSessionState> {
964+ if (force || !state.initialized) {
965+ state.pending = true;
966+ }
967+
968+ try {
969+ const response = await fetch("/v1/ui/session/me", {
970+ credentials: "same-origin"
971+ });
972+ const payload = (await response.json()) as UiSessionResponseEnvelope;
973+
974+ if (payload.ok !== true) {
975+ throw new Error(payload.message ?? "Unable to read UI session state.");
976+ }
977+
978+ applyEnvelope(payload.data);
979+ return state;
980+ } finally {
981+ state.initialized = true;
982+ state.pending = false;
983+ }
984+}
985+
986+function applyEnvelope(envelope: UiSessionEnvelope): void {
987+ state.authenticated = envelope.authenticated;
988+ state.availableRoles = envelope.available_roles;
989+ state.session = envelope.session;
990+}
991diff --git a/apps/conductor-ui/src/features/auth/views/LoginView.vue b/apps/conductor-ui/src/features/auth/views/LoginView.vue
992new file mode 100644
993index 0000000000000000000000000000000000000000..455f04df7a4717b76b439b7486b7b82fcf5de6c3
994--- /dev/null
995+++ b/apps/conductor-ui/src/features/auth/views/LoginView.vue
996@@ -0,0 +1,124 @@
997+<template>
998+ <main class="shell auth-shell">
999+ <section class="panel auth-panel">
1000+ <div class="auth-copy">
1001+ <p class="eyebrow">BAA Conductor / Session</p>
1002+ <h1>Login To Workspace</h1>
1003+ <p class="lede">
1004+ `/app` 现在通过 `HttpOnly` session cookie 进入工作台,不再把长期 token 暴露给浏览器脚本。
1005+ </p>
1006+ </div>
1007+
1008+ <form class="auth-form" @submit.prevent="handleSubmit">
1009+ <div class="auth-section">
1010+ <p class="section-label">Role</p>
1011+ <div class="role-grid">
1012+ <button
1013+ v-for="option in roleOptions"
1014+ :key="option.role"
1015+ :class="['role-card', selectedRole === option.role ? 'role-card-active' : '']"
1016+ type="button"
1017+ @click="selectedRole = option.role"
1018+ >
1019+ <strong>{{ option.title }}</strong>
1020+ <span>{{ option.copy }}</span>
1021+ </button>
1022+ </div>
1023+ </div>
1024+
1025+ <label class="auth-section auth-field">
1026+ <span class="section-label">Password</span>
1027+ <input
1028+ v-model="password"
1029+ class="auth-input"
1030+ type="password"
1031+ autocomplete="current-password"
1032+ placeholder="Enter session password"
1033+ />
1034+ </label>
1035+
1036+ <p v-if="!hasAvailableRoles" class="auth-note">
1037+ 当前 conductor 没有配置任何 UI session 角色。请先设置 `BAA_UI_BROWSER_ADMIN_PASSWORD` 或
1038+ `BAA_UI_READONLY_PASSWORD`。
1039+ </p>
1040+
1041+ <p v-else-if="redirectTarget !== '/control'" class="auth-note">
1042+ 登录后将返回 `{{ redirectTarget }}`。
1043+ </p>
1044+
1045+ <p v-if="errorMessage" class="auth-error">{{ errorMessage }}</p>
1046+
1047+ <div class="auth-actions">
1048+ <button class="action-button" type="submit" :disabled="submitDisabled">
1049+ {{ uiSessionState.pending ? "Signing In..." : "Sign In" }}
1050+ </button>
1051+ <span class="auth-hint">Cookie: `HttpOnly` / `SameSite=Lax` / `Path=/`</span>
1052+ </div>
1053+ </form>
1054+ </section>
1055+ </main>
1056+</template>
1057+
1058+<script setup lang="ts">
1059+import { computed, ref, watchEffect } from "vue";
1060+import { useRoute, useRouter } from "vue-router";
1061+
1062+import {
1063+ loginUiSession,
1064+ normalizeRedirectTarget,
1065+ uiSessionState,
1066+ type UiSessionRole
1067+} from "@/auth/session";
1068+
1069+const route = useRoute();
1070+const router = useRouter();
1071+
1072+const password = ref("");
1073+const errorMessage = ref("");
1074+const selectedRole = ref<UiSessionRole>("browser_admin");
1075+
1076+const redirectTarget = computed(() => normalizeRedirectTarget(route.query.redirect));
1077+const hasAvailableRoles = computed(() => uiSessionState.availableRoles.length > 0);
1078+const submitDisabled = computed(() => uiSessionState.pending || !hasAvailableRoles.value || password.value.trim() === "");
1079+const roleOptions = computed(() =>
1080+ uiSessionState.availableRoles.map((role) => ({
1081+ copy:
1082+ role === "browser_admin"
1083+ ? "可进入正式工作台,并执行后续 Control 写操作。"
1084+ : "只读观察角色,后续只显示安全的状态视图。",
1085+ role,
1086+ title: role === "browser_admin" ? "Browser Admin" : "Readonly"
1087+ }))
1088+);
1089+
1090+watchEffect(() => {
1091+ const [firstRole] = uiSessionState.availableRoles;
1092+
1093+ if (!firstRole) {
1094+ return;
1095+ }
1096+
1097+ if (!uiSessionState.availableRoles.includes(selectedRole.value)) {
1098+ selectedRole.value = firstRole;
1099+ }
1100+});
1101+
1102+async function handleSubmit(): Promise<void> {
1103+ if (submitDisabled.value) {
1104+ return;
1105+ }
1106+
1107+ errorMessage.value = "";
1108+
1109+ try {
1110+ await loginUiSession({
1111+ password: password.value,
1112+ role: selectedRole.value
1113+ });
1114+ password.value = "";
1115+ await router.replace(redirectTarget.value);
1116+ } catch (error) {
1117+ errorMessage.value = error instanceof Error ? error.message : "UI session login failed.";
1118+ }
1119+}
1120+</script>
1121diff --git a/apps/conductor-ui/src/features/auth/views/index.ts b/apps/conductor-ui/src/features/auth/views/index.ts
1122new file mode 100644
1123index 0000000000000000000000000000000000000000..dcdf0ee77496d91b6b3c43c7196dc1c3bf9bd064
1124--- /dev/null
1125+++ b/apps/conductor-ui/src/features/auth/views/index.ts
1126@@ -0,0 +1 @@
1127+export { default as LoginView } from "./LoginView.vue";
1128diff --git a/apps/conductor-ui/src/features/control/views/ControlWorkspaceView.vue b/apps/conductor-ui/src/features/control/views/ControlWorkspaceView.vue
1129index 9a9b6fab3082946f85dd8657187daa7a5c6d68f9..4b28b59656127f11c329916b8aeb2f5f067de611 100644
1130--- a/apps/conductor-ui/src/features/control/views/ControlWorkspaceView.vue
1131+++ b/apps/conductor-ui/src/features/control/views/ControlWorkspaceView.vue
1132@@ -12,7 +12,12 @@
1133 <div class="hero-meta">
1134 <div class="pill pill-live">Vue 3</div>
1135 <div class="pill">/app</div>
1136- <div class="pill pill-muted">Session Pending</div>
1137+ <div class="pill">
1138+ {{ uiSessionState.session?.role === "browser_admin" ? "Browser Admin" : "Readonly" }}
1139+ </div>
1140+ <button class="ghost-button" type="button" @click="handleLogout">
1141+ {{ uiSessionState.pending ? "Signing Out..." : "Sign Out" }}
1142+ </button>
1143 </div>
1144 </section>
1145
1146@@ -34,9 +39,9 @@
1147
1148 <div class="status-box">
1149 <p class="section-label">Runtime</p>
1150- <p class="status-title">Session 未接入</p>
1151+ <p class="status-title">{{ uiSessionState.session?.role === "browser_admin" ? "Admin Session" : "Readonly Session" }}</p>
1152 <p class="status-copy">
1153- T-S071 将在这里接入浏览器会话、登录页和角色守卫。当前壳页面明确展示为未认证状态。
1154+ 当前会话通过 `HttpOnly` cookie 保持。T-S072 会在此基础上接入真正的 `Control` 数据读写和角色受控动作。
1155 </p>
1156 </div>
1157 </aside>
1158@@ -55,7 +60,7 @@
1159 <p class="section-label">T-S071</p>
1160 <h2>Session Auth</h2>
1161 <p>
1162- 登录页、`browser_session`、`readonly` 角色和 `/v1/ui/session/*` 接口将在下一任务接入。
1163+ 登录页、`browser_session`、`readonly` 角色和 `/v1/ui/session/*` 接口已经接入当前工作台。
1164 </p>
1165 </article>
1166
1167@@ -80,11 +85,11 @@
1168 <div class="roadmap-list">
1169 <div class="roadmap-row">
1170 <strong>已完成本页目标</strong>
1171- <span>Vue 3 脚手架、`/app` 路由壳、同源静态托管</span>
1172+ <span>Vue 3 壳、`/app` 托管、UI session cookie、登录守卫</span>
1173 </div>
1174 <div class="roadmap-row">
1175 <strong>下一步</strong>
1176- <span>UI session、登录页、受保护的 `/app/control`</span>
1177+ <span>正式 `Control` 数据面、浏览器控制动作、只读与写权限差异化 UI</span>
1178 </div>
1179 <div class="roadmap-row">
1180 <strong>再下一步</strong>
1181@@ -96,3 +101,23 @@
1182 </section>
1183 </main>
1184 </template>
1185+
1186+<script setup lang="ts">
1187+import { useRouter } from "vue-router";
1188+
1189+import {
1190+ logoutUiSession,
1191+ uiSessionState
1192+} from "@/auth/session";
1193+
1194+const router = useRouter();
1195+
1196+async function handleLogout(): Promise<void> {
1197+ if (uiSessionState.pending) {
1198+ return;
1199+ }
1200+
1201+ await logoutUiSession();
1202+ await router.replace("/login");
1203+}
1204+</script>
1205diff --git a/apps/conductor-ui/src/routes/index.ts b/apps/conductor-ui/src/routes/index.ts
1206index adf5337245c56805cc501d895d4a9bb73d6f0864..c47aff6419919005f325a4fd148f7057844e81a8 100644
1207--- a/apps/conductor-ui/src/routes/index.ts
1208+++ b/apps/conductor-ui/src/routes/index.ts
1209@@ -1,5 +1,11 @@
1210 import { createRouter, createWebHistory } from "vue-router";
1211
1212+import {
1213+ ensureUiSessionLoaded,
1214+ normalizeRedirectTarget,
1215+ uiSessionState
1216+} from "@/auth/session";
1217+import { LoginView } from "@/features/auth/views";
1218 import { ControlWorkspaceView } from "@/features/control/views";
1219 import { NotFoundView } from "@/features/system/views";
1220
1221@@ -10,16 +16,48 @@ export const router = createRouter({
1222 path: "/",
1223 redirect: "/control"
1224 },
1225+ {
1226+ path: "/login",
1227+ component: LoginView,
1228+ meta: {
1229+ guestOnly: true
1230+ }
1231+ },
1232 {
1233 path: "/control",
1234- component: ControlWorkspaceView
1235+ component: ControlWorkspaceView,
1236+ meta: {
1237+ requiresSession: true
1238+ }
1239 },
1240 {
1241 path: "/:pathMatch(.*)*",
1242- component: NotFoundView
1243+ component: NotFoundView,
1244+ meta: {
1245+ requiresSession: true
1246+ }
1247 }
1248 ],
1249 scrollBehavior() {
1250 return { left: 0, top: 0 };
1251 }
1252 });
1253+
1254+router.beforeEach(async (to) => {
1255+ await ensureUiSessionLoaded();
1256+
1257+ if (to.meta.requiresSession === true && !uiSessionState.authenticated) {
1258+ return {
1259+ path: "/login",
1260+ query: {
1261+ redirect: to.fullPath
1262+ }
1263+ };
1264+ }
1265+
1266+ if (to.meta.guestOnly === true && uiSessionState.authenticated) {
1267+ return normalizeRedirectTarget(to.query.redirect);
1268+ }
1269+
1270+ return true;
1271+});
1272diff --git a/apps/conductor-ui/src/styles/base.css b/apps/conductor-ui/src/styles/base.css
1273index ddc6d43bb42dda22730d569de47127f088e03862..32ebc71d1002fc4b787f704839f12d740064a5a8 100644
1274--- a/apps/conductor-ui/src/styles/base.css
1275+++ b/apps/conductor-ui/src/styles/base.css
1276@@ -49,6 +49,10 @@ a {
1277 color: inherit;
1278 }
1279
1280+.app-root {
1281+ min-height: 100vh;
1282+}
1283+
1284 .shell {
1285 position: relative;
1286 width: min(1220px, calc(100% - 32px));
1287@@ -141,6 +145,57 @@ h2 {
1288 color: var(--warn);
1289 }
1290
1291+.ghost-button,
1292+.action-button,
1293+.role-card,
1294+.auth-input {
1295+ font: inherit;
1296+}
1297+
1298+.ghost-button,
1299+.action-button,
1300+.role-card,
1301+.back-link {
1302+ transition:
1303+ transform 160ms ease,
1304+ border-color 160ms ease,
1305+ background-color 160ms ease;
1306+}
1307+
1308+.ghost-button,
1309+.action-button {
1310+ display: inline-flex;
1311+ align-items: center;
1312+ justify-content: center;
1313+ min-height: 40px;
1314+ padding: 0 16px;
1315+ border-radius: 999px;
1316+ border: 1px solid var(--line);
1317+ background: var(--panel-strong);
1318+ color: var(--ink);
1319+ cursor: pointer;
1320+}
1321+
1322+.action-button {
1323+ background: linear-gradient(145deg, rgba(10, 108, 116, 0.18), rgba(255, 255, 255, 0.92));
1324+ color: var(--accent);
1325+}
1326+
1327+.ghost-button:hover,
1328+.action-button:hover,
1329+.role-card:hover,
1330+.back-link:hover {
1331+ transform: translateY(-1px);
1332+ border-color: rgba(10, 108, 116, 0.32);
1333+}
1334+
1335+.ghost-button:disabled,
1336+.action-button:disabled {
1337+ cursor: default;
1338+ opacity: 0.6;
1339+ transform: none;
1340+}
1341+
1342 .workspace-grid {
1343 display: grid;
1344 grid-template-columns: 280px minmax(0, 1fr);
1345@@ -269,6 +324,134 @@ h2 {
1346 border-bottom: 1px solid rgba(29, 24, 17, 0.08);
1347 }
1348
1349+.boot-overlay {
1350+ position: fixed;
1351+ inset: 0;
1352+ display: grid;
1353+ place-items: center;
1354+ padding: 20px;
1355+ background: rgba(239, 229, 209, 0.55);
1356+ backdrop-filter: blur(8px);
1357+}
1358+
1359+.boot-panel {
1360+ width: min(440px, 100%);
1361+ padding: 24px;
1362+}
1363+
1364+.boot-copy,
1365+.auth-note,
1366+.auth-hint,
1367+.auth-error,
1368+.role-card span {
1369+ font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, monospace;
1370+}
1371+
1372+.boot-copy {
1373+ color: var(--muted);
1374+}
1375+
1376+.auth-shell {
1377+ display: grid;
1378+ place-items: center;
1379+ min-height: 100vh;
1380+}
1381+
1382+.auth-panel {
1383+ display: grid;
1384+ grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
1385+ gap: 22px;
1386+ width: min(1040px, 100%);
1387+ padding: 28px;
1388+}
1389+
1390+.auth-copy {
1391+ display: grid;
1392+ align-content: start;
1393+}
1394+
1395+.auth-form {
1396+ display: grid;
1397+ gap: 18px;
1398+ align-content: start;
1399+}
1400+
1401+.auth-section {
1402+ display: grid;
1403+ gap: 10px;
1404+}
1405+
1406+.auth-field {
1407+ align-content: start;
1408+}
1409+
1410+.role-grid {
1411+ display: grid;
1412+ gap: 12px;
1413+ grid-template-columns: repeat(2, minmax(0, 1fr));
1414+}
1415+
1416+.role-card {
1417+ display: grid;
1418+ gap: 8px;
1419+ padding: 16px;
1420+ text-align: left;
1421+ border-radius: 18px;
1422+ border: 1px solid var(--line);
1423+ background: rgba(255, 255, 255, 0.42);
1424+ cursor: pointer;
1425+}
1426+
1427+.role-card strong {
1428+ font-size: 18px;
1429+}
1430+
1431+.role-card span {
1432+ color: var(--muted);
1433+ font-size: 12px;
1434+ line-height: 1.6;
1435+}
1436+
1437+.role-card-active {
1438+ border-color: rgba(10, 108, 116, 0.32);
1439+ background: linear-gradient(160deg, var(--accent-soft), rgba(255, 255, 255, 0.88));
1440+}
1441+
1442+.auth-input {
1443+ width: 100%;
1444+ min-height: 48px;
1445+ padding: 0 14px;
1446+ border-radius: 16px;
1447+ border: 1px solid var(--line);
1448+ background: rgba(255, 255, 255, 0.7);
1449+ color: var(--ink);
1450+}
1451+
1452+.auth-input:focus {
1453+ outline: 2px solid rgba(10, 108, 116, 0.18);
1454+ outline-offset: 1px;
1455+}
1456+
1457+.auth-note,
1458+.auth-hint {
1459+ color: var(--muted);
1460+ font-size: 12px;
1461+ line-height: 1.7;
1462+}
1463+
1464+.auth-error {
1465+ color: var(--warn);
1466+ font-size: 12px;
1467+ line-height: 1.7;
1468+}
1469+
1470+.auth-actions {
1471+ display: flex;
1472+ flex-wrap: wrap;
1473+ gap: 12px;
1474+ align-items: center;
1475+}
1476+
1477 .not-found-shell {
1478 display: grid;
1479 place-items: center;
1480@@ -286,11 +469,16 @@ h2 {
1481 }
1482
1483 @media (max-width: 960px) {
1484+ .auth-panel,
1485 .workspace-grid,
1486 .cards,
1487 .roadmap-row {
1488 grid-template-columns: 1fr;
1489 }
1490+
1491+ .role-grid {
1492+ grid-template-columns: 1fr;
1493+ }
1494 }
1495
1496 @media (max-width: 720px) {
1497diff --git a/docs/auth/README.md b/docs/auth/README.md
1498index 6e81f0bf48f5067afcd09106bead2d3828606837..ba244f5248912184e11576e0e6510df7fc153d0b 100644
1499--- a/docs/auth/README.md
1500+++ b/docs/auth/README.md
1501@@ -41,6 +41,38 @@
1502 | `browser_session` | 当前可用 | `browser_admin`、`readonly` | 浏览器侧 bearer token,承载控制面板和只读面板 |
1503 | `ops_session` | 当前可用 | `ops_admin` | 与浏览器控制面板分离的运维 token |
1504
1505+## 当前已落地的 UI session MVP
1506+
1507+`T-S071` 已经把浏览器工作台的最小 session 闭环接进 `conductor-daemon`:
1508+
1509+- `POST /v1/ui/session/login`
1510+- `POST /v1/ui/session/logout`
1511+- `GET /v1/ui/session/me`
1512+
1513+当前实现边界:
1514+
1515+- 介质:`HttpOnly` cookie
1516+- cookie 属性:`SameSite=Lax`、`Path=/`,在 `https` 下自动加 `Secure`
1517+- session 存储:当前进程内内存表,重启后失效
1518+- 角色:`browser_admin`、`readonly`
1519+
1520+当前环境变量:
1521+
1522+- `BAA_UI_BROWSER_ADMIN_PASSWORD`
1523+- `BAA_UI_READONLY_PASSWORD`
1524+- `BAA_UI_SESSION_TTL_SEC`
1525+
1526+当前 `login` 接口是单用户最小模型:
1527+
1528+- 请求体:`{ "role": "browser_admin" | "readonly", "password": "..." }`
1529+- 不向前端暴露长期 bearer token
1530+- `me` 始终返回当前会话状态和 `available_roles`,供 `/app/login` 与前端路由守卫使用
1531+
1532+当前未覆盖的范围:
1533+
1534+- session 不持久化到数据库
1535+- 现有大部分 `/v1/*` 业务接口仍保留原有访问边界,后续需要把 `browser_session` 逐步接到真正的读写授权中间层
1536+
1537 统一 claim 形状建议包含:
1538
1539 - `subject`
1540diff --git a/packages/auth/package.json b/packages/auth/package.json
1541index fbbda198b11fab124b27bf18d4385e69d9f33ed2..b0fd9e5a61b6727b0c8d5da9f2808a03730f59b6 100644
1542--- a/packages/auth/package.json
1543+++ b/packages/auth/package.json
1544@@ -2,12 +2,13 @@
1545 "name": "@baa-conductor/auth",
1546 "private": true,
1547 "type": "module",
1548+ "main": "dist/index.js",
1549 "exports": {
1550- ".": "./src/index.ts"
1551+ ".": "./dist/index.js"
1552 },
1553- "types": "./src/index.ts",
1554+ "types": "dist/index.d.ts",
1555 "scripts": {
1556- "build": "pnpm exec tsc --noEmit -p tsconfig.json",
1557+ "build": "pnpm exec tsc -p tsconfig.json",
1558 "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json"
1559 }
1560 }
1561diff --git a/packages/auth/src/browser-session.ts b/packages/auth/src/browser-session.ts
1562new file mode 100644
1563index 0000000000000000000000000000000000000000..c993ced0890e9a26bc64c64782677a286d908b9c
1564--- /dev/null
1565+++ b/packages/auth/src/browser-session.ts
1566@@ -0,0 +1,32 @@
1567+import {
1568+ DEFAULT_AUTH_AUDIENCE,
1569+ type AuthPrincipal
1570+} from "./model.js";
1571+
1572+export const BROWSER_SESSION_ROLES = ["browser_admin", "readonly"] as const;
1573+
1574+export type BrowserSessionRole = (typeof BROWSER_SESSION_ROLES)[number];
1575+
1576+export interface BrowserSessionDescriptor {
1577+ expiresAt: string;
1578+ issuedAt: string;
1579+ role: BrowserSessionRole;
1580+ sessionId: string;
1581+ subject: string;
1582+}
1583+
1584+export function isBrowserSessionRole(value: string): value is BrowserSessionRole {
1585+ return value === "browser_admin" || value === "readonly";
1586+}
1587+
1588+export function createBrowserSessionPrincipal(descriptor: BrowserSessionDescriptor): AuthPrincipal {
1589+ return {
1590+ audience: DEFAULT_AUTH_AUDIENCE,
1591+ expiresAt: descriptor.expiresAt,
1592+ issuedAt: descriptor.issuedAt,
1593+ role: descriptor.role,
1594+ sessionId: descriptor.sessionId,
1595+ subject: descriptor.subject,
1596+ tokenKind: "browser_session"
1597+ };
1598+}
1599diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts
1600index db16e9b9b8d3d7e7233b669514d7f84eacb12953..6fee61442d2a530f48f5e085967c5d95319f399b 100644
1601--- a/packages/auth/src/index.ts
1602+++ b/packages/auth/src/index.ts
1603@@ -1,4 +1,5 @@
1604 export * from "./actions.js";
1605+export * from "./browser-session.js";
1606 export * from "./control-api.js";
1607 export * from "./model.js";
1608 export * from "./policy.js";
1609diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json
1610index 0f94510416da351efd842201cc52ecc4f5f5885f..666ad9248331f9f9852a3dd51bf0d58b088fdc18 100644
1611--- a/packages/auth/tsconfig.json
1612+++ b/packages/auth/tsconfig.json
1613@@ -1,9 +1,9 @@
1614 {
1615 "extends": "../../tsconfig.base.json",
1616 "compilerOptions": {
1617+ "declaration": true,
1618 "rootDir": "src",
1619 "outDir": "dist"
1620 },
1621 "include": ["src/**/*.ts"]
1622 }
1623-
1624diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
1625index e325f2aff09cfe466b7b2c54ef9f6fcc3c2f7081..faa3672270e1a143e29f6a077e076c7617816385 100644
1626--- a/pnpm-lock.yaml
1627+++ b/pnpm-lock.yaml
1628@@ -21,6 +21,9 @@ importers:
1629 '@baa-conductor/artifact-db':
1630 specifier: workspace:*
1631 version: link:../../packages/artifact-db
1632+ '@baa-conductor/auth':
1633+ specifier: workspace:*
1634+ version: link:../../packages/auth
1635 '@baa-conductor/d1-client':
1636 specifier: workspace:*
1637 version: link:../../packages/d1-client
1638diff --git a/tasks/T-S071.md b/tasks/T-S071.md
1639index 9edaa9be1b5965727df6eea25c4476356cd220a5..5afb5e716032e2886f3f3ccd53a23be91f546a45 100644
1640--- a/tasks/T-S071.md
1641+++ b/tasks/T-S071.md
1642@@ -2,7 +2,7 @@
1643
1644 ## 状态
1645
1646-- 当前状态:`待开始`
1647+- 当前状态:`已完成`
1648 - 规模预估:`M`
1649 - 依赖任务:`T-S070`
1650 - 建议执行者:`Codex`(涉及 HTTP 鉴权边界、session cookie 和前后端联动)
1651@@ -21,7 +21,7 @@
1652
1653 - 仓库:`/Users/george/code/baa-conductor`
1654 - 分支基线:`main`
1655-- 提交:`b063524`
1656+- 提交:`ebbf8ac`
1657
1658 ## 分支与 worktree(强制)
1659
1660@@ -147,21 +147,41 @@
1661
1662 ### 开始执行
1663
1664-- 执行者:
1665-- 开始时间:
1666+- 执行者:`Codex`
1667+- 开始时间:`2026-04-02 00:24:22 CST`
1668 - 状态变更:`待开始` → `进行中`
1669
1670 ### 完成摘要
1671
1672-- 完成时间:
1673+- 完成时间:`2026-04-02 00:40:40 CST`
1674 - 状态变更:`进行中` → `已完成`
1675 - 修改了哪些文件:
1676+ - `packages/auth/`:补 `dist` 构建输出和浏览器 session 角色 helper
1677+ - `apps/conductor-daemon/src/ui-session.ts`:新增内存 session manager、cookie 生成与 principal 映射
1678+ - `apps/conductor-daemon/src/local-api.ts`:新增 `/v1/ui/session/login`、`/v1/ui/session/logout`、`/v1/ui/session/me`
1679+ - `apps/conductor-daemon/src/index.ts`:接入 UI session 配置、runtime warning 和 HTTP request context
1680+ - `apps/conductor-ui/src/auth/`、`apps/conductor-ui/src/features/auth/`:新增前端 session store 与 `/app/login`
1681+ - `apps/conductor-ui/src/routes/index.ts`:新增登录页和路由守卫
1682+ - `apps/conductor-ui/src/features/control/views/ControlWorkspaceView.vue`:接入登录态展示和登出按钮
1683+ - `apps/conductor-daemon/src/index.test.js`:补 `/app/login` 与 UI session cookie 闭环测试
1684+ - `docs/auth/README.md`:补 UI session MVP 合同与环境变量说明
1685 - 核心实现思路:
1686+ - 服务端使用进程内 session 表和 `HttpOnly` cookie 承载 `browser_admin` / `readonly`
1687+ - `me` 始终返回 `authenticated`、`available_roles`、`session`,作为登录页和前端守卫的统一合同
1688+ - `/app/login` 通过 Vue router guest route 进入,`/app/control` 和未知工作台路由统一走前端 session guard
1689+ - 登录凭据来自环境变量 `BAA_UI_BROWSER_ADMIN_PASSWORD`、`BAA_UI_READONLY_PASSWORD`,不向前端暴露长期 bearer token
1690 - 跑了哪些测试:
1691+ - `pnpm install`
1692+ - `pnpm -C packages/auth build`
1693+ - `pnpm -C apps/conductor-ui typecheck`
1694+ - `pnpm -C apps/conductor-ui build`
1695+ - `pnpm -C apps/conductor-daemon typecheck`
1696+ - `pnpm -C apps/conductor-daemon build`
1697+ - `pnpm -C apps/conductor-daemon test`
1698
1699 ### 执行过程中遇到的问题
1700
1701--
1702+- HTTP server 最初漏传了 `uiSessionManager` 到 `handleConductorHttpRequest(...)`,导致 `/v1/ui/session/*` 在 runtime 下退回到空 manager;已补 request context 传递并用集成测试覆盖
1703
1704 ### 剩余风险
1705
1706diff --git a/tasks/TASK_OVERVIEW.md b/tasks/TASK_OVERVIEW.md
1707index 17c75da6e21538b91ac2aac1c8d58948b7491721..364dbed88fdebcb3d4ef350d118ea15064a2b68e 100644
1708--- a/tasks/TASK_OVERVIEW.md
1709+++ b/tasks/TASK_OVERVIEW.md
1710@@ -95,19 +95,18 @@
1711 | [`T-S069`](./T-S069.md) | proxy_delivery 成功语义增强 | L | T-S060 | Codex | 已完成 |
1712 | [`T-S065`](./T-S065.md) | policy 配置化 | M | 无 | Codex | 已完成 |
1713 | [`T-S070`](./T-S070.md) | Conductor UI 基础设施:Vue 3 脚手架与 `/app` 静态托管 | M | 无 | Codex | 已完成 |
1714+| [`T-S071`](./T-S071.md) | Conductor UI 会话鉴权:登录页与浏览器 session | M | T-S070 | Codex | 已完成 |
1715
1716 ### 当前下一波任务
1717
1718-建议下一波继续按 Web UI 工作台剩余 2 张任务卡推进:
1719+建议下一波继续推进剩余的正式 `Control` 工作区:
1720
1721 | 任务 | 标题 | 规模 | 依赖 | 建议 AI | 状态 |
1722 |---|---|---|---|---|---|
1723-| [`T-S071`](./T-S071.md) | Conductor UI 会话鉴权:登录页与浏览器 session | M | T-S070 | Codex | 待开始 |
1724 | [`T-S072`](./T-S072.md) | Conductor UI `Control` 工作区首版 | L | T-S070, T-S071 | Codex | 待开始 |
1725
1726-这两张任务卡对应:
1727+这张任务卡对应:
1728
1729-- Phase 0.5:浏览器工作台 session 鉴权
1730 - Phase 1:正式 `Control` 工作区
1731
1732 `Channels` 工作区和正式 `channel` 域模型暂未纳入这一轮 3 张任务卡,后续应在 `T-S072` 收口后再继续拆。