baa-conductor


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` 收口后再继续拆。