baa-conductor


commit
8c51fa0
parent
aecd029
author
codex@macbookpro
date
2026-04-03 15:54:31 +0800 CST
Merge remote-tracking branch 'origin/feat/conductor-ui-control-workspace'

# Conflicts:
#	apps/conductor-daemon/src/index.test.js
#	apps/conductor-daemon/src/local-api.ts
#	apps/conductor-ui/src/features/control/views/ControlWorkspaceView.vue
#	apps/conductor-ui/src/routes/index.ts
#	apps/conductor-ui/src/styles/base.css
#	tasks/TASK_OVERVIEW.md
30 files changed,  +4205, -84
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 94f1762dad24e1b166da3a7a02e59d6bbe497569..2a44028624f6b55f15fd8f4b939323903bb4cb3d 100644
  27--- a/apps/conductor-daemon/src/index.test.js
  28+++ b/apps/conductor-daemon/src/index.test.js
  29@@ -7662,6 +7662,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@@ -7755,6 +7757,100 @@ test("ConductorRuntime serves health and migrated local API endpoints over HTTP"
  39     assert.equal(appAssetResponse.headers.get("cache-control"), "public, max-age=31536000, immutable");
  40     assert.ok(appAssetResponse.headers.get("content-type"));
  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 uiSessionMeResponse = await fetch(`${baseUrl}/v1/ui/session/me`);
  47+    assert.equal(uiSessionMeResponse.status, 200);
  48+    const uiSessionMePayload = await uiSessionMeResponse.json();
  49+    assert.equal(uiSessionMePayload.ok, true);
  50+    assert.equal(uiSessionMePayload.data.authenticated, false);
  51+    assert.deepEqual(uiSessionMePayload.data.available_roles, ["browser_admin", "readonly"]);
  52+    assert.equal(uiSessionMePayload.data.session, null);
  53+
  54+    const invalidUiLoginResponse = await fetch(`${baseUrl}/v1/ui/session/login`, {
  55+      method: "POST",
  56+      headers: {
  57+        "content-type": "application/json"
  58+      },
  59+      body: JSON.stringify({
  60+        password: "wrong-secret",
  61+        role: "readonly"
  62+      })
  63+    });
  64+    assert.equal(invalidUiLoginResponse.status, 401);
  65+    const invalidUiLoginPayload = await invalidUiLoginResponse.json();
  66+    assert.equal(invalidUiLoginPayload.ok, false);
  67+    assert.equal(invalidUiLoginPayload.error, "unauthorized");
  68+
  69+    const readonlyUiLoginResponse = await fetch(`${baseUrl}/v1/ui/session/login`, {
  70+      method: "POST",
  71+      headers: {
  72+        "content-type": "application/json"
  73+      },
  74+      body: JSON.stringify({
  75+        password: "readonly-secret",
  76+        role: "readonly"
  77+      })
  78+    });
  79+    assert.equal(readonlyUiLoginResponse.status, 200);
  80+    const readonlyUiLoginPayload = await readonlyUiLoginResponse.json();
  81+    assert.equal(readonlyUiLoginPayload.data.authenticated, true);
  82+    assert.equal(readonlyUiLoginPayload.data.session.role, "readonly");
  83+    const readonlySetCookie = readonlyUiLoginResponse.headers.get("set-cookie");
  84+    assert.ok(readonlySetCookie);
  85+    assert.match(readonlySetCookie, /baa_ui_session=/u);
  86+    assert.match(readonlySetCookie, /HttpOnly/u);
  87+    assert.match(readonlySetCookie, /SameSite=Lax/u);
  88+    assert.match(readonlySetCookie, /Path=\//u);
  89+    const readonlyCookie = readonlySetCookie.split(";", 1)[0];
  90+
  91+    const readonlyUiMeResponse = await fetch(`${baseUrl}/v1/ui/session/me`, {
  92+      headers: {
  93+        cookie: readonlyCookie
  94+      }
  95+    });
  96+    assert.equal(readonlyUiMeResponse.status, 200);
  97+    const readonlyUiMePayload = await readonlyUiMeResponse.json();
  98+    assert.equal(readonlyUiMePayload.data.authenticated, true);
  99+    assert.equal(readonlyUiMePayload.data.session.role, "readonly");
 100+    assert.match(readonlyUiMeResponse.headers.get("set-cookie") ?? "", /baa_ui_session=/u);
 101+
 102+    const readonlyUiLogoutResponse = await fetch(`${baseUrl}/v1/ui/session/logout`, {
 103+      method: "POST",
 104+      headers: {
 105+        cookie: readonlyCookie
 106+      }
 107+    });
 108+    assert.equal(readonlyUiLogoutResponse.status, 200);
 109+    assert.equal((await readonlyUiLogoutResponse.json()).data.authenticated, false);
 110+    assert.match(readonlyUiLogoutResponse.headers.get("set-cookie") ?? "", /Max-Age=0/u);
 111+
 112+    const staleReadonlyMeResponse = await fetch(`${baseUrl}/v1/ui/session/me`, {
 113+      headers: {
 114+        cookie: readonlyCookie
 115+      }
 116+    });
 117+    assert.equal(staleReadonlyMeResponse.status, 200);
 118+    const staleReadonlyMePayload = await staleReadonlyMeResponse.json();
 119+    assert.equal(staleReadonlyMePayload.data.authenticated, false);
 120+    assert.match(staleReadonlyMeResponse.headers.get("set-cookie") ?? "", /Max-Age=0/u);
 121+
 122+    const browserAdminLoginResponse = await fetch(`${baseUrl}/v1/ui/session/login`, {
 123+      method: "POST",
 124+      headers: {
 125+        "content-type": "application/json"
 126+      },
 127+      body: JSON.stringify({
 128+        password: "admin-secret",
 129+        role: "browser_admin"
 130+      })
 131+    });
 132+    assert.equal(browserAdminLoginResponse.status, 200);
 133+    const browserAdminLoginPayload = await browserAdminLoginResponse.json();
 134+    assert.equal(browserAdminLoginPayload.data.session.role, "browser_admin");
 135+
 136     const codexStatusResponse = await fetch(`${baseUrl}/v1/codex`);
 137     assert.equal(codexStatusResponse.status, 200);
 138     const codexStatusPayload = await codexStatusResponse.json();
 139diff --git a/apps/conductor-daemon/src/index.ts b/apps/conductor-daemon/src/index.ts
 140index bab2759a5ccc256b450d272bc6c3e52a4ad1c733..c215e4ef318e09855aa776807da94bebc67b4476 100644
 141--- a/apps/conductor-daemon/src/index.ts
 142+++ b/apps/conductor-daemon/src/index.ts
 143@@ -52,6 +52,10 @@ import {
 144   ConductorLocalControlPlane,
 145   resolveDefaultConductorStateDir
 146 } from "./local-control-plane.js";
 147+import {
 148+  DEFAULT_UI_SESSION_TTL_SEC,
 149+  UiSessionManager
 150+} from "./ui-session.js";
 151 import { createRenewalDispatcherRunner } from "./renewal/dispatcher.js";
 152 import { createRenewalProjectorRunner } from "./renewal/projector.js";
 153 import { ConductorTimedJobs } from "./timed-jobs/index.js";
 154@@ -191,6 +195,9 @@ export interface ConductorRuntimeConfig extends ConductorConfig {
 155   timedJobsMaxMessagesPerTick?: number;
 156   timedJobsMaxTasksPerTick?: number;
 157   timedJobsSettleDelayMs?: number;
 158+  uiBrowserAdminPassword?: string | null;
 159+  uiReadonlyPassword?: string | null;
 160+  uiSessionTtlSec?: number | null;
 161 }
 162 
 163 export interface ResolvedConductorRuntimeConfig
 164@@ -216,6 +223,9 @@ export interface ResolvedConductorRuntimeConfig
 165   timedJobsMaxMessagesPerTick: number;
 166   timedJobsMaxTasksPerTick: number;
 167   timedJobsSettleDelayMs: number;
 168+  uiBrowserAdminPassword: string | null;
 169+  uiReadonlyPassword: string | null;
 170+  uiSessionTtlSec: number;
 171   version: string | null;
 172 }
 173 
 174@@ -764,6 +774,7 @@ class ConductorLocalHttpServer {
 175   private readonly repository: ControlPlaneRepository;
 176   private readonly sharedToken: string | null;
 177   private readonly snapshotLoader: () => ConductorRuntimeSnapshot;
 178+  private readonly uiSessionManager: UiSessionManager;
 179   private readonly version: string | null;
 180   private resolvedBaseUrl: string;
 181   private server: Server | null = null;
 182@@ -780,6 +791,9 @@ class ConductorLocalHttpServer {
 183     sharedToken: string | null,
 184     version: string | null,
 185     now: () => number,
 186+    uiBrowserAdminPassword: string | null,
 187+    uiReadonlyPassword: string | null,
 188+    uiSessionTtlSec: number,
 189     artifactInlineThreshold: number,
 190     artifactSummaryLength: number,
 191     browserRequestPolicyOptions: BrowserRequestPolicyControllerOptions = {},
 192@@ -801,6 +815,12 @@ class ConductorLocalHttpServer {
 193     this.repository = repository;
 194     this.sharedToken = sharedToken;
 195     this.snapshotLoader = snapshotLoader;
 196+    this.uiSessionManager = new UiSessionManager({
 197+      browserAdminPassword: uiBrowserAdminPassword,
 198+      now: () => this.now() * 1000,
 199+      readonlyPassword: uiReadonlyPassword,
 200+      ttlSec: uiSessionTtlSec
 201+    });
 202     this.version = version;
 203     this.resolvedBaseUrl = localApiBase;
 204     const nowMs = () => this.now() * 1000;
 205@@ -812,6 +832,7 @@ class ConductorLocalHttpServer {
 206       repository: this.repository,
 207       sharedToken: this.sharedToken,
 208       snapshotLoader: this.snapshotLoader,
 209+      uiSessionManager: this.uiSessionManager,
 210       version: this.version
 211     };
 212     const instructionIngest = new BaaLiveInstructionIngest({
 213@@ -908,6 +929,7 @@ class ConductorLocalHttpServer {
 214             repository: this.repository,
 215             sharedToken: this.sharedToken,
 216             snapshotLoader: this.snapshotLoader,
 217+            uiSessionManager: this.uiSessionManager,
 218             version: this.version
 219           }
 220         );
 221@@ -1744,6 +1766,7 @@ export function resolveConductorRuntimeConfig(
 222     config.timedJobsMaxTasksPerTick ?? DEFAULT_TIMED_JOBS_MAX_TASKS_PER_TICK;
 223   const timedJobsSettleDelayMs =
 224     config.timedJobsSettleDelayMs ?? DEFAULT_TIMED_JOBS_SETTLE_DELAY_MS;
 225+  const uiSessionTtlSec = config.uiSessionTtlSec ?? DEFAULT_UI_SESSION_TTL_SEC;
 226   const priority = config.priority ?? (config.role === "primary" ? 100 : 50);
 227 
 228   if (heartbeatIntervalMs <= 0) {
 229@@ -1786,6 +1809,10 @@ export function resolveConductorRuntimeConfig(
 230     throw new Error("Conductor timedJobsSettleDelayMs must be a non-negative integer.");
 231   }
 232 
 233+  if (!Number.isInteger(uiSessionTtlSec) || uiSessionTtlSec <= 0) {
 234+    throw new Error("Conductor uiSessionTtlSec must be a positive integer.");
 235+  }
 236+
 237   return {
 238     ...config,
 239     artifactInlineThreshold,
 240@@ -1812,6 +1839,9 @@ export function resolveConductorRuntimeConfig(
 241     timedJobsMaxMessagesPerTick,
 242     timedJobsMaxTasksPerTick,
 243     timedJobsSettleDelayMs,
 244+    uiBrowserAdminPassword: normalizeOptionalString(config.uiBrowserAdminPassword),
 245+    uiReadonlyPassword: normalizeOptionalString(config.uiReadonlyPassword),
 246+    uiSessionTtlSec,
 247     version: normalizeOptionalString(config.version)
 248   };
 249 }
 250@@ -1924,6 +1954,11 @@ function resolveRuntimeConfigFromSources(
 251     localApiAllowedHosts: overrides.localApiAllowedHosts ?? env.BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS,
 252     localApiBase: normalizeOptionalString(overrides.localApiBase ?? env.BAA_CONDUCTOR_LOCAL_API),
 253     sharedToken: normalizeOptionalString(overrides.sharedToken ?? env.BAA_SHARED_TOKEN),
 254+    uiBrowserAdminPassword: normalizeOptionalString(env.BAA_UI_BROWSER_ADMIN_PASSWORD),
 255+    uiReadonlyPassword: normalizeOptionalString(env.BAA_UI_READONLY_PASSWORD),
 256+    uiSessionTtlSec: parseIntegerValue("Conductor UI session TTL", env.BAA_UI_SESSION_TTL_SEC, {
 257+      minimum: 1
 258+    }),
 259     paths: {
 260       logsDir: normalizeOptionalString(overrides.logsDir ?? env.BAA_LOGS_DIR),
 261       runsDir: normalizeOptionalString(overrides.runsDir ?? env.BAA_RUNS_DIR),
 262@@ -2127,6 +2162,10 @@ function buildRuntimeWarnings(config: ResolvedConductorRuntimeConfig): string[]
 263     warnings.push("BAA_SHARED_TOKEN is still set to replace-me; replace it before production rollout.");
 264   }
 265 
 266+  if (config.uiBrowserAdminPassword == null && config.uiReadonlyPassword == null) {
 267+    warnings.push("No UI session password is configured; /app/login cannot authenticate any browser session.");
 268+  }
 269+
 270   if (config.localApiBase == null) {
 271     warnings.push("BAA_CONDUCTOR_LOCAL_API is not configured; only the in-process snapshot interface is available.");
 272   }
 273@@ -2258,6 +2297,9 @@ function getUsageText(): string {
 274     "  BAA_CONDUCTOR_LOCAL_API",
 275     "  BAA_CONDUCTOR_LOCAL_API_ALLOWED_HOSTS",
 276     "  BAA_SHARED_TOKEN",
 277+    "  BAA_UI_BROWSER_ADMIN_PASSWORD",
 278+    "  BAA_UI_READONLY_PASSWORD",
 279+    "  BAA_UI_SESSION_TTL_SEC",
 280     "  BAA_ARTIFACT_INLINE_THRESHOLD",
 281     "  BAA_ARTIFACT_SUMMARY_LENGTH",
 282     "  BAA_CONDUCTOR_PRIORITY",
 283@@ -2369,6 +2411,9 @@ export class ConductorRuntime {
 284             this.config.sharedToken,
 285             this.config.version,
 286             this.now,
 287+            this.config.uiBrowserAdminPassword,
 288+            this.config.uiReadonlyPassword,
 289+            this.config.uiSessionTtlSec,
 290             this.config.artifactInlineThreshold,
 291             this.config.artifactSummaryLength,
 292             options.browserRequestPolicyOptions,
 293diff --git a/apps/conductor-daemon/src/local-api.ts b/apps/conductor-daemon/src/local-api.ts
 294index 447f523769bb11533b1ad26b31c2afad137402f8..85d2ab065bcb2374f23fe09ab9ee5525cb8bfd82 100644
 295--- a/apps/conductor-daemon/src/local-api.ts
 296+++ b/apps/conductor-daemon/src/local-api.ts
 297@@ -2,6 +2,11 @@ import * as nodeFs from "node:fs";
 298 import { randomUUID } from "node:crypto";
 299 import * as nodePath from "node:path";
 300 import { fileURLToPath } from "node:url";
 301+import {
 302+  isBrowserSessionRole,
 303+  type AuthPrincipal,
 304+  type BrowserSessionRole
 305+} from "../../../packages/auth/dist/index.js";
 306 import {
 307   buildArtifactRelativePath,
 308   buildArtifactPublicUrl,
 309@@ -84,6 +89,11 @@ import {
 310   setRenewalConversationAutomationStatus
 311 } from "./renewal/conversations.js";
 312 import type { BaaInstructionPolicyConfig } from "./instructions/policy.js";
 313+import {
 314+  UI_SESSION_COOKIE_NAME,
 315+  UiSessionManager,
 316+  type UiSessionRecord
 317+} from "./ui-session.js";
 318 
 319 interface FileStatsLike {
 320   isDirectory(): boolean;
 321@@ -333,6 +343,7 @@ interface LocalApiRequestContext {
 322   sharedToken: string | null;
 323   snapshotLoader: () => ConductorRuntimeApiSnapshot;
 324   uiDistDir: string;
 325+  uiSessionManager: UiSessionManager;
 326   url: URL;
 327 }
 328 
 329@@ -387,6 +398,7 @@ export interface ConductorLocalApiContext {
 330   sharedToken?: string | null;
 331   snapshotLoader: () => ConductorRuntimeApiSnapshot;
 332   uiDistDir?: string | null;
 333+  uiSessionManager?: UiSessionManager | null;
 334   version?: string | null;
 335 }
 336 
 337@@ -496,6 +508,30 @@ const LOCAL_API_ROUTES: LocalApiRouteDefinition[] = [
 338     pathPattern: "/app/:app_path*",
 339     summary: "为 conductor-ui 提供 SPA history fallback"
 340   },
 341+  {
 342+    id: "ui.session.me",
 343+    exposeInDescribe: false,
 344+    kind: "read",
 345+    method: "GET",
 346+    pathPattern: "/v1/ui/session/me",
 347+    summary: "读取当前 Web UI 会话与可登录角色"
 348+  },
 349+  {
 350+    id: "ui.session.login",
 351+    exposeInDescribe: false,
 352+    kind: "write",
 353+    method: "POST",
 354+    pathPattern: "/v1/ui/session/login",
 355+    summary: "创建当前 Web UI session cookie"
 356+  },
 357+  {
 358+    id: "ui.session.logout",
 359+    exposeInDescribe: false,
 360+    kind: "write",
 361+    method: "POST",
 362+    pathPattern: "/v1/ui/session/logout",
 363+    summary: "销毁当前 Web UI session cookie"
 364+  },
 365   {
 366     id: "service.code.read",
 367     kind: "read",
 368@@ -4380,6 +4416,42 @@ function readHeaderValue(request: ConductorHttpRequest, headerName: string): str
 369   return undefined;
 370 }
 371 
 372+function buildUiSessionResponseData(
 373+  context: LocalApiRequestContext,
 374+  session: UiSessionRecord | null
 375+): JsonObject {
 376+  return {
 377+    authenticated: session != null,
 378+    available_roles: context.uiSessionManager.getAvailableRoles(),
 379+    session: UiSessionManager.toSnapshot(session) as unknown as JsonValue
 380+  };
 381+}
 382+
 383+function buildUiSessionSuccessResponse(
 384+  context: LocalApiRequestContext,
 385+  session: UiSessionRecord | null,
 386+  headers: Record<string, string> = {}
 387+): ConductorHttpResponse {
 388+  return buildSuccessEnvelope(context.requestId, 200, buildUiSessionResponseData(context, session), headers);
 389+}
 390+
 391+function buildUiSessionCookieHeader(context: LocalApiRequestContext, session: UiSessionRecord): string {
 392+  return context.uiSessionManager.buildSessionCookieHeader(session.sessionId, context.url.protocol);
 393+}
 394+
 395+function buildUiSessionClearCookieHeader(context: LocalApiRequestContext): string {
 396+  return context.uiSessionManager.buildClearCookieHeader(context.url.protocol);
 397+}
 398+
 399+function hasUiSessionCookie(request: ConductorHttpRequest): boolean {
 400+  const cookieHeader = readHeaderValue(request, "cookie");
 401+  return typeof cookieHeader === "string" && cookieHeader.includes(`${UI_SESSION_COOKIE_NAME}=`);
 402+}
 403+
 404+function readUiSessionPrincipal(context: LocalApiRequestContext): AuthPrincipal | null {
 405+  return context.uiSessionManager.resolvePrincipal(readHeaderValue(context.request, "cookie"));
 406+}
 407+
 408 function extractBearerToken(
 409   authorizationHeader: string | undefined
 410 ): { ok: true; token: string } | { ok: false; reason: SharedTokenAuthFailureReason } {
 411@@ -6770,6 +6842,75 @@ function isPathOutsideRoot(rootPath: string, targetPath: string): boolean {
 412   return firstSegment === "..";
 413 }
 414 
 415+async function handleUiSessionMe(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
 416+  const session = context.uiSessionManager.touch(readHeaderValue(context.request, "cookie"));
 417+
 418+  if (!session && hasUiSessionCookie(context.request)) {
 419+    return buildUiSessionSuccessResponse(context, null, {
 420+      "set-cookie": buildUiSessionClearCookieHeader(context)
 421+    });
 422+  }
 423+
 424+  if (!session) {
 425+    return buildUiSessionSuccessResponse(context, null);
 426+  }
 427+
 428+  return buildUiSessionSuccessResponse(context, session, {
 429+    "set-cookie": buildUiSessionCookieHeader(context, session)
 430+  });
 431+}
 432+
 433+async function handleUiSessionLogin(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
 434+  if (!context.uiSessionManager.hasConfiguredRoles()) {
 435+    throw new LocalApiHttpError(
 436+      503,
 437+      "ui_session_not_configured",
 438+      "No UI session role is configured on this conductor instance."
 439+    );
 440+  }
 441+
 442+  const body = readBodyObject(context.request);
 443+  const role = readOptionalStringBodyField(body, "role");
 444+  const password = readOptionalStringBodyField(body, "password");
 445+
 446+  if (!role || !isBrowserSessionRole(role)) {
 447+    throw new LocalApiHttpError(400, "invalid_request", 'Field "role" must be "browser_admin" or "readonly".', {
 448+      field: "role"
 449+    });
 450+  }
 451+
 452+  if (!password) {
 453+    throw new LocalApiHttpError(400, "invalid_request", 'Field "password" is required.', {
 454+      field: "password"
 455+    });
 456+  }
 457+
 458+  const result = context.uiSessionManager.login(role, password);
 459+
 460+  if (!result.ok) {
 461+    if (result.reason === "role_not_enabled") {
 462+      throw new LocalApiHttpError(
 463+        403,
 464+        "forbidden",
 465+        `UI session role "${role}" is not enabled on this conductor instance.`
 466+      );
 467+    }
 468+
 469+    throw new LocalApiHttpError(401, "unauthorized", "Invalid UI session credentials.");
 470+  }
 471+
 472+  return buildUiSessionSuccessResponse(context, result.session, {
 473+    "set-cookie": buildUiSessionCookieHeader(context, result.session)
 474+  });
 475+}
 476+
 477+async function handleUiSessionLogout(context: LocalApiRequestContext): Promise<ConductorHttpResponse> {
 478+  context.uiSessionManager.logout(readHeaderValue(context.request, "cookie"));
 479+
 480+  return buildUiSessionSuccessResponse(context, null, {
 481+    "set-cookie": buildUiSessionClearCookieHeader(context)
 482+  });
 483+}
 484 function buildCodeAccessForbiddenError(): LocalApiHttpError {
 485   return new LocalApiHttpError(403, "forbidden", "Requested code path is not allowed.");
 486 }
 487@@ -7844,6 +7985,12 @@ async function dispatchBusinessRoute(
 488       return handleScopedDescribeRead(context, version, "control");
 489     case "service.robots":
 490       return handleRobotsRead();
 491+    case "ui.session.me":
 492+      return handleUiSessionMe(context);
 493+    case "ui.session.login":
 494+      return handleUiSessionLogin(context);
 495+    case "ui.session.logout":
 496+      return handleUiSessionLogout(context);
 497     case "service.artifact.read":
 498       return handleArtifactRead(context);
 499     case "service.app.shell":
 500@@ -8097,6 +8244,14 @@ export async function handleConductorHttpRequest(
 501   const matchedRoute = matchRoute(request.method.toUpperCase(), url.pathname);
 502   const requestId = crypto.randomUUID();
 503   const version = context.version?.trim() || "dev";
 504+  const now = context.now ?? (() => Math.floor(Date.now() / 1000));
 505+  const uiSessionManager =
 506+    context.uiSessionManager ??
 507+    new UiSessionManager({
 508+      browserAdminPassword: null,
 509+      now: () => now() * 1000,
 510+      readonlyPassword: null
 511+    });
 512 
 513   if (!matchedRoute) {
 514     const allowedMethods = findAllowedMethods(url.pathname);
 515@@ -8144,7 +8299,7 @@ export async function handleConductorHttpRequest(
 516         codexdLocalApiBase:
 517           normalizeOptionalString(context.codexdLocalApiBase) ?? getSnapshotCodexdLocalApiBase(context.snapshotLoader()),
 518         fetchImpl: context.fetchImpl ?? globalThis.fetch,
 519-        now: context.now ?? (() => Math.floor(Date.now() / 1000)),
 520+        now,
 521         params: matchedRoute.params,
 522         repository: context.repository,
 523         request,
 524@@ -8152,6 +8307,7 @@ export async function handleConductorHttpRequest(
 525         sharedToken: normalizeOptionalString(context.sharedToken),
 526         snapshotLoader: context.snapshotLoader,
 527         uiDistDir: resolveUiDistDir(context.uiDistDir),
 528+        uiSessionManager,
 529         url
 530       },
 531       version
 532diff --git a/apps/conductor-daemon/src/ui-session.ts b/apps/conductor-daemon/src/ui-session.ts
 533new file mode 100644
 534index 0000000000000000000000000000000000000000..0e36db681192be51616bef989f56f77f4718f83d
 535--- /dev/null
 536+++ b/apps/conductor-daemon/src/ui-session.ts
 537@@ -0,0 +1,273 @@
 538+import { randomUUID } from "node:crypto";
 539+import {
 540+  createBrowserSessionPrincipal,
 541+  type AuthPrincipal,
 542+  type BrowserSessionRole
 543+} from "../../../packages/auth/dist/index.js";
 544+
 545+export const UI_SESSION_COOKIE_NAME = "baa_ui_session";
 546+export const DEFAULT_UI_SESSION_TTL_SEC = 60 * 60 * 8;
 547+
 548+export interface UiSessionManagerOptions {
 549+  browserAdminPassword: string | null;
 550+  now?: () => number;
 551+  readonlyPassword: string | null;
 552+  ttlSec?: number;
 553+}
 554+
 555+export interface UiSessionRecord {
 556+  createdAtMs: number;
 557+  expiresAtMs: number;
 558+  lastSeenAtMs: number;
 559+  role: BrowserSessionRole;
 560+  sessionId: string;
 561+  subject: string;
 562+}
 563+
 564+export interface UiSessionSnapshot {
 565+  expires_at: string;
 566+  issued_at: string;
 567+  role: BrowserSessionRole;
 568+  session_id: string;
 569+  subject: string;
 570+}
 571+
 572+export type UiSessionLoginFailureReason = "invalid_credentials" | "role_not_enabled";
 573+
 574+export type UiSessionLoginResult =
 575+  | {
 576+      ok: true;
 577+      session: UiSessionRecord;
 578+    }
 579+  | {
 580+      ok: false;
 581+      reason: UiSessionLoginFailureReason;
 582+    };
 583+
 584+export class UiSessionManager {
 585+  private readonly browserAdminPassword: string | null;
 586+  private readonly now: () => number;
 587+  private readonly readonlyPassword: string | null;
 588+  private readonly sessions = new Map<string, UiSessionRecord>();
 589+  private readonly ttlSec: number;
 590+
 591+  constructor(options: UiSessionManagerOptions) {
 592+    this.browserAdminPassword = normalizeOptionalString(options.browserAdminPassword);
 593+    this.now = options.now ?? Date.now;
 594+    this.readonlyPassword = normalizeOptionalString(options.readonlyPassword);
 595+    this.ttlSec = normalizeTtlSec(options.ttlSec);
 596+  }
 597+
 598+  buildClearCookieHeader(requestProtocol: string): string {
 599+    return buildSessionCookieHeader("", 0, requestProtocol);
 600+  }
 601+
 602+  buildSessionCookieHeader(sessionId: string, requestProtocol: string): string {
 603+    return buildSessionCookieHeader(sessionId, this.ttlSec, requestProtocol);
 604+  }
 605+
 606+  getAvailableRoles(): BrowserSessionRole[] {
 607+    const roles: BrowserSessionRole[] = [];
 608+
 609+    if (this.browserAdminPassword != null) {
 610+      roles.push("browser_admin");
 611+    }
 612+
 613+    if (this.readonlyPassword != null) {
 614+      roles.push("readonly");
 615+    }
 616+
 617+    return roles;
 618+  }
 619+
 620+  hasConfiguredRoles(): boolean {
 621+    return this.getAvailableRoles().length > 0;
 622+  }
 623+
 624+  login(role: BrowserSessionRole, password: string): UiSessionLoginResult {
 625+    const expectedPassword = this.getExpectedPassword(role);
 626+
 627+    if (expectedPassword == null) {
 628+      return {
 629+        ok: false,
 630+        reason: "role_not_enabled"
 631+      };
 632+    }
 633+
 634+    if (password !== expectedPassword) {
 635+      return {
 636+        ok: false,
 637+        reason: "invalid_credentials"
 638+      };
 639+    }
 640+
 641+    const session = this.createSession(role);
 642+    return {
 643+      ok: true,
 644+      session
 645+    };
 646+  }
 647+
 648+  logout(cookieHeader: string | undefined): boolean {
 649+    const sessionId = readCookieValue(cookieHeader, UI_SESSION_COOKIE_NAME);
 650+
 651+    if (!sessionId) {
 652+      return false;
 653+    }
 654+
 655+    return this.sessions.delete(sessionId);
 656+  }
 657+
 658+  resolvePrincipal(cookieHeader: string | undefined): AuthPrincipal | null {
 659+    const session = this.readSession(cookieHeader);
 660+
 661+    if (!session) {
 662+      return null;
 663+    }
 664+
 665+    return createBrowserSessionPrincipal({
 666+      expiresAt: toIsoString(session.expiresAtMs),
 667+      issuedAt: toIsoString(session.createdAtMs),
 668+      role: session.role,
 669+      sessionId: session.sessionId,
 670+      subject: session.subject
 671+    });
 672+  }
 673+
 674+  touch(cookieHeader: string | undefined): UiSessionRecord | null {
 675+    const sessionId = readCookieValue(cookieHeader, UI_SESSION_COOKIE_NAME);
 676+
 677+    if (!sessionId) {
 678+      return null;
 679+    }
 680+
 681+    const current = this.sessions.get(sessionId);
 682+
 683+    if (!current) {
 684+      return null;
 685+    }
 686+
 687+    if (current.expiresAtMs <= this.now()) {
 688+      this.sessions.delete(sessionId);
 689+      return null;
 690+    }
 691+
 692+    const nowMs = this.now();
 693+    const updated: UiSessionRecord = {
 694+      ...current,
 695+      expiresAtMs: nowMs + this.ttlSec * 1000,
 696+      lastSeenAtMs: nowMs
 697+    };
 698+    this.sessions.set(updated.sessionId, updated);
 699+    return updated;
 700+  }
 701+
 702+  static toSnapshot(session: UiSessionRecord | null): UiSessionSnapshot | null {
 703+    if (!session) {
 704+      return null;
 705+    }
 706+
 707+    return {
 708+      expires_at: toIsoString(session.expiresAtMs),
 709+      issued_at: toIsoString(session.createdAtMs),
 710+      role: session.role,
 711+      session_id: session.sessionId,
 712+      subject: session.subject
 713+    };
 714+  }
 715+
 716+  private createSession(role: BrowserSessionRole): UiSessionRecord {
 717+    const nowMs = this.now();
 718+    const session: UiSessionRecord = {
 719+      createdAtMs: nowMs,
 720+      expiresAtMs: nowMs + this.ttlSec * 1000,
 721+      lastSeenAtMs: nowMs,
 722+      role,
 723+      sessionId: randomUUID(),
 724+      subject: `ui:${role}`
 725+    };
 726+    this.sessions.set(session.sessionId, session);
 727+    return session;
 728+  }
 729+
 730+  private getExpectedPassword(role: BrowserSessionRole): string | null {
 731+    if (role === "browser_admin") {
 732+      return this.browserAdminPassword;
 733+    }
 734+
 735+    return this.readonlyPassword;
 736+  }
 737+
 738+  private readSession(cookieHeader: string | undefined): UiSessionRecord | null {
 739+    const sessionId = readCookieValue(cookieHeader, UI_SESSION_COOKIE_NAME);
 740+
 741+    if (!sessionId) {
 742+      return null;
 743+    }
 744+
 745+    const current = this.sessions.get(sessionId);
 746+
 747+    if (!current) {
 748+      return null;
 749+    }
 750+
 751+    if (current.expiresAtMs <= this.now()) {
 752+      this.sessions.delete(sessionId);
 753+      return null;
 754+    }
 755+
 756+    return current;
 757+  }
 758+}
 759+
 760+function buildSessionCookieHeader(value: string, maxAgeSec: number, requestProtocol: string): string {
 761+  const parts = [`${UI_SESSION_COOKIE_NAME}=${encodeURIComponent(value)}`, "Path=/", "HttpOnly", "SameSite=Lax"];
 762+
 763+  if (requestProtocol === "https:") {
 764+    parts.push("Secure");
 765+  }
 766+
 767+  parts.push(`Max-Age=${Math.max(0, Math.trunc(maxAgeSec))}`);
 768+  return parts.join("; ");
 769+}
 770+
 771+function normalizeOptionalString(value: string | null | undefined): string | null {
 772+  if (value == null) {
 773+    return null;
 774+  }
 775+
 776+  const normalized = value.trim();
 777+  return normalized === "" ? null : normalized;
 778+}
 779+
 780+function normalizeTtlSec(value: number | undefined): number {
 781+  if (!Number.isInteger(value) || value == null || value <= 0) {
 782+    return DEFAULT_UI_SESSION_TTL_SEC;
 783+  }
 784+
 785+  return value;
 786+}
 787+
 788+function readCookieValue(cookieHeader: string | undefined, name: string): string | null {
 789+  if (!cookieHeader) {
 790+    return null;
 791+  }
 792+
 793+  for (const part of cookieHeader.split(";")) {
 794+    const [rawName, ...rawValueParts] = part.split("=");
 795+    const cookieName = rawName?.trim();
 796+
 797+    if (!cookieName || cookieName !== name) {
 798+      continue;
 799+    }
 800+
 801+    const rawValue = rawValueParts.join("=").trim();
 802+    return rawValue === "" ? null : decodeURIComponent(rawValue);
 803+  }
 804+
 805+  return null;
 806+}
 807+
 808+function toIsoString(value: number): string {
 809+  return new Date(value).toISOString();
 810+}
 811diff --git a/apps/conductor-ui/src/App.vue b/apps/conductor-ui/src/App.vue
 812index 543cfdbab78c08f07ccec245e955b5d84ad0f7ab..0481736ad9918419586d59207410003b3768adc3 100644
 813--- a/apps/conductor-ui/src/App.vue
 814+++ b/apps/conductor-ui/src/App.vue
 815@@ -1,7 +1,17 @@
 816 <template>
 817-  <RouterView />
 818+  <div class="app-root">
 819+    <RouterView />
 820+    <div v-if="uiSessionState.pending && !uiSessionState.initialized" class="boot-overlay">
 821+      <div class="panel boot-panel">
 822+        <p class="eyebrow">BAA Conductor / Boot</p>
 823+        <p class="boot-copy">Checking UI session…</p>
 824+      </div>
 825+    </div>
 826+  </div>
 827 </template>
 828 
 829 <script setup lang="ts">
 830 import { RouterView } from "vue-router";
 831+
 832+import { uiSessionState } from "./auth/session";
 833 </script>
 834diff --git a/apps/conductor-ui/src/api/control.ts b/apps/conductor-ui/src/api/control.ts
 835new file mode 100644
 836index 0000000000000000000000000000000000000000..ccfc8d907aa3f6e82f626fe605c9042ab8248c79
 837--- /dev/null
 838+++ b/apps/conductor-ui/src/api/control.ts
 839@@ -0,0 +1,724 @@
 840+import { postJson, requestJson } from "./http";
 841+
 842+export type TimestampLike = number | string | null | undefined;
 843+export type ControlWorkspaceErrorKey =
 844+  | "artifactExecutions"
 845+  | "artifactMessages"
 846+  | "artifactSessions"
 847+  | "browser"
 848+  | "codexSessions"
 849+  | "codexStatus"
 850+  | "renewalConversations"
 851+  | "renewalJobs"
 852+  | "renewalLinks"
 853+  | "runs"
 854+  | "system"
 855+  | "tasks";
 856+export type ControlWorkspaceSystemAction = "drain" | "pause" | "resume";
 857+export type BrowserActionName =
 858+  | "controller_reload"
 859+  | "plugin_status"
 860+  | "request_credentials"
 861+  | "tab_focus"
 862+  | "tab_open"
 863+  | "tab_reload"
 864+  | "tab_restore"
 865+  | "ws_reconnect";
 866+
 867+export interface SystemState {
 868+  automation: {
 869+    mode: string | null;
 870+    reason?: string | null;
 871+    requested_by?: string | null;
 872+    source?: string | null;
 873+    updated_at?: string | null;
 874+  };
 875+  holder_host?: string | null;
 876+  holder_id?: string | null;
 877+  leader: {
 878+    controller_id?: string | null;
 879+    host?: string | null;
 880+    lease_expires_at?: string | null;
 881+    role?: string | null;
 882+    status?: string | null;
 883+    term?: number | null;
 884+    version?: string | null;
 885+  };
 886+  lease_expires_at?: string | null;
 887+  mode: string | null;
 888+  queue: {
 889+    active_runs: number;
 890+    queued_tasks: number;
 891+  };
 892+  term?: number | null;
 893+  updated_at?: string | null;
 894+}
 895+
 896+export interface BrowserCredentialSnapshot {
 897+  account?: string | null;
 898+  account_captured_at?: TimestampLike;
 899+  account_last_seen_at?: TimestampLike;
 900+  captured_at?: TimestampLike;
 901+  credential_fingerprint?: string | null;
 902+  freshness?: string | null;
 903+  header_count?: number | null;
 904+  last_seen_at?: TimestampLike;
 905+  platform: string;
 906+}
 907+
 908+export interface BrowserRequestHookSnapshot {
 909+  account?: string | null;
 910+  credential_fingerprint?: string | null;
 911+  endpoint_count?: number | null;
 912+  endpoint_metadata?: Array<{
 913+    first_seen_at?: TimestampLike;
 914+    last_seen_at?: TimestampLike;
 915+    method?: string | null;
 916+    path?: string | null;
 917+  }>;
 918+  endpoints?: string[];
 919+  last_verified_at?: TimestampLike;
 920+  platform: string;
 921+  updated_at?: TimestampLike;
 922+}
 923+
 924+export interface BrowserShellRuntimeSnapshot {
 925+  actual: {
 926+    active?: boolean | null;
 927+    candidate_tab_id?: number | null;
 928+    candidate_url?: string | null;
 929+    discarded?: boolean | null;
 930+    exists: boolean;
 931+    healthy?: boolean | null;
 932+    hidden?: boolean | null;
 933+    issue?: string | null;
 934+    last_ready_at?: TimestampLike;
 935+    last_seen_at?: TimestampLike;
 936+    status?: string | null;
 937+    tab_id?: number | null;
 938+    title?: string | null;
 939+    url?: string | null;
 940+    window_id?: number | null;
 941+  };
 942+  desired: {
 943+    exists: boolean;
 944+    last_action?: string | null;
 945+    last_action_at?: TimestampLike;
 946+    reason?: string | null;
 947+    shell_url?: string | null;
 948+    source?: string | null;
 949+    updated_at?: TimestampLike;
 950+  };
 951+  drift: {
 952+    aligned: boolean;
 953+    needs_restore?: boolean | null;
 954+    reason?: string | null;
 955+    unexpected_actual?: boolean | null;
 956+  };
 957+  platform: string;
 958+}
 959+
 960+export interface BrowserActionResultItemSnapshot {
 961+  delivery_ack?: {
 962+    confirmed_at?: TimestampLike;
 963+    failed?: boolean;
 964+    level?: number | string | null;
 965+    reason?: string | null;
 966+    status_code?: number | null;
 967+  };
 968+  ok?: boolean;
 969+  platform?: string | null;
 970+  restored?: boolean | null;
 971+  shell_runtime?: BrowserShellRuntimeSnapshot | null;
 972+  skipped?: boolean | null;
 973+  tab_id?: number | null;
 974+}
 975+
 976+export interface BrowserActionResultSnapshot {
 977+  accepted: boolean;
 978+  action: string;
 979+  completed: boolean;
 980+  failed: boolean;
 981+  reason?: string | null;
 982+  received_at?: TimestampLike;
 983+  request_id?: string | null;
 984+  result?: {
 985+    actual_count?: number | null;
 986+    desired_count?: number | null;
 987+    drift_count?: number | null;
 988+    failed_count?: number | null;
 989+    ok_count?: number | null;
 990+    platform_count?: number | null;
 991+    restored_count?: number | null;
 992+    skipped_reasons?: Record<string, number> | null;
 993+  };
 994+  results?: BrowserActionResultItemSnapshot[];
 995+  shell_runtime?: BrowserShellRuntimeSnapshot[];
 996+  target?: {
 997+    client_id?: string | null;
 998+    connection_id?: string | null;
 999+    platform?: string | null;
1000+    requested_client_id?: string | null;
1001+    requested_platform?: string | null;
1002+  };
1003+  type?: string | null;
1004+}
1005+
1006+export interface BrowserBridgeClient {
1007+  client_id: string;
1008+  connected_at: TimestampLike;
1009+  connection_id: string;
1010+  credentials: BrowserCredentialSnapshot[];
1011+  last_action_result: BrowserActionResultSnapshot | null;
1012+  last_message_at: TimestampLike;
1013+  node_category?: string | null;
1014+  node_platform?: string | null;
1015+  node_type?: string | null;
1016+  request_hooks: BrowserRequestHookSnapshot[];
1017+  shell_runtime: BrowserShellRuntimeSnapshot[];
1018+}
1019+
1020+export interface BrowserRecord {
1021+  account?: string | null;
1022+  active_connection?: {
1023+    active_client?: boolean;
1024+    connected_at?: TimestampLike;
1025+    connection_id?: string | null;
1026+    last_message_at?: TimestampLike;
1027+    node_category?: string | null;
1028+    node_platform?: string | null;
1029+    node_type?: string | null;
1030+  } | null;
1031+  browser?: string | null;
1032+  client_id?: string | null;
1033+  host?: string | null;
1034+  live?: {
1035+    credentials?: BrowserCredentialSnapshot | null;
1036+    request_hooks?: BrowserRequestHookSnapshot | null;
1037+    shell_runtime?: BrowserShellRuntimeSnapshot | null;
1038+  } | null;
1039+  persisted?: {
1040+    captured_at?: TimestampLike;
1041+    credential_fingerprint?: string | null;
1042+    endpoints?: string[];
1043+    last_seen_at?: TimestampLike;
1044+    last_verified_at?: TimestampLike;
1045+    status?: string | null;
1046+    updated_at?: TimestampLike;
1047+  } | null;
1048+  platform: string;
1049+  status?: string | null;
1050+  view?: string | null;
1051+}
1052+
1053+export interface BrowserAutomationConversation {
1054+  active_link?: {
1055+    client_id?: string | null;
1056+    link_id: string;
1057+    local_conversation_id: string;
1058+    page_title?: string | null;
1059+    page_url?: string | null;
1060+    remote_conversation_id?: string | null;
1061+    route_path?: string | null;
1062+    route_pattern?: string | null;
1063+    target_id?: string | null;
1064+    target_kind?: string | null;
1065+    updated_at?: TimestampLike;
1066+  } | null;
1067+  automation_status: string;
1068+  execution_state?: string | null;
1069+  last_error?: string | null;
1070+  last_non_paused_automation_status?: string | null;
1071+  local_conversation_id: string;
1072+  pause_reason?: string | null;
1073+  paused_at?: TimestampLike;
1074+  platform: string;
1075+  remote_conversation_id?: string | null;
1076+  updated_at?: TimestampLike;
1077+}
1078+
1079+export interface BrowserStatusData {
1080+  automation_conversations: BrowserAutomationConversation[];
1081+  bridge: {
1082+    active_client_id?: string | null;
1083+    active_connection_id?: string | null;
1084+    client_count: number;
1085+    clients: BrowserBridgeClient[];
1086+    status?: string | null;
1087+    transport?: string | null;
1088+    ws_path?: string | null;
1089+    ws_url?: string | null;
1090+  };
1091+  claude: {
1092+    credentials: BrowserCredentialSnapshot | null;
1093+    current_client_id?: string | null;
1094+    open_url?: string | null;
1095+    platform: string;
1096+    ready: boolean;
1097+    request_hooks: BrowserRequestHookSnapshot | null;
1098+    shell_runtime: BrowserShellRuntimeSnapshot | null;
1099+    supported: boolean;
1100+  };
1101+  current_client: BrowserBridgeClient | null;
1102+  delivery: {
1103+    active_session_count?: number | null;
1104+    last_route?: Record<string, unknown> | null;
1105+    last_session?: Record<string, unknown> | null;
1106+  };
1107+  filters?: Record<string, unknown>;
1108+  instruction_ingest: {
1109+    last_execute?: Record<string, unknown> | null;
1110+    last_ingest?: Record<string, unknown> | null;
1111+    recent_executes?: unknown[];
1112+    recent_ingests?: unknown[];
1113+  };
1114+  policy: {
1115+    defaults?: Record<string, unknown>;
1116+    platforms?: Array<Record<string, unknown>>;
1117+    targets?: Array<Record<string, unknown>>;
1118+  };
1119+  records: BrowserRecord[];
1120+  summary: {
1121+    active_records: number;
1122+    automation_conversation_count: number;
1123+    matched_records: number;
1124+    persisted_only_records: number;
1125+    runtime_counts: {
1126+      actual: number;
1127+      desired: number;
1128+      drift: number;
1129+    };
1130+    status_counts: {
1131+      fresh: number;
1132+      lost: number;
1133+      stale: number;
1134+    };
1135+  };
1136+}
1137+
1138+export interface RenewalConversation {
1139+  active_link?: RenewalLink | null;
1140+  automation_status: string;
1141+  consecutive_failure_count?: number | null;
1142+  cooldown_until?: TimestampLike;
1143+  created_at?: TimestampLike;
1144+  execution_state?: string | null;
1145+  last_error?: string | null;
1146+  last_message_at?: TimestampLike;
1147+  last_message_id?: string | null;
1148+  last_non_paused_automation_status?: string | null;
1149+  local_conversation_id: string;
1150+  pause_reason?: string | null;
1151+  paused_at?: TimestampLike;
1152+  platform: string;
1153+  repeated_message_count?: number | null;
1154+  repeated_renewal_count?: number | null;
1155+  summary?: string | null;
1156+  title?: string | null;
1157+  updated_at?: TimestampLike;
1158+}
1159+
1160+export interface RenewalLink {
1161+  client_id?: string | null;
1162+  created_at?: TimestampLike;
1163+  is_active: boolean;
1164+  link_id: string;
1165+  local_conversation_id: string;
1166+  observed_at?: TimestampLike;
1167+  page_title?: string | null;
1168+  page_url?: string | null;
1169+  platform: string;
1170+  remote_conversation_id?: string | null;
1171+  route?: {
1172+    params?: Record<string, unknown> | null;
1173+    path?: string | null;
1174+    pattern?: string | null;
1175+  } | null;
1176+  target?: {
1177+    id?: string | null;
1178+    kind?: string | null;
1179+    payload?: Record<string, unknown> | string | null;
1180+  } | null;
1181+  updated_at?: TimestampLike;
1182+}
1183+
1184+export interface RenewalJob {
1185+  attempt_count?: number | null;
1186+  created_at?: TimestampLike;
1187+  finished_at?: TimestampLike;
1188+  job_id: string;
1189+  last_attempt_at?: TimestampLike;
1190+  last_error?: string | null;
1191+  local_conversation_id: string;
1192+  log_path?: string | null;
1193+  max_attempts?: number | null;
1194+  message_id?: string | null;
1195+  next_attempt_at?: TimestampLike;
1196+  payload?: unknown;
1197+  payload_kind?: string | null;
1198+  payload_text?: string | null;
1199+  started_at?: TimestampLike;
1200+  status: string;
1201+  target_snapshot?: unknown;
1202+  updated_at?: TimestampLike;
1203+}
1204+
1205+export interface TaskSummary {
1206+  assigned_controller_id?: string | null;
1207+  base_ref?: string | null;
1208+  branch_name?: string | null;
1209+  created_at?: TimestampLike;
1210+  current_step_index?: number | null;
1211+  error_text?: string | null;
1212+  finished_at?: TimestampLike;
1213+  goal?: string | null;
1214+  planner_provider?: string | null;
1215+  planning_strategy?: string | null;
1216+  priority?: number | null;
1217+  repo?: string | null;
1218+  result_summary?: string | null;
1219+  source?: string | null;
1220+  started_at?: TimestampLike;
1221+  status: string;
1222+  target_host?: string | null;
1223+  task_id: string;
1224+  task_type?: string | null;
1225+  title?: string | null;
1226+  updated_at?: TimestampLike;
1227+}
1228+
1229+export interface RunSummary {
1230+  checkpoint_seq?: number | null;
1231+  controller_id?: string | null;
1232+  created_at?: TimestampLike;
1233+  exit_code?: number | null;
1234+  finished_at?: TimestampLike;
1235+  host?: string | null;
1236+  pid?: number | null;
1237+  run_id: string;
1238+  started_at?: TimestampLike;
1239+  status: string;
1240+  step_id?: string | null;
1241+  task_id?: string | null;
1242+  updated_at?: TimestampLike;
1243+  worker_id?: string | null;
1244+}
1245+
1246+export interface CodexStatusData {
1247+  backend?: string | null;
1248+  daemon?: {
1249+    child?: {
1250+      endpoint?: string | null;
1251+      last_error?: string | null;
1252+      pid?: number | null;
1253+      status?: string | null;
1254+      strategy?: string | null;
1255+    };
1256+    daemon_id?: string | null;
1257+    node_id?: string | null;
1258+    started?: boolean | null;
1259+    started_at?: string | null;
1260+    updated_at?: string | null;
1261+    version?: string | null;
1262+  };
1263+  notes?: string[];
1264+  proxy?: {
1265+    route_prefix?: string | null;
1266+    target_base_url?: string | null;
1267+    transport?: string | null;
1268+  };
1269+  recent_events?: {
1270+    count?: number | null;
1271+    updated_at?: string | null;
1272+  };
1273+  routes?: Array<Record<string, unknown>>;
1274+  service?: {
1275+    event_stream_url?: string | null;
1276+    listening?: boolean | null;
1277+    resolved_base_url?: string | null;
1278+    websocket_clients?: number | null;
1279+  };
1280+  sessions?: {
1281+    active_count?: number | null;
1282+    count?: number | null;
1283+    updated_at?: string | null;
1284+  };
1285+  surface?: string[];
1286+}
1287+
1288+export interface CodexSessionSummary {
1289+  childPid?: number | null;
1290+  createdAt?: string | null;
1291+  currentTurnId?: string | null;
1292+  cwd?: string | null;
1293+  endpoint?: string | null;
1294+  lastTurnId?: string | null;
1295+  lastTurnStatus?: string | null;
1296+  metadata?: Record<string, unknown> | null;
1297+  model?: string | null;
1298+  modelProvider?: string | null;
1299+  purpose?: string | null;
1300+  reasoningEffort?: string | null;
1301+  serviceTier?: string | null;
1302+  sessionId: string;
1303+  status?: string | null;
1304+  threadId?: string | null;
1305+  updatedAt?: string | null;
1306+}
1307+
1308+export interface ArtifactSession {
1309+  artifact_url?: string | null;
1310+  conversation_id?: string | null;
1311+  execution_count?: number | null;
1312+  id: string;
1313+  last_activity_at?: TimestampLike;
1314+  message_count?: number | null;
1315+  platform: string;
1316+  started_at?: TimestampLike;
1317+  summary?: string | null;
1318+}
1319+
1320+export interface ArtifactMessage {
1321+  artifact_url?: string | null;
1322+  conversation_id?: string | null;
1323+  id: string;
1324+  observed_at?: TimestampLike;
1325+  platform: string;
1326+  role?: string | null;
1327+  summary?: string | null;
1328+}
1329+
1330+export interface ArtifactExecution {
1331+  artifact_url?: string | null;
1332+  executed_at?: TimestampLike;
1333+  instruction_id: string;
1334+  message_id?: string | null;
1335+  result_ok?: boolean | null;
1336+  result_summary?: string | null;
1337+  target?: string | null;
1338+  tool?: string | null;
1339+}
1340+
1341+export interface ControlWorkspaceSnapshot {
1342+  artifacts: {
1343+    executions: ArtifactExecution[];
1344+    messages: ArtifactMessage[];
1345+    sessions: ArtifactSession[];
1346+  };
1347+  browser: BrowserStatusData | null;
1348+  codex: {
1349+    sessions: CodexSessionSummary[];
1350+    status: CodexStatusData | null;
1351+  };
1352+  errors: Partial<Record<ControlWorkspaceErrorKey, string>>;
1353+  fetched_at: string;
1354+  renewal: {
1355+    conversations: RenewalConversation[];
1356+    jobs: RenewalJob[];
1357+    links: RenewalLink[];
1358+  };
1359+  runs: RunSummary[];
1360+  system: SystemState | null;
1361+  tasks: TaskSummary[];
1362+}
1363+
1364+export interface BrowserActionDispatchResult {
1365+  accepted: boolean;
1366+  action: BrowserActionName;
1367+  client_id?: string | null;
1368+  completed: boolean;
1369+  connection_id?: string | null;
1370+  dispatched_at?: TimestampLike;
1371+  failed: boolean;
1372+  platform?: string | null;
1373+  reason?: string | null;
1374+  request_id?: string | null;
1375+  result?: {
1376+    actual_count?: number | null;
1377+    desired_count?: number | null;
1378+    drift_count?: number | null;
1379+    failed_count?: number | null;
1380+    ok_count?: number | null;
1381+    platform_count?: number | null;
1382+    restored_count?: number | null;
1383+    skipped_reasons?: Record<string, number> | null;
1384+  };
1385+  results?: BrowserActionResultItemSnapshot[];
1386+  shell_runtime?: BrowserShellRuntimeSnapshot[];
1387+  target?: {
1388+    client_id?: string | null;
1389+    connection_id?: string | null;
1390+    platform?: string | null;
1391+    requested_client_id?: string | null;
1392+    requested_platform?: string | null;
1393+  };
1394+  type?: string | null;
1395+}
1396+
1397+export interface BrowserActionRequest {
1398+  action: BrowserActionName;
1399+  clientId?: string;
1400+  platform?: string;
1401+  reason?: string;
1402+}
1403+
1404+interface RenewalConversationListResponse {
1405+  conversations: RenewalConversation[];
1406+}
1407+
1408+interface RenewalLinksListResponse {
1409+  links: RenewalLink[];
1410+}
1411+
1412+interface RenewalJobsListResponse {
1413+  jobs: RenewalJob[];
1414+}
1415+
1416+interface TasksListResponse {
1417+  tasks: TaskSummary[];
1418+}
1419+
1420+interface RunsListResponse {
1421+  runs: RunSummary[];
1422+}
1423+
1424+interface CodexSessionsResponse {
1425+  sessions: CodexSessionSummary[];
1426+}
1427+
1428+interface ArtifactSessionsResponse {
1429+  sessions: ArtifactSession[];
1430+}
1431+
1432+interface ArtifactMessagesResponse {
1433+  messages: ArtifactMessage[];
1434+}
1435+
1436+interface ArtifactExecutionsResponse {
1437+  executions: ArtifactExecution[];
1438+}
1439+
1440+interface SettledResult<T> {
1441+  data: T | null;
1442+  error: string | null;
1443+}
1444+
1445+export async function readControlWorkspaceSnapshot(): Promise<ControlWorkspaceSnapshot> {
1446+  const [
1447+    systemResult,
1448+    browserResult,
1449+    renewalConversationsResult,
1450+    renewalLinksResult,
1451+    renewalJobsResult,
1452+    tasksResult,
1453+    runsResult,
1454+    codexStatusResult,
1455+    codexSessionsResult,
1456+    artifactSessionsResult,
1457+    artifactMessagesResult,
1458+    artifactExecutionsResult
1459+  ] = await Promise.all([
1460+    settle(requestJson<SystemState>("/v1/system/state")),
1461+    settle(requestJson<BrowserStatusData>("/v1/browser")),
1462+    settle(requestJson<RenewalConversationListResponse>("/v1/renewal/conversations?limit=6")),
1463+    settle(requestJson<RenewalLinksListResponse>("/v1/renewal/links?limit=6&active=true")),
1464+    settle(requestJson<RenewalJobsListResponse>("/v1/renewal/jobs?limit=6")),
1465+    settle(requestJson<TasksListResponse>("/v1/tasks?limit=6")),
1466+    settle(requestJson<RunsListResponse>("/v1/runs?limit=6")),
1467+    settle(requestJson<CodexStatusData>("/v1/codex")),
1468+    settle(requestJson<CodexSessionsResponse>("/v1/codex/sessions")),
1469+    settle(requestJson<ArtifactSessionsResponse>("/v1/sessions/latest")),
1470+    settle(requestJson<ArtifactMessagesResponse>("/v1/messages?limit=6")),
1471+    settle(requestJson<ArtifactExecutionsResponse>("/v1/executions?limit=6"))
1472+  ]);
1473+
1474+  return {
1475+    artifacts: {
1476+      executions: artifactExecutionsResult.data?.executions ?? [],
1477+      messages: artifactMessagesResult.data?.messages ?? [],
1478+      sessions: artifactSessionsResult.data?.sessions ?? []
1479+    },
1480+    browser: browserResult.data,
1481+    codex: {
1482+      sessions: codexSessionsResult.data?.sessions ?? [],
1483+      status: codexStatusResult.data
1484+    },
1485+    errors: compactErrors({
1486+      artifactExecutions: artifactExecutionsResult.error,
1487+      artifactMessages: artifactMessagesResult.error,
1488+      artifactSessions: artifactSessionsResult.error,
1489+      browser: browserResult.error,
1490+      codexSessions: codexSessionsResult.error,
1491+      codexStatus: codexStatusResult.error,
1492+      renewalConversations: renewalConversationsResult.error,
1493+      renewalJobs: renewalJobsResult.error,
1494+      renewalLinks: renewalLinksResult.error,
1495+      runs: runsResult.error,
1496+      system: systemResult.error,
1497+      tasks: tasksResult.error
1498+    }),
1499+    fetched_at: new Date().toISOString(),
1500+    renewal: {
1501+      conversations: renewalConversationsResult.data?.conversations ?? [],
1502+      jobs: renewalJobsResult.data?.jobs ?? [],
1503+      links: renewalLinksResult.data?.links ?? []
1504+    },
1505+    runs: runsResult.data?.runs ?? [],
1506+    system: systemResult.data,
1507+    tasks: tasksResult.data?.tasks ?? []
1508+  };
1509+}
1510+
1511+export async function runSystemAction(action: ControlWorkspaceSystemAction): Promise<SystemState> {
1512+  return postJson<SystemState>(`/v1/system/${action}`, {
1513+    reason: `workspace_${action}`,
1514+    requested_by: "conductor_ui",
1515+    source: "conductor_ui"
1516+  });
1517+}
1518+
1519+export async function runBrowserAction(input: BrowserActionRequest): Promise<BrowserActionDispatchResult> {
1520+  return postJson<BrowserActionDispatchResult>("/v1/browser/actions", {
1521+    action: input.action,
1522+    clientId: normalizeOptionalString(input.clientId),
1523+    platform: normalizeOptionalString(input.platform),
1524+    reason: normalizeOptionalString(input.reason)
1525+  });
1526+}
1527+
1528+async function settle<T>(promise: Promise<T>): Promise<SettledResult<T>> {
1529+  try {
1530+    return {
1531+      data: await promise,
1532+      error: null
1533+    };
1534+  } catch (error) {
1535+    return {
1536+      data: null,
1537+      error: error instanceof Error ? error.message : "Request failed."
1538+    };
1539+  }
1540+}
1541+
1542+function compactErrors(
1543+  errors: Partial<Record<ControlWorkspaceErrorKey, string | null>>
1544+): Partial<Record<ControlWorkspaceErrorKey, string>> {
1545+  const nextErrors: Partial<Record<ControlWorkspaceErrorKey, string>> = {};
1546+
1547+  for (const [key, value] of Object.entries(errors) as Array<[ControlWorkspaceErrorKey, string | null]>) {
1548+    if (typeof value === "string" && value !== "") {
1549+      nextErrors[key] = value;
1550+    }
1551+  }
1552+
1553+  return nextErrors;
1554+}
1555+
1556+function normalizeOptionalString(value: string | null | undefined): string | undefined {
1557+  if (typeof value !== "string") {
1558+    return undefined;
1559+  }
1560+
1561+  const normalized = value.trim();
1562+  return normalized === "" ? undefined : normalized;
1563+}
1564diff --git a/apps/conductor-ui/src/api/http.ts b/apps/conductor-ui/src/api/http.ts
1565new file mode 100644
1566index 0000000000000000000000000000000000000000..0b1217d631b7a5ab80d3bc246a960ebc0033ad16
1567--- /dev/null
1568+++ b/apps/conductor-ui/src/api/http.ts
1569@@ -0,0 +1,51 @@
1570+interface ApiEnvelope<T> {
1571+  data: T;
1572+  error?: string;
1573+  message?: string;
1574+  ok: boolean;
1575+  request_id?: string;
1576+}
1577+
1578+export async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T> {
1579+  const response = await fetch(path, {
1580+    ...init,
1581+    credentials: "same-origin",
1582+    headers: {
1583+      accept: "application/json",
1584+      ...(init.headers ?? {})
1585+    }
1586+  });
1587+
1588+  let payload: ApiEnvelope<T>;
1589+
1590+  try {
1591+    payload = (await response.json()) as ApiEnvelope<T>;
1592+  } catch (error) {
1593+    throw new Error(
1594+      error instanceof Error
1595+        ? error.message
1596+        : `Request to ${path} returned a non-JSON response.`
1597+    );
1598+  }
1599+
1600+  if (!response.ok || payload.ok !== true) {
1601+    throw new Error(payload.message ?? payload.error ?? `Request to ${path} failed.`);
1602+  }
1603+
1604+  return payload.data;
1605+}
1606+
1607+export async function postJson<T>(path: string, body?: unknown, init: RequestInit = {}): Promise<T> {
1608+  const headers = new Headers(init.headers ?? {});
1609+
1610+  if (body !== undefined && !headers.has("content-type")) {
1611+    headers.set("content-type", "application/json");
1612+  }
1613+
1614+  return requestJson<T>(path, {
1615+    ...init,
1616+    body: body === undefined ? init.body : JSON.stringify(body),
1617+    headers,
1618+    method: init.method ?? "POST"
1619+  });
1620+}
1621diff --git a/apps/conductor-ui/src/auth/session.ts b/apps/conductor-ui/src/auth/session.ts
1622new file mode 100644
1623index 0000000000000000000000000000000000000000..2a32732a9b2bc8dbb7d4a3c0f9d3670d9beebbf6
1624--- /dev/null
1625+++ b/apps/conductor-ui/src/auth/session.ts
1626@@ -0,0 +1,144 @@
1627+import { reactive, readonly } from "vue";
1628+
1629+export type UiSessionRole = "browser_admin" | "readonly";
1630+
1631+export interface UiSessionSnapshot {
1632+  expires_at: string;
1633+  issued_at: string;
1634+  role: UiSessionRole;
1635+  session_id: string;
1636+  subject: string;
1637+}
1638+
1639+interface UiSessionEnvelope {
1640+  authenticated: boolean;
1641+  available_roles: UiSessionRole[];
1642+  session: UiSessionSnapshot | null;
1643+}
1644+
1645+interface UiSessionResponseEnvelope {
1646+  data: UiSessionEnvelope;
1647+  error?: string;
1648+  message?: string;
1649+  ok: boolean;
1650+}
1651+
1652+interface UiSessionState {
1653+  authenticated: boolean;
1654+  availableRoles: UiSessionRole[];
1655+  initialized: boolean;
1656+  pending: boolean;
1657+  session: UiSessionSnapshot | null;
1658+}
1659+
1660+const state = reactive<UiSessionState>({
1661+  authenticated: false,
1662+  availableRoles: [],
1663+  initialized: false,
1664+  pending: false,
1665+  session: null
1666+});
1667+
1668+let pendingRequest: Promise<UiSessionState> | null = null;
1669+
1670+export const uiSessionState = readonly(state);
1671+
1672+export async function ensureUiSessionLoaded(force = false): Promise<UiSessionState> {
1673+  if (!force && state.initialized) {
1674+    return state;
1675+  }
1676+
1677+  if (pendingRequest) {
1678+    return pendingRequest;
1679+  }
1680+
1681+  pendingRequest = refreshUiSession(force);
1682+
1683+  try {
1684+    return await pendingRequest;
1685+  } finally {
1686+    pendingRequest = null;
1687+  }
1688+}
1689+
1690+export async function loginUiSession(input: {
1691+  password: string;
1692+  role: UiSessionRole;
1693+}): Promise<UiSessionState> {
1694+  return applySessionMutation("/v1/ui/session/login", {
1695+    body: JSON.stringify(input),
1696+    headers: {
1697+      "content-type": "application/json"
1698+    },
1699+    method: "POST"
1700+  });
1701+}
1702+
1703+export async function logoutUiSession(): Promise<UiSessionState> {
1704+  return applySessionMutation("/v1/ui/session/logout", {
1705+    method: "POST"
1706+  });
1707+}
1708+
1709+export function normalizeRedirectTarget(value: unknown): string {
1710+  if (typeof value !== "string") {
1711+    return "/control";
1712+  }
1713+
1714+  if (!value.startsWith("/") || value.startsWith("//")) {
1715+    return "/control";
1716+  }
1717+
1718+  return value;
1719+}
1720+
1721+async function applySessionMutation(path: string, init: RequestInit): Promise<UiSessionState> {
1722+  state.pending = true;
1723+
1724+  try {
1725+    const response = await fetch(path, {
1726+      ...init,
1727+      credentials: "same-origin"
1728+    });
1729+    const payload = (await response.json()) as UiSessionResponseEnvelope;
1730+
1731+    if (!response.ok || payload.ok !== true) {
1732+      throw new Error(payload.message ?? "UI session request failed.");
1733+    }
1734+
1735+    applyEnvelope(payload.data);
1736+    return state;
1737+  } finally {
1738+    state.pending = false;
1739+    state.initialized = true;
1740+  }
1741+}
1742+
1743+async function refreshUiSession(force: boolean): Promise<UiSessionState> {
1744+  if (force || !state.initialized) {
1745+    state.pending = true;
1746+  }
1747+
1748+  try {
1749+    const response = await fetch("/v1/ui/session/me", {
1750+      credentials: "same-origin"
1751+    });
1752+    const payload = (await response.json()) as UiSessionResponseEnvelope;
1753+
1754+    if (payload.ok !== true) {
1755+      throw new Error(payload.message ?? "Unable to read UI session state.");
1756+    }
1757+
1758+    applyEnvelope(payload.data);
1759+    return state;
1760+  } finally {
1761+    state.initialized = true;
1762+    state.pending = false;
1763+  }
1764+}
1765+
1766+function applyEnvelope(envelope: UiSessionEnvelope): void {
1767+  state.authenticated = envelope.authenticated;
1768+  state.availableRoles = envelope.available_roles;
1769+  state.session = envelope.session;
1770+}
1771diff --git a/apps/conductor-ui/src/features/auth/views/LoginView.vue b/apps/conductor-ui/src/features/auth/views/LoginView.vue
1772new file mode 100644
1773index 0000000000000000000000000000000000000000..455f04df7a4717b76b439b7486b7b82fcf5de6c3
1774--- /dev/null
1775+++ b/apps/conductor-ui/src/features/auth/views/LoginView.vue
1776@@ -0,0 +1,124 @@
1777+<template>
1778+  <main class="shell auth-shell">
1779+    <section class="panel auth-panel">
1780+      <div class="auth-copy">
1781+        <p class="eyebrow">BAA Conductor / Session</p>
1782+        <h1>Login To Workspace</h1>
1783+        <p class="lede">
1784+          `/app` 现在通过 `HttpOnly` session cookie 进入工作台,不再把长期 token 暴露给浏览器脚本。
1785+        </p>
1786+      </div>
1787+
1788+      <form class="auth-form" @submit.prevent="handleSubmit">
1789+        <div class="auth-section">
1790+          <p class="section-label">Role</p>
1791+          <div class="role-grid">
1792+            <button
1793+              v-for="option in roleOptions"
1794+              :key="option.role"
1795+              :class="['role-card', selectedRole === option.role ? 'role-card-active' : '']"
1796+              type="button"
1797+              @click="selectedRole = option.role"
1798+            >
1799+              <strong>{{ option.title }}</strong>
1800+              <span>{{ option.copy }}</span>
1801+            </button>
1802+          </div>
1803+        </div>
1804+
1805+        <label class="auth-section auth-field">
1806+          <span class="section-label">Password</span>
1807+          <input
1808+            v-model="password"
1809+            class="auth-input"
1810+            type="password"
1811+            autocomplete="current-password"
1812+            placeholder="Enter session password"
1813+          />
1814+        </label>
1815+
1816+        <p v-if="!hasAvailableRoles" class="auth-note">
1817+          当前 conductor 没有配置任何 UI session 角色。请先设置 `BAA_UI_BROWSER_ADMIN_PASSWORD` 或
1818+          `BAA_UI_READONLY_PASSWORD`。
1819+        </p>
1820+
1821+        <p v-else-if="redirectTarget !== '/control'" class="auth-note">
1822+          登录后将返回 `{{ redirectTarget }}`。
1823+        </p>
1824+
1825+        <p v-if="errorMessage" class="auth-error">{{ errorMessage }}</p>
1826+
1827+        <div class="auth-actions">
1828+          <button class="action-button" type="submit" :disabled="submitDisabled">
1829+            {{ uiSessionState.pending ? "Signing In..." : "Sign In" }}
1830+          </button>
1831+          <span class="auth-hint">Cookie: `HttpOnly` / `SameSite=Lax` / `Path=/`</span>
1832+        </div>
1833+      </form>
1834+    </section>
1835+  </main>
1836+</template>
1837+
1838+<script setup lang="ts">
1839+import { computed, ref, watchEffect } from "vue";
1840+import { useRoute, useRouter } from "vue-router";
1841+
1842+import {
1843+  loginUiSession,
1844+  normalizeRedirectTarget,
1845+  uiSessionState,
1846+  type UiSessionRole
1847+} from "@/auth/session";
1848+
1849+const route = useRoute();
1850+const router = useRouter();
1851+
1852+const password = ref("");
1853+const errorMessage = ref("");
1854+const selectedRole = ref<UiSessionRole>("browser_admin");
1855+
1856+const redirectTarget = computed(() => normalizeRedirectTarget(route.query.redirect));
1857+const hasAvailableRoles = computed(() => uiSessionState.availableRoles.length > 0);
1858+const submitDisabled = computed(() => uiSessionState.pending || !hasAvailableRoles.value || password.value.trim() === "");
1859+const roleOptions = computed(() =>
1860+  uiSessionState.availableRoles.map((role) => ({
1861+    copy:
1862+      role === "browser_admin"
1863+        ? "可进入正式工作台,并执行后续 Control 写操作。"
1864+        : "只读观察角色,后续只显示安全的状态视图。",
1865+    role,
1866+    title: role === "browser_admin" ? "Browser Admin" : "Readonly"
1867+  }))
1868+);
1869+
1870+watchEffect(() => {
1871+  const [firstRole] = uiSessionState.availableRoles;
1872+
1873+  if (!firstRole) {
1874+    return;
1875+  }
1876+
1877+  if (!uiSessionState.availableRoles.includes(selectedRole.value)) {
1878+    selectedRole.value = firstRole;
1879+  }
1880+});
1881+
1882+async function handleSubmit(): Promise<void> {
1883+  if (submitDisabled.value) {
1884+    return;
1885+  }
1886+
1887+  errorMessage.value = "";
1888+
1889+  try {
1890+    await loginUiSession({
1891+      password: password.value,
1892+      role: selectedRole.value
1893+    });
1894+    password.value = "";
1895+    await router.replace(redirectTarget.value);
1896+  } catch (error) {
1897+    errorMessage.value = error instanceof Error ? error.message : "UI session login failed.";
1898+  }
1899+}
1900+</script>
1901diff --git a/apps/conductor-ui/src/features/auth/views/index.ts b/apps/conductor-ui/src/features/auth/views/index.ts
1902new file mode 100644
1903index 0000000000000000000000000000000000000000..dcdf0ee77496d91b6b3c43c7196dc1c3bf9bd064
1904--- /dev/null
1905+++ b/apps/conductor-ui/src/features/auth/views/index.ts
1906@@ -0,0 +1 @@
1907+export { default as LoginView } from "./LoginView.vue";
1908diff --git a/apps/conductor-ui/src/features/control/useControlWorkspace.ts b/apps/conductor-ui/src/features/control/useControlWorkspace.ts
1909new file mode 100644
1910index 0000000000000000000000000000000000000000..ae522c0df3f97e831a803cb153855142a29474bd
1911--- /dev/null
1912+++ b/apps/conductor-ui/src/features/control/useControlWorkspace.ts
1913@@ -0,0 +1,201 @@
1914+import { ref } from "vue";
1915+
1916+import {
1917+  readControlWorkspaceSnapshot,
1918+  runBrowserAction,
1919+  runSystemAction,
1920+  type BrowserActionDispatchResult,
1921+  type BrowserActionRequest,
1922+  type ControlWorkspaceSnapshot,
1923+  type ControlWorkspaceSystemAction,
1924+  type SystemState
1925+} from "@/api/control";
1926+
1927+export interface ControlWorkspaceFeedback {
1928+  at: string;
1929+  label: string;
1930+  message: string;
1931+  payload: unknown;
1932+  status: "error" | "success";
1933+}
1934+
1935+const POLL_INTERVAL_MS = 15_000;
1936+
1937+export function useControlWorkspace() {
1938+  const actionFeedback = ref<ControlWorkspaceFeedback | null>(null);
1939+  const actionPending = ref<string | null>(null);
1940+  const errorMessage = ref("");
1941+  const loading = ref(true);
1942+  const refreshing = ref(false);
1943+  const snapshot = ref<ControlWorkspaceSnapshot | null>(null);
1944+
1945+  let pendingLoad: Promise<void> | null = null;
1946+  let pollHandle: ReturnType<typeof globalThis.setInterval> | null = null;
1947+
1948+  async function load(): Promise<void> {
1949+    if (pendingLoad) {
1950+      return pendingLoad;
1951+    }
1952+
1953+    if (snapshot.value == null) {
1954+      loading.value = true;
1955+    } else {
1956+      refreshing.value = true;
1957+    }
1958+
1959+    pendingLoad = (async () => {
1960+      try {
1961+        const nextSnapshot = await readControlWorkspaceSnapshot();
1962+        snapshot.value = nextSnapshot;
1963+        errorMessage.value = hasUsableData(nextSnapshot)
1964+          ? ""
1965+          : "Control workspace could not load any usable panel data.";
1966+      } catch (error) {
1967+        errorMessage.value = error instanceof Error ? error.message : "Failed to load control workspace.";
1968+      } finally {
1969+        loading.value = false;
1970+        refreshing.value = false;
1971+      }
1972+    })();
1973+
1974+    try {
1975+      await pendingLoad;
1976+    } finally {
1977+      pendingLoad = null;
1978+    }
1979+  }
1980+
1981+  function startPolling(): void {
1982+    if (pollHandle != null) {
1983+      return;
1984+    }
1985+
1986+    pollHandle = globalThis.setInterval(() => {
1987+      if (typeof document !== "undefined" && document.hidden) {
1988+        return;
1989+      }
1990+
1991+      void load();
1992+    }, POLL_INTERVAL_MS);
1993+  }
1994+
1995+  function stopPolling(): void {
1996+    if (pollHandle == null) {
1997+      return;
1998+    }
1999+
2000+    globalThis.clearInterval(pollHandle);
2001+    pollHandle = null;
2002+  }
2003+
2004+  async function refresh(): Promise<void> {
2005+    await load();
2006+  }
2007+
2008+  async function executeSystemAction(action: ControlWorkspaceSystemAction): Promise<SystemState | null> {
2009+    const label = `System ${action}`;
2010+    actionPending.value = `system:${action}`;
2011+
2012+    try {
2013+      const result = await runSystemAction(action);
2014+      actionFeedback.value = {
2015+        at: new Date().toISOString(),
2016+        label,
2017+        message: `Automation mode updated to ${result.mode ?? "unknown"}.`,
2018+        payload: result,
2019+        status: "success"
2020+      };
2021+      await load();
2022+      return result;
2023+    } catch (error) {
2024+      actionFeedback.value = {
2025+        at: new Date().toISOString(),
2026+        label,
2027+        message: error instanceof Error ? error.message : "System action failed.",
2028+        payload: null,
2029+        status: "error"
2030+      };
2031+      return null;
2032+    } finally {
2033+      actionPending.value = null;
2034+    }
2035+  }
2036+
2037+  async function executeBrowserAction(
2038+    input: BrowserActionRequest
2039+  ): Promise<BrowserActionDispatchResult | null> {
2040+    const label = `Browser ${input.action}`;
2041+    actionPending.value = `browser:${input.action}`;
2042+
2043+    try {
2044+      const result = await runBrowserAction(input);
2045+      actionFeedback.value = {
2046+        at: new Date().toISOString(),
2047+        label,
2048+        message: buildBrowserActionMessage(result),
2049+        payload: result,
2050+        status: result.failed ? "error" : "success"
2051+      };
2052+      await load();
2053+      return result;
2054+    } catch (error) {
2055+      actionFeedback.value = {
2056+        at: new Date().toISOString(),
2057+        label,
2058+        message: error instanceof Error ? error.message : "Browser action failed.",
2059+        payload: null,
2060+        status: "error"
2061+      };
2062+      return null;
2063+    } finally {
2064+      actionPending.value = null;
2065+    }
2066+  }
2067+
2068+  return {
2069+    actionFeedback,
2070+    actionPending,
2071+    errorMessage,
2072+    executeBrowserAction,
2073+    executeSystemAction,
2074+    loading,
2075+    refresh,
2076+    refreshing,
2077+    snapshot,
2078+    startPolling,
2079+    stopPolling
2080+  };
2081+}
2082+
2083+function buildBrowserActionMessage(result: BrowserActionDispatchResult): string {
2084+  if (result.failed) {
2085+    return result.reason ?? "Browser action failed.";
2086+  }
2087+
2088+  if (result.completed) {
2089+    return result.reason ?? "Browser action completed.";
2090+  }
2091+
2092+  if (result.accepted) {
2093+    return "Browser action accepted and is waiting for completion.";
2094+  }
2095+
2096+  return "Browser action returned without an explicit completion state.";
2097+}
2098+
2099+function hasUsableData(snapshot: ControlWorkspaceSnapshot): boolean {
2100+  return (
2101+    snapshot.system != null
2102+    || snapshot.browser != null
2103+    || snapshot.renewal.conversations.length > 0
2104+    || snapshot.renewal.links.length > 0
2105+    || snapshot.renewal.jobs.length > 0
2106+    || snapshot.tasks.length > 0
2107+    || snapshot.runs.length > 0
2108+    || snapshot.codex.status != null
2109+    || snapshot.codex.sessions.length > 0
2110+    || snapshot.artifacts.sessions.length > 0
2111+    || snapshot.artifacts.messages.length > 0
2112+    || snapshot.artifacts.executions.length > 0
2113+  );
2114+}
2115diff --git a/apps/conductor-ui/src/features/control/views/ControlWorkspaceView.vue b/apps/conductor-ui/src/features/control/views/ControlWorkspaceView.vue
2116new file mode 100644
2117index 0000000000000000000000000000000000000000..696cf4853531a394eb780f94fc1458d075ed45fb
2118--- /dev/null
2119+++ b/apps/conductor-ui/src/features/control/views/ControlWorkspaceView.vue
2120@@ -0,0 +1,1338 @@
2121+<template>
2122+  <main class="shell">
2123+    <section class="hero panel">
2124+      <div>
2125+        <p class="eyebrow">BAA Conductor / Control</p>
2126+        <h1>Control Workspace</h1>
2127+        <p class="lede">
2128+          正式汇总 `system`、`browser`、`renewal`、`tasks/runs`、`codex` 和 `artifact` 读写面,不再把 Firefox
2129+          插件调试页当作日常入口。
2130+        </p>
2131+      </div>
2132+
2133+      <div class="hero-meta">
2134+        <div :class="['pill', 'status-pill', toneClass(system?.mode)]">
2135+          {{ system?.mode ?? "Unavailable" }}
2136+        </div>
2137+        <div class="pill">{{ roleLabel }}</div>
2138+        <div class="pill">{{ refreshSummary }}</div>
2139+        <button class="ghost-button" type="button" :disabled="workspace.refreshing.value" @click="handleRefresh">
2140+          {{ workspace.refreshing.value ? "Refreshing..." : "Refresh" }}
2141+        </button>
2142+        <button class="ghost-button" type="button" @click="handleLogout">
2143+          {{ uiSessionState.pending ? "Signing Out..." : "Sign Out" }}
2144+        </button>
2145+      </div>
2146+    </section>
2147+
2148+    <p v-if="workspace.errorMessage.value" class="workspace-banner workspace-banner-error">
2149+      {{ workspace.errorMessage.value }}
2150+    </p>
2151+    <p
2152+      v-if="workspace.actionFeedback.value"
2153+      :class="[
2154+        'workspace-banner',
2155+        workspace.actionFeedback.value.status === 'error'
2156+          ? 'workspace-banner-error'
2157+          : 'workspace-banner-success'
2158+      ]"
2159+    >
2160+      <strong>{{ workspace.actionFeedback.value.label }}</strong>
2161+      <span>{{ workspace.actionFeedback.value.message }}</span>
2162+    </p>
2163+
2164+    <section class="workspace-grid">
2165+      <aside class="panel rail control-rail">
2166+        <div class="rail-head">
2167+          <p class="section-label">Workspace</p>
2168+          <p class="rail-title">Control</p>
2169+        </div>
2170+
2171+        <nav class="nav-list" aria-label="workspace sections">
2172+          <a class="nav-item nav-item-active" href="#overview">Overview</a>
2173+          <a class="nav-item" href="#system">System</a>
2174+          <a class="nav-item" href="#browser">Browser</a>
2175+          <a class="nav-item" href="#renewal">Renewal</a>
2176+          <a class="nav-item" href="#tasks">Tasks & Runs</a>
2177+          <a class="nav-item" href="#codex">Codex</a>
2178+          <a class="nav-item" href="#artifacts">Artifacts</a>
2179+          <a class="nav-item" href="#inspector">Inspector</a>
2180+        </nav>
2181+
2182+        <div class="status-box">
2183+          <p class="section-label">Runtime</p>
2184+          <p class="status-title">{{ roleLabel }}</p>
2185+          <p class="status-copy">
2186+            {{ canWrite ? "当前会话允许触发 Control 写动作。" : "当前会话是只读观察角色,所有写动作都会被禁用。" }}
2187+          </p>
2188+          <p class="status-copy">自动轮询:`15s`</p>
2189+        </div>
2190+
2191+        <div class="status-box">
2192+          <p class="section-label">Last Result</p>
2193+          <p class="status-title">{{ workspace.actionFeedback.value?.label ?? "No Action Yet" }}</p>
2194+          <p class="status-copy">
2195+            {{ workspace.actionFeedback.value?.message ?? "系统动作和 browser action 的结果会固定显示在这里。" }}
2196+          </p>
2197+        </div>
2198+      </aside>
2199+
2200+      <section class="content control-content">
2201+        <section id="overview" class="overview-grid">
2202+          <article v-for="metric in overviewMetrics" :key="metric.label" class="panel metric-card">
2203+            <div class="metric-head">
2204+              <p class="section-label">{{ metric.label }}</p>
2205+              <span :class="['status-badge', metric.tone]">{{ metric.tag }}</span>
2206+            </div>
2207+            <p class="metric-value">{{ metric.value }}</p>
2208+            <p class="metric-copy">{{ metric.copy }}</p>
2209+          </article>
2210+        </section>
2211+
2212+        <section id="system" class="panel control-panel">
2213+          <div class="panel-head">
2214+            <div>
2215+              <p class="section-label">System</p>
2216+              <h2>Automation Gate</h2>
2217+            </div>
2218+            <div class="panel-meta">
2219+              <span v-if="systemPanelError" class="inline-note inline-note-error">{{ systemPanelError }}</span>
2220+              <span class="inline-note">
2221+                {{ system?.updated_at ? `Updated ${formatRelativeTime(system.updated_at)}` : "No live payload" }}
2222+              </span>
2223+            </div>
2224+          </div>
2225+
2226+          <div class="detail-grid">
2227+            <article class="detail-card">
2228+              <span class="detail-key">Mode</span>
2229+              <strong>{{ system?.mode ?? "Unavailable" }}</strong>
2230+              <span>{{ systemAutomationCopy }}</span>
2231+            </article>
2232+            <article class="detail-card">
2233+              <span class="detail-key">Leader</span>
2234+              <strong>{{ system?.leader?.controller_id ?? system?.holder_id ?? "n/a" }}</strong>
2235+              <span>
2236+                {{ system?.leader?.host ?? system?.holder_host ?? "No leader host" }}
2237+              </span>
2238+            </article>
2239+            <article class="detail-card">
2240+              <span class="detail-key">Queue</span>
2241+              <strong>{{ system?.queue.queued_tasks ?? 0 }}</strong>
2242+              <span>{{ system?.queue.active_runs ?? 0 }} active runs</span>
2243+            </article>
2244+            <article class="detail-card">
2245+              <span class="detail-key">Lease</span>
2246+              <strong>{{ formatShortId(system?.leader?.controller_id ?? system?.holder_id) }}</strong>
2247+              <span>{{ formatTimestamp(system?.leader?.lease_expires_at ?? system?.lease_expires_at) }}</span>
2248+            </article>
2249+          </div>
2250+
2251+          <div class="action-strip">
2252+            <button
2253+              class="action-button"
2254+              type="button"
2255+              :disabled="!canWrite || Boolean(workspace.actionPending.value)"
2256+              @click="handleSystemAction('pause')"
2257+            >
2258+              {{ workspace.actionPending.value === "system:pause" ? "Pausing..." : "Pause" }}
2259+            </button>
2260+            <button
2261+              class="action-button"
2262+              type="button"
2263+              :disabled="!canWrite || Boolean(workspace.actionPending.value)"
2264+              @click="handleSystemAction('resume')"
2265+            >
2266+              {{ workspace.actionPending.value === "system:resume" ? "Resuming..." : "Resume" }}
2267+            </button>
2268+            <button
2269+              class="action-button"
2270+              type="button"
2271+              :disabled="!canWrite || Boolean(workspace.actionPending.value)"
2272+              @click="handleSystemAction('drain')"
2273+            >
2274+              {{ workspace.actionPending.value === "system:drain" ? "Draining..." : "Drain" }}
2275+            </button>
2276+            <button
2277+              class="ghost-button"
2278+              type="button"
2279+              :disabled="system == null"
2280+              @click="inspectPayload('System state', 'System snapshot', system)"
2281+            >
2282+              Inspect
2283+            </button>
2284+          </div>
2285+
2286+          <p class="inline-note">
2287+            {{
2288+              canWrite
2289+                ? "写动作会等待服务端返回结果,再触发整页刷新。"
2290+                : "Readonly 会话下只保留状态观察;系统写动作在 UI 上已被禁用。"
2291+            }}
2292+          </p>
2293+        </section>
2294+
2295+        <section id="browser" class="panel control-panel">
2296+          <div class="panel-head">
2297+            <div>
2298+              <p class="section-label">Browser</p>
2299+              <h2>Bridge Clients And Runtime</h2>
2300+            </div>
2301+            <div class="panel-meta">
2302+              <span v-if="browserPanelError" class="inline-note inline-note-error">{{ browserPanelError }}</span>
2303+              <span class="inline-note">{{ browserSummaryLine }}</span>
2304+            </div>
2305+          </div>
2306+
2307+          <div class="control-split">
2308+            <section class="subpanel">
2309+              <div class="subpanel-head">
2310+                <strong>Quick Actions</strong>
2311+                <span class="inline-note">Active client is used when `client_id` is empty.</span>
2312+              </div>
2313+
2314+              <div class="form-grid">
2315+                <label class="field">
2316+                  <span class="section-label">Platform</span>
2317+                  <select v-model="browserActionForm.platform" class="auth-input">
2318+                    <option v-for="option in browserPlatforms" :key="option" :value="option">
2319+                      {{ option }}
2320+                    </option>
2321+                  </select>
2322+                </label>
2323+
2324+                <label class="field">
2325+                  <span class="section-label">Client Id</span>
2326+                  <input
2327+                    v-model="browserActionForm.clientId"
2328+                    class="auth-input"
2329+                    type="text"
2330+                    placeholder="Optional explicit browser client"
2331+                  />
2332+                </label>
2333+
2334+                <label class="field field-span">
2335+                  <span class="section-label">Reason</span>
2336+                  <input
2337+                    v-model="browserActionForm.reason"
2338+                    class="auth-input"
2339+                    type="text"
2340+                    placeholder="Optional browser action reason"
2341+                  />
2342+                </label>
2343+              </div>
2344+
2345+              <div class="action-strip">
2346+                <button
2347+                  class="action-button"
2348+                  type="button"
2349+                  :disabled="browserWriteDisabled"
2350+                  @click="handleBrowserAction('tab_open')"
2351+                >
2352+                  {{ workspace.actionPending.value === "browser:tab_open" ? "Opening..." : "Open Tab" }}
2353+                </button>
2354+                <button
2355+                  class="action-button"
2356+                  type="button"
2357+                  :disabled="browserWriteDisabled"
2358+                  @click="handleBrowserAction('tab_focus')"
2359+                >
2360+                  {{ workspace.actionPending.value === "browser:tab_focus" ? "Focusing..." : "Focus Tab" }}
2361+                </button>
2362+                <button
2363+                  class="action-button"
2364+                  type="button"
2365+                  :disabled="browserWriteDisabled"
2366+                  @click="handleBrowserAction('tab_reload')"
2367+                >
2368+                  {{ workspace.actionPending.value === "browser:tab_reload" ? "Reloading..." : "Reload Tab" }}
2369+                </button>
2370+                <button
2371+                  class="action-button"
2372+                  type="button"
2373+                  :disabled="browserWriteDisabled"
2374+                  @click="handleBrowserAction('request_credentials')"
2375+                >
2376+                  {{
2377+                    workspace.actionPending.value === "browser:request_credentials"
2378+                      ? "Requesting..."
2379+                      : "Request Credentials"
2380+                  }}
2381+                </button>
2382+              </div>
2383+
2384+              <div class="token-row">
2385+                <span :class="['status-badge', toneClass(browser?.bridge.status)]">
2386+                  {{ browser?.bridge.status ?? "disconnected" }}
2387+                </span>
2388+                <span class="token-chip">clients {{ browser?.bridge.client_count ?? 0 }}</span>
2389+                <span class="token-chip">fresh {{ browser?.summary.status_counts.fresh ?? 0 }}</span>
2390+                <span class="token-chip">stale {{ browser?.summary.status_counts.stale ?? 0 }}</span>
2391+                <span class="token-chip">lost {{ browser?.summary.status_counts.lost ?? 0 }}</span>
2392+              </div>
2393+
2394+              <div class="detail-list">
2395+                <div class="detail-row">
2396+                  <strong>Current Client</strong>
2397+                  <span>{{ formatShortId(browser?.current_client?.client_id) }}</span>
2398+                </div>
2399+                <div class="detail-row">
2400+                  <strong>Claude Ready</strong>
2401+                  <span>{{ browser?.claude.ready ? "ready" : "not ready" }}</span>
2402+                </div>
2403+                <div class="detail-row">
2404+                  <strong>WS Path</strong>
2405+                  <span>{{ browser?.bridge.ws_path ?? "n/a" }}</span>
2406+                </div>
2407+                <div class="detail-row">
2408+                  <strong>Latest Delivery</strong>
2409+                  <span>{{ formatTimestamp(latestDeliveryCompletedAt) }}</span>
2410+                </div>
2411+              </div>
2412+            </section>
2413+
2414+            <section class="subpanel">
2415+              <div class="subpanel-head">
2416+                <strong>Current Client Snapshot</strong>
2417+                <button
2418+                  class="ghost-button"
2419+                  type="button"
2420+                  :disabled="browser?.current_client == null"
2421+                  @click="inspectPayload('Browser client', 'Current browser client', browser?.current_client)"
2422+                >
2423+                  Inspect
2424+                </button>
2425+              </div>
2426+
2427+              <div v-if="browser?.current_client" class="entity-stack">
2428+                <article
2429+                  v-for="runtime in browser.current_client.shell_runtime"
2430+                  :key="`${browser.current_client.client_id}-${runtime.platform}`"
2431+                  class="entity-card"
2432+                >
2433+                  <div class="entity-head">
2434+                    <strong>{{ runtime.platform }}</strong>
2435+                    <span :class="['status-badge', runtime.drift.aligned ? 'status-badge-positive' : 'status-badge-caution']">
2436+                      {{ runtime.drift.aligned ? "aligned" : "drift" }}
2437+                    </span>
2438+                  </div>
2439+                  <p class="entity-copy">
2440+                    {{ runtime.actual.title ?? runtime.actual.url ?? "No shell page title yet." }}
2441+                  </p>
2442+                  <div class="token-row">
2443+                    <span class="token-chip">actual {{ runtime.actual.exists ? "exists" : "missing" }}</span>
2444+                    <span class="token-chip">desired {{ runtime.desired.exists ? "exists" : "missing" }}</span>
2445+                    <span class="token-chip">status {{ runtime.actual.status ?? "n/a" }}</span>
2446+                  </div>
2447+                </article>
2448+
2449+                <article v-if="browser.current_client.last_action_result" class="entity-card">
2450+                  <div class="entity-head">
2451+                    <strong>Last Action Result</strong>
2452+                    <span :class="['status-badge', browser.current_client.last_action_result.failed ? 'status-badge-danger' : 'status-badge-positive']">
2453+                      {{ browser.current_client.last_action_result.action }}
2454+                    </span>
2455+                  </div>
2456+                  <p class="entity-copy">
2457+                    {{ browser.current_client.last_action_result.reason ?? "Structured bridge response is available." }}
2458+                  </p>
2459+                  <button
2460+                    class="ghost-button"
2461+                    type="button"
2462+                    @click="inspectPayload('Browser action result', 'Current client last action', browser.current_client?.last_action_result)"
2463+                  >
2464+                    Inspect Result
2465+                  </button>
2466+                </article>
2467+              </div>
2468+
2469+              <p v-else class="empty-state">No active Firefox bridge client is currently connected.</p>
2470+            </section>
2471+          </div>
2472+
2473+          <div class="list-grid list-grid-double">
2474+            <section class="subpanel">
2475+              <div class="subpanel-head">
2476+                <strong>Bridge Clients</strong>
2477+                <span class="inline-note">{{ browser?.bridge.clients.length ?? 0 }} clients</span>
2478+              </div>
2479+
2480+              <div v-if="browser?.bridge.clients.length" class="entity-stack">
2481+                <article v-for="client in browser.bridge.clients" :key="client.client_id" class="entity-card">
2482+                  <div class="entity-head">
2483+                    <strong>{{ formatShortId(client.client_id) }}</strong>
2484+                    <span :class="['status-badge', client.client_id === browser.bridge.active_client_id ? 'status-badge-positive' : 'status-badge-neutral']">
2485+                      {{ client.client_id === browser.bridge.active_client_id ? "active" : "connected" }}
2486+                    </span>
2487+                  </div>
2488+                  <p class="entity-copy">
2489+                    {{ client.node_platform ?? "unknown platform" }} / {{ client.node_type ?? "unknown node" }}
2490+                  </p>
2491+                  <div class="token-row">
2492+                    <span
2493+                      v-for="credential in client.credentials"
2494+                      :key="`${client.client_id}-${credential.platform}`"
2495+                      class="token-chip"
2496+                    >
2497+                      {{ credential.platform }}{{ credential.account ? ` / ${credential.account}` : "" }}
2498+                    </span>
2499+                    <span v-if="client.credentials.length === 0" class="token-chip token-chip-muted">
2500+                      no credentials
2501+                    </span>
2502+                  </div>
2503+                  <p class="entity-foot">
2504+                    Connected {{ formatRelativeTime(client.connected_at) }} · Last message
2505+                    {{ formatRelativeTime(client.last_message_at) }}
2506+                  </p>
2507+                  <button
2508+                    class="ghost-button"
2509+                    type="button"
2510+                    @click="inspectPayload('Bridge client', `Bridge client ${client.client_id}`, client)"
2511+                  >
2512+                    Inspect
2513+                  </button>
2514+                </article>
2515+              </div>
2516+
2517+              <p v-else class="empty-state">No bridge clients are connected.</p>
2518+            </section>
2519+
2520+            <section class="subpanel">
2521+              <div class="subpanel-head">
2522+                <strong>Credential Records</strong>
2523+                <span class="inline-note">{{ browser?.records.length ?? 0 }} records</span>
2524+              </div>
2525+
2526+              <div v-if="browser?.records.length" class="entity-stack">
2527+                <article
2528+                  v-for="record in browser.records"
2529+                  :key="`${record.platform}-${record.client_id ?? record.account ?? 'record'}`"
2530+                  class="entity-card"
2531+                >
2532+                  <div class="entity-head">
2533+                    <strong>{{ record.platform }}</strong>
2534+                    <span :class="['status-badge', toneClass(record.status)]">{{ record.status ?? "unknown" }}</span>
2535+                  </div>
2536+                  <p class="entity-copy">
2537+                    {{ record.live?.credentials?.account ?? record.account ?? "No captured account yet." }}
2538+                  </p>
2539+                  <div class="token-row">
2540+                    <span class="token-chip">{{ record.view ?? "mixed" }}</span>
2541+                    <span class="token-chip">{{ record.client_id ?? "no client" }}</span>
2542+                    <span class="token-chip">
2543+                      {{ record.live?.shell_runtime?.drift.aligned === false ? "drift" : "aligned" }}
2544+                    </span>
2545+                  </div>
2546+                  <p class="entity-foot">
2547+                    Captured {{ formatTimestamp(record.persisted?.captured_at ?? record.live?.credentials?.captured_at) }}
2548+                  </p>
2549+                  <button
2550+                    class="ghost-button"
2551+                    type="button"
2552+                    @click="inspectPayload('Browser record', `Browser record ${record.platform}`, record)"
2553+                  >
2554+                    Inspect
2555+                  </button>
2556+                </article>
2557+              </div>
2558+
2559+              <p v-else class="empty-state">No browser credential records are available.</p>
2560+            </section>
2561+          </div>
2562+
2563+          <details class="raw-details">
2564+            <summary>Diagnostics Fallback</summary>
2565+            <div class="raw-grid">
2566+              <article class="detail-card">
2567+                <span class="detail-key">Delivery</span>
2568+                <pre class="raw-pre">{{ formatJson(browser?.delivery ?? null) }}</pre>
2569+              </article>
2570+              <article class="detail-card">
2571+                <span class="detail-key">Instruction Ingest</span>
2572+                <pre class="raw-pre">{{ formatJson(browser?.instruction_ingest ?? null) }}</pre>
2573+              </article>
2574+            </div>
2575+          </details>
2576+        </section>
2577+
2578+        <section id="renewal" class="panel control-panel">
2579+          <div class="panel-head">
2580+            <div>
2581+              <p class="section-label">Renewal</p>
2582+              <h2>Automation Conversations</h2>
2583+            </div>
2584+            <div class="panel-meta">
2585+              <span v-if="renewalPanelError" class="inline-note inline-note-error">{{ renewalPanelError }}</span>
2586+              <span class="inline-note">{{ renewalSummaryLine }}</span>
2587+            </div>
2588+          </div>
2589+
2590+          <div class="list-grid list-grid-triple">
2591+            <section class="subpanel">
2592+              <div class="subpanel-head">
2593+                <strong>Conversations</strong>
2594+                <span class="inline-note">{{ renewalConversations.length }} rows</span>
2595+              </div>
2596+
2597+              <div v-if="renewalConversations.length" class="entity-stack">
2598+                <article
2599+                  v-for="conversation in renewalConversations"
2600+                  :key="conversation.local_conversation_id"
2601+                  class="entity-card"
2602+                >
2603+                  <div class="entity-head">
2604+                    <strong>{{ conversation.title ?? formatShortId(conversation.local_conversation_id) }}</strong>
2605+                    <span :class="['status-badge', toneClass(conversation.automation_status)]">
2606+                      {{ conversation.automation_status }}
2607+                    </span>
2608+                  </div>
2609+                  <p class="entity-copy">
2610+                    {{ conversation.summary ?? conversation.last_error ?? "No summary captured yet." }}
2611+                  </p>
2612+                  <div class="token-row">
2613+                    <span class="token-chip">{{ conversation.platform }}</span>
2614+                    <span class="token-chip">exec {{ conversation.execution_state ?? "idle" }}</span>
2615+                    <span v-if="conversation.pause_reason" class="token-chip token-chip-muted">
2616+                      {{ conversation.pause_reason }}
2617+                    </span>
2618+                  </div>
2619+                  <button
2620+                    class="ghost-button"
2621+                    type="button"
2622+                    @click="inspectPayload('Renewal conversation', `Renewal ${conversation.local_conversation_id}`, conversation)"
2623+                  >
2624+                    Inspect
2625+                  </button>
2626+                </article>
2627+              </div>
2628+
2629+              <p v-else class="empty-state">No renewal conversations matched the current snapshot.</p>
2630+            </section>
2631+
2632+            <section class="subpanel">
2633+              <div class="subpanel-head">
2634+                <strong>Active Links</strong>
2635+                <span class="inline-note">{{ renewalLinks.length }} rows</span>
2636+              </div>
2637+
2638+              <div v-if="renewalLinks.length" class="entity-stack">
2639+                <article v-for="link in renewalLinks" :key="link.link_id" class="entity-card">
2640+                  <div class="entity-head">
2641+                    <strong>{{ link.platform }}</strong>
2642+                    <span :class="['status-badge', link.is_active ? 'status-badge-positive' : 'status-badge-neutral']">
2643+                      {{ link.is_active ? "active" : "inactive" }}
2644+                    </span>
2645+                  </div>
2646+                  <p class="entity-copy">
2647+                    {{ link.page_title ?? link.page_url ?? "No page metadata." }}
2648+                  </p>
2649+                  <div class="token-row">
2650+                    <span class="token-chip">{{ formatShortId(link.local_conversation_id) }}</span>
2651+                    <span class="token-chip">{{ link.target?.kind ?? "no target" }}</span>
2652+                  </div>
2653+                  <button
2654+                    class="ghost-button"
2655+                    type="button"
2656+                    @click="inspectPayload('Renewal link', `Renewal link ${link.link_id}`, link)"
2657+                  >
2658+                    Inspect
2659+                  </button>
2660+                </article>
2661+              </div>
2662+
2663+              <p v-else class="empty-state">No active renewal links are visible.</p>
2664+            </section>
2665+
2666+            <section class="subpanel">
2667+              <div class="subpanel-head">
2668+                <strong>Jobs</strong>
2669+                <span class="inline-note">{{ renewalJobs.length }} rows</span>
2670+              </div>
2671+
2672+              <div v-if="renewalJobs.length" class="entity-stack">
2673+                <article v-for="job in renewalJobs" :key="job.job_id" class="entity-card">
2674+                  <div class="entity-head">
2675+                    <strong>{{ formatShortId(job.job_id) }}</strong>
2676+                    <span :class="['status-badge', toneClass(job.status)]">{{ job.status }}</span>
2677+                  </div>
2678+                  <p class="entity-copy">
2679+                    {{ job.payload_text ?? job.last_error ?? "Structured payload is available in inspector." }}
2680+                  </p>
2681+                  <div class="token-row">
2682+                    <span class="token-chip">attempt {{ job.attempt_count ?? 0 }}/{{ job.max_attempts ?? 0 }}</span>
2683+                    <span class="token-chip">{{ formatShortId(job.local_conversation_id) }}</span>
2684+                  </div>
2685+                  <button
2686+                    class="ghost-button"
2687+                    type="button"
2688+                    @click="inspectPayload('Renewal job', `Renewal job ${job.job_id}`, job)"
2689+                  >
2690+                    Inspect
2691+                  </button>
2692+                </article>
2693+              </div>
2694+
2695+              <p v-else class="empty-state">No renewal jobs matched this snapshot.</p>
2696+            </section>
2697+          </div>
2698+        </section>
2699+
2700+        <section id="tasks" class="panel control-panel">
2701+          <div class="panel-head">
2702+            <div>
2703+              <p class="section-label">Tasks & Runs</p>
2704+              <h2>Execution Queue</h2>
2705+            </div>
2706+            <div class="panel-meta">
2707+              <span v-if="tasksPanelError" class="inline-note inline-note-error">{{ tasksPanelError }}</span>
2708+              <span class="inline-note">{{ tasks.length }} tasks / {{ runs.length }} runs</span>
2709+            </div>
2710+          </div>
2711+
2712+          <div class="list-grid list-grid-double">
2713+            <section class="subpanel">
2714+              <div class="subpanel-head">
2715+                <strong>Tasks</strong>
2716+                <span class="inline-note">Latest control-plane rows</span>
2717+              </div>
2718+
2719+              <div v-if="tasks.length" class="entity-stack">
2720+                <article v-for="task in tasks" :key="task.task_id" class="entity-card">
2721+                  <div class="entity-head">
2722+                    <strong>{{ task.title ?? task.task_id }}</strong>
2723+                    <span :class="['status-badge', toneClass(task.status)]">{{ task.status }}</span>
2724+                  </div>
2725+                  <p class="entity-copy">{{ task.goal ?? task.result_summary ?? "No task summary yet." }}</p>
2726+                  <div class="token-row">
2727+                    <span class="token-chip">{{ task.repo ?? "unknown repo" }}</span>
2728+                    <span class="token-chip">{{ task.branch_name ?? task.base_ref ?? "no branch" }}</span>
2729+                  </div>
2730+                  <button
2731+                    class="ghost-button"
2732+                    type="button"
2733+                    @click="inspectPayload('Task', `Task ${task.task_id}`, task)"
2734+                  >
2735+                    Inspect
2736+                  </button>
2737+                </article>
2738+              </div>
2739+
2740+              <p v-else class="empty-state">No task rows were returned.</p>
2741+            </section>
2742+
2743+            <section class="subpanel">
2744+              <div class="subpanel-head">
2745+                <strong>Runs</strong>
2746+                <span class="inline-note">Latest worker executions</span>
2747+              </div>
2748+
2749+              <div v-if="runs.length" class="entity-stack">
2750+                <article v-for="run in runs" :key="run.run_id" class="entity-card">
2751+                  <div class="entity-head">
2752+                    <strong>{{ run.run_id }}</strong>
2753+                    <span :class="['status-badge', toneClass(run.status)]">{{ run.status }}</span>
2754+                  </div>
2755+                  <p class="entity-copy">
2756+                    Task {{ run.task_id ?? "n/a" }} · worker {{ run.worker_id ?? "n/a" }}
2757+                  </p>
2758+                  <div class="token-row">
2759+                    <span class="token-chip">{{ run.controller_id ?? "no controller" }}</span>
2760+                    <span class="token-chip">pid {{ run.pid ?? "n/a" }}</span>
2761+                  </div>
2762+                  <button
2763+                    class="ghost-button"
2764+                    type="button"
2765+                    @click="inspectPayload('Run', `Run ${run.run_id}`, run)"
2766+                  >
2767+                    Inspect
2768+                  </button>
2769+                </article>
2770+              </div>
2771+
2772+              <p v-else class="empty-state">No run rows were returned.</p>
2773+            </section>
2774+          </div>
2775+        </section>
2776+
2777+        <section id="codex" class="panel control-panel">
2778+          <div class="panel-head">
2779+            <div>
2780+              <p class="section-label">Codex</p>
2781+              <h2>codexd Proxy Surface</h2>
2782+            </div>
2783+            <div class="panel-meta">
2784+              <span v-if="codexPanelError" class="inline-note inline-note-error">{{ codexPanelError }}</span>
2785+              <span class="inline-note">{{ codexSummaryLine }}</span>
2786+            </div>
2787+          </div>
2788+
2789+          <div class="list-grid list-grid-double">
2790+            <section class="subpanel">
2791+              <div class="subpanel-head">
2792+                <strong>Status</strong>
2793+                <button
2794+                  class="ghost-button"
2795+                  type="button"
2796+                  :disabled="codexStatus == null"
2797+                  @click="inspectPayload('Codex status', 'codexd status', codexStatus)"
2798+                >
2799+                  Inspect
2800+                </button>
2801+              </div>
2802+
2803+              <div v-if="codexStatus" class="detail-list">
2804+                <div class="detail-row">
2805+                  <strong>Backend</strong>
2806+                  <span>{{ codexStatus.backend ?? "n/a" }}</span>
2807+                </div>
2808+                <div class="detail-row">
2809+                  <strong>Target</strong>
2810+                  <span>{{ codexStatus.proxy?.target_base_url ?? "n/a" }}</span>
2811+                </div>
2812+                <div class="detail-row">
2813+                  <strong>Active Sessions</strong>
2814+                  <span>{{ codexStatus.sessions?.active_count ?? 0 }}</span>
2815+                </div>
2816+                <div class="detail-row">
2817+                  <strong>Recent Events</strong>
2818+                  <span>{{ codexStatus.recent_events?.count ?? 0 }}</span>
2819+                </div>
2820+              </div>
2821+
2822+              <p v-else class="empty-state">Codex status is unavailable on this conductor.</p>
2823+            </section>
2824+
2825+            <section class="subpanel">
2826+              <div class="subpanel-head">
2827+                <strong>Sessions</strong>
2828+                <span class="inline-note">{{ codexSessions.length }} rows</span>
2829+              </div>
2830+
2831+              <div v-if="codexSessions.length" class="entity-stack">
2832+                <article v-for="session in codexSessions" :key="session.sessionId" class="entity-card">
2833+                  <div class="entity-head">
2834+                    <strong>{{ session.sessionId }}</strong>
2835+                    <span :class="['status-badge', toneClass(session.status)]">{{ session.status ?? "unknown" }}</span>
2836+                  </div>
2837+                  <p class="entity-copy">
2838+                    {{ session.model ?? "unknown model" }} · {{ session.cwd ?? "no cwd" }}
2839+                  </p>
2840+                  <div class="token-row">
2841+                    <span class="token-chip">{{ session.purpose ?? "n/a" }}</span>
2842+                    <span class="token-chip">{{ session.lastTurnStatus ?? "no turns" }}</span>
2843+                  </div>
2844+                  <button
2845+                    class="ghost-button"
2846+                    type="button"
2847+                    @click="inspectPayload('Codex session', `Codex session ${session.sessionId}`, session)"
2848+                  >
2849+                    Inspect
2850+                  </button>
2851+                </article>
2852+              </div>
2853+
2854+              <p v-else class="empty-state">No codex sessions were returned.</p>
2855+            </section>
2856+          </div>
2857+        </section>
2858+
2859+        <section id="artifacts" class="panel control-panel">
2860+          <div class="panel-head">
2861+            <div>
2862+              <p class="section-label">Artifacts</p>
2863+              <h2>Recent Sessions, Messages, Executions</h2>
2864+            </div>
2865+            <div class="panel-meta">
2866+              <span v-if="artifactsPanelError" class="inline-note inline-note-error">{{ artifactsPanelError }}</span>
2867+              <span class="inline-note">
2868+                {{ artifactSessions.length }} sessions / {{ artifactMessages.length }} messages /
2869+                {{ artifactExecutions.length }} executions
2870+              </span>
2871+            </div>
2872+          </div>
2873+
2874+          <div class="list-grid list-grid-triple">
2875+            <section class="subpanel">
2876+              <div class="subpanel-head">
2877+                <strong>Sessions</strong>
2878+                <span class="inline-note">Latest artifact sessions</span>
2879+              </div>
2880+
2881+              <div v-if="artifactSessions.length" class="entity-stack">
2882+                <article v-for="session in artifactSessions" :key="session.id" class="entity-card">
2883+                  <div class="entity-head">
2884+                    <strong>{{ session.platform }}</strong>
2885+                    <span class="status-badge status-badge-neutral">{{ formatShortId(session.id) }}</span>
2886+                  </div>
2887+                  <p class="entity-copy">{{ session.summary ?? "No summary yet." }}</p>
2888+                  <div class="token-row">
2889+                    <span class="token-chip">{{ session.message_count ?? 0 }} messages</span>
2890+                    <span class="token-chip">{{ session.execution_count ?? 0 }} executions</span>
2891+                  </div>
2892+                  <button
2893+                    class="ghost-button"
2894+                    type="button"
2895+                    @click="inspectPayload('Artifact session', `Artifact session ${session.id}`, session)"
2896+                  >
2897+                    Inspect
2898+                  </button>
2899+                </article>
2900+              </div>
2901+
2902+              <p v-else class="empty-state">No recent artifact sessions are available.</p>
2903+            </section>
2904+
2905+            <section class="subpanel">
2906+              <div class="subpanel-head">
2907+                <strong>Messages</strong>
2908+                <span class="inline-note">Latest observed assistant/user messages</span>
2909+              </div>
2910+
2911+              <div v-if="artifactMessages.length" class="entity-stack">
2912+                <article v-for="message in artifactMessages" :key="message.id" class="entity-card">
2913+                  <div class="entity-head">
2914+                    <strong>{{ message.platform }}</strong>
2915+                    <span :class="['status-badge', toneClass(message.role)]">{{ message.role ?? "unknown" }}</span>
2916+                  </div>
2917+                  <p class="entity-copy">{{ message.summary ?? "No message summary." }}</p>
2918+                  <div class="token-row">
2919+                    <span class="token-chip">{{ formatShortId(message.id) }}</span>
2920+                    <span class="token-chip">{{ formatTimestamp(message.observed_at) }}</span>
2921+                  </div>
2922+                  <button
2923+                    class="ghost-button"
2924+                    type="button"
2925+                    @click="inspectPayload('Artifact message', `Artifact message ${message.id}`, message)"
2926+                  >
2927+                    Inspect
2928+                  </button>
2929+                </article>
2930+              </div>
2931+
2932+              <p v-else class="empty-state">No artifact messages are available.</p>
2933+            </section>
2934+
2935+            <section class="subpanel">
2936+              <div class="subpanel-head">
2937+                <strong>Executions</strong>
2938+                <span class="inline-note">Latest tool executions</span>
2939+              </div>
2940+
2941+              <div v-if="artifactExecutions.length" class="entity-stack">
2942+                <article
2943+                  v-for="execution in artifactExecutions"
2944+                  :key="execution.instruction_id"
2945+                  class="entity-card"
2946+                >
2947+                  <div class="entity-head">
2948+                    <strong>{{ execution.target ?? execution.tool ?? "execution" }}</strong>
2949+                    <span :class="['status-badge', execution.result_ok ? 'status-badge-positive' : 'status-badge-danger']">
2950+                      {{ execution.result_ok ? "ok" : "failed" }}
2951+                    </span>
2952+                  </div>
2953+                  <p class="entity-copy">{{ execution.result_summary ?? "No execution summary." }}</p>
2954+                  <div class="token-row">
2955+                    <span class="token-chip">{{ formatShortId(execution.instruction_id) }}</span>
2956+                    <span class="token-chip">{{ execution.tool ?? "no tool" }}</span>
2957+                  </div>
2958+                  <button
2959+                    class="ghost-button"
2960+                    type="button"
2961+                    @click="inspectPayload('Artifact execution', `Artifact execution ${execution.instruction_id}`, execution)"
2962+                  >
2963+                    Inspect
2964+                  </button>
2965+                </article>
2966+              </div>
2967+
2968+              <p v-else class="empty-state">No artifact executions are available.</p>
2969+            </section>
2970+          </div>
2971+        </section>
2972+
2973+        <section id="inspector" class="panel control-panel inspector-panel">
2974+          <div class="panel-head">
2975+            <div>
2976+              <p class="section-label">Inspector</p>
2977+              <h2>Raw Payload Fallback</h2>
2978+            </div>
2979+            <div class="panel-meta">
2980+              <span class="inline-note">{{ inspector?.kind ?? "Nothing selected" }}</span>
2981+            </div>
2982+          </div>
2983+
2984+          <div v-if="inspector" class="inspector-body">
2985+            <div class="detail-list">
2986+              <div class="detail-row">
2987+                <strong>Focus</strong>
2988+                <span>{{ inspector.label }}</span>
2989+              </div>
2990+              <div class="detail-row">
2991+                <strong>Kind</strong>
2992+                <span>{{ inspector.kind }}</span>
2993+              </div>
2994+            </div>
2995+            <pre class="raw-pre raw-pre-large">{{ formatJson(inspector.payload) }}</pre>
2996+          </div>
2997+
2998+          <p v-else class="empty-state">
2999+            选择任意任务、run、browser 记录或动作结果后,这里会显示原始 payload。
3000+          </p>
3001+
3002+          <details class="raw-details">
3003+            <summary>Workspace Snapshot</summary>
3004+            <pre class="raw-pre raw-pre-large">{{ formatJson(snapshot) }}</pre>
3005+          </details>
3006+        </section>
3007+      </section>
3008+    </section>
3009+  </main>
3010+</template>
3011+
3012+<script setup lang="ts">
3013+import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
3014+import { useRouter } from "vue-router";
3015+
3016+import {
3017+  type BrowserActionName,
3018+  type ControlWorkspaceErrorKey,
3019+  type ControlWorkspaceSystemAction,
3020+  type TimestampLike
3021+} from "@/api/control";
3022+import { logoutUiSession, uiSessionState } from "@/auth/session";
3023+import { useControlWorkspace } from "@/features/control/useControlWorkspace";
3024+
3025+interface InspectorEntry {
3026+  kind: string;
3027+  label: string;
3028+  payload: unknown;
3029+}
3030+
3031+const router = useRouter();
3032+const workspace = useControlWorkspace();
3033+const inspector = ref<InspectorEntry | null>(null);
3034+const browserActionForm = reactive({
3035+  clientId: "",
3036+  platform: "claude",
3037+  reason: ""
3038+});
3039+
3040+const snapshot = computed(() => workspace.snapshot.value);
3041+const system = computed(() => snapshot.value?.system ?? null);
3042+const browser = computed(() => snapshot.value?.browser ?? null);
3043+const renewalConversations = computed(() => snapshot.value?.renewal.conversations ?? []);
3044+const renewalLinks = computed(() => snapshot.value?.renewal.links ?? []);
3045+const renewalJobs = computed(() => snapshot.value?.renewal.jobs ?? []);
3046+const tasks = computed(() => snapshot.value?.tasks ?? []);
3047+const runs = computed(() => snapshot.value?.runs ?? []);
3048+const codexStatus = computed(() => snapshot.value?.codex.status ?? null);
3049+const codexSessions = computed(() => snapshot.value?.codex.sessions ?? []);
3050+const artifactSessions = computed(() => snapshot.value?.artifacts.sessions ?? []);
3051+const artifactMessages = computed(() => snapshot.value?.artifacts.messages ?? []);
3052+const artifactExecutions = computed(() => snapshot.value?.artifacts.executions ?? []);
3053+
3054+const canWrite = computed(() => uiSessionState.session?.role === "browser_admin");
3055+const roleLabel = computed(() => canWrite.value ? "Browser Admin" : "Readonly");
3056+const refreshSummary = computed(() => {
3057+  if (workspace.loading.value) {
3058+    return "Loading...";
3059+  }
3060+
3061+  if (workspace.refreshing.value) {
3062+    return "Refreshing...";
3063+  }
3064+
3065+  return snapshot.value?.fetched_at
3066+    ? `Updated ${formatRelativeTime(snapshot.value.fetched_at)}`
3067+    : "Not loaded";
3068+});
3069+const browserPlatforms = computed(() => {
3070+  const platforms = new Set<string>(["claude", "chatgpt", "gemini"]);
3071+
3072+  for (const record of browser.value?.records ?? []) {
3073+    platforms.add(record.platform);
3074+  }
3075+
3076+  for (const client of browser.value?.bridge.clients ?? []) {
3077+    for (const runtime of client.shell_runtime) {
3078+      platforms.add(runtime.platform);
3079+    }
3080+  }
3081+
3082+  return [...platforms];
3083+});
3084+const browserWriteDisabled = computed(() => !canWrite.value || Boolean(workspace.actionPending.value));
3085+const latestDeliveryCompletedAt = computed<TimestampLike>(() => {
3086+  const lastSession = browser.value?.delivery.last_session;
3087+
3088+  if (lastSession == null || typeof lastSession !== "object") {
3089+    return null;
3090+  }
3091+
3092+  return "completed_at" in lastSession ? (lastSession.completed_at as TimestampLike) : null;
3093+});
3094+
3095+const overviewMetrics = computed(() => {
3096+  const browserStatusCounts = browser.value?.summary.status_counts;
3097+  const autoCount = renewalConversations.value.filter((entry) => entry.automation_status === "auto").length;
3098+  const pausedCount = renewalConversations.value.filter((entry) => entry.automation_status === "paused").length;
3099+  const activeCodexCount =
3100+    codexStatus.value?.sessions?.active_count
3101+    ?? codexSessions.value.filter((entry) => entry.status === "active").length;
3102+
3103+  return [
3104+    {
3105+      copy: systemAutomationCopy.value,
3106+      label: "Mode",
3107+      tag: system.value?.automation.mode ?? system.value?.mode ?? "unknown",
3108+      tone: toneClass(system.value?.mode),
3109+      value: system.value?.mode ?? "Unavailable"
3110+    },
3111+    {
3112+      copy: formatTimestamp(system.value?.leader?.lease_expires_at ?? system.value?.lease_expires_at),
3113+      label: "Leader",
3114+      tag: system.value?.leader?.status ?? "idle",
3115+      tone: toneClass(system.value?.leader?.status),
3116+      value: system.value?.leader?.controller_id ?? system.value?.holder_id ?? "No Leader"
3117+    },
3118+    {
3119+      copy: `${system.value?.queue.active_runs ?? 0} active runs`,
3120+      label: "Queue",
3121+      tag: "tasks",
3122+      tone: "status-badge-neutral",
3123+      value: String(system.value?.queue.queued_tasks ?? 0)
3124+    },
3125+    {
3126+      copy: `${browser.value?.bridge.client_count ?? 0} connected clients`,
3127+      label: "Browser Health",
3128+      tag: "fresh/stale/lost",
3129+      tone: toneClass(browser.value?.bridge.status),
3130+      value: `${browserStatusCounts?.fresh ?? 0}/${browserStatusCounts?.stale ?? 0}/${browserStatusCounts?.lost ?? 0}`
3131+    },
3132+    {
3133+      copy: `${autoCount} auto · ${pausedCount} paused`,
3134+      label: "Renewal",
3135+      tag: "conversations",
3136+      tone: "status-badge-neutral",
3137+      value: String(renewalConversations.value.length)
3138+    },
3139+    {
3140+      copy: codexStatus.value?.proxy?.target_base_url ?? "codexd not available",
3141+      label: "Codex",
3142+      tag: codexStatus.value?.backend ?? "unavailable",
3143+      tone: toneClass(codexSessions.value.length > 0 ? "active" : codexStatus.value == null ? "lost" : "ready"),
3144+      value: String(activeCodexCount ?? 0)
3145+    }
3146+  ];
3147+});
3148+
3149+const systemAutomationCopy = computed(() => {
3150+  const automation = system.value?.automation;
3151+
3152+  if (!automation) {
3153+    return "Automation metadata is unavailable.";
3154+  }
3155+
3156+  const parts = [
3157+    automation.requested_by ? `requested_by ${automation.requested_by}` : null,
3158+    automation.reason ? `reason ${automation.reason}` : null,
3159+    automation.source ? `source ${automation.source}` : null
3160+  ].filter((entry): entry is string => entry != null);
3161+
3162+  return parts.length > 0 ? parts.join(" · ") : "No explicit system mutation metadata.";
3163+});
3164+
3165+const browserSummaryLine = computed(() => {
3166+  const payload = browser.value;
3167+
3168+  if (!payload) {
3169+    return "Browser status unavailable";
3170+  }
3171+
3172+  return `${payload.bridge.client_count} clients · ${payload.summary.runtime_counts.actual} actual runtimes · `
3173+    + `${payload.summary.runtime_counts.drift} drift`;
3174+});
3175+
3176+const renewalSummaryLine = computed(() => {
3177+  const autoCount = renewalConversations.value.filter((entry) => entry.automation_status === "auto").length;
3178+  const manualCount = renewalConversations.value.filter((entry) => entry.automation_status === "manual").length;
3179+  const pausedCount = renewalConversations.value.filter((entry) => entry.automation_status === "paused").length;
3180+
3181+  return `${autoCount} auto · ${manualCount} manual · ${pausedCount} paused`;
3182+});
3183+
3184+const codexSummaryLine = computed(() => {
3185+  if (codexStatus.value == null && codexSessions.value.length === 0) {
3186+    return "No codexd surface available";
3187+  }
3188+
3189+  const activeCount =
3190+    codexStatus.value?.sessions?.active_count
3191+    ?? codexSessions.value.filter((entry) => entry.status === "active").length;
3192+
3193+  return `${activeCount} active sessions · ${codexStatus.value?.recent_events?.count ?? 0} recent events`;
3194+});
3195+
3196+const systemPanelError = computed(() => panelError(["system"]));
3197+const browserPanelError = computed(() => panelError(["browser"]));
3198+const renewalPanelError = computed(() => panelError(["renewalConversations", "renewalLinks", "renewalJobs"]));
3199+const tasksPanelError = computed(() => panelError(["tasks", "runs"]));
3200+const codexPanelError = computed(() => panelError(["codexStatus", "codexSessions"]));
3201+const artifactsPanelError = computed(() => panelError(["artifactSessions", "artifactMessages", "artifactExecutions"]));
3202+
3203+watch(browserPlatforms, (options) => {
3204+  if (options.length === 0) {
3205+    browserActionForm.platform = "claude";
3206+    return;
3207+  }
3208+
3209+  if (!options.includes(browserActionForm.platform)) {
3210+    browserActionForm.platform = options[0] ?? "claude";
3211+  }
3212+}, {
3213+  immediate: true
3214+});
3215+
3216+watch(snapshot, (nextSnapshot) => {
3217+  if (nextSnapshot == null || inspector.value != null) {
3218+    return;
3219+  }
3220+
3221+  inspector.value = {
3222+    kind: "System state",
3223+    label: "System snapshot",
3224+    payload: nextSnapshot.system ?? nextSnapshot
3225+  };
3226+}, {
3227+  immediate: true
3228+});
3229+
3230+watch(() => workspace.actionFeedback.value, (feedback) => {
3231+  if (feedback?.payload == null) {
3232+    return;
3233+  }
3234+
3235+  inspector.value = {
3236+    kind: feedback.label,
3237+    label: feedback.label,
3238+    payload: feedback.payload
3239+  };
3240+});
3241+
3242+onMounted(() => {
3243+  void workspace.refresh();
3244+  workspace.startPolling();
3245+});
3246+
3247+onBeforeUnmount(() => {
3248+  workspace.stopPolling();
3249+});
3250+
3251+async function handleLogout(): Promise<void> {
3252+  if (uiSessionState.pending) {
3253+    return;
3254+  }
3255+
3256+  await logoutUiSession();
3257+  await router.replace("/login");
3258+}
3259+
3260+async function handleRefresh(): Promise<void> {
3261+  await workspace.refresh();
3262+}
3263+
3264+async function handleSystemAction(action: ControlWorkspaceSystemAction): Promise<void> {
3265+  if (!canWrite.value || workspace.actionPending.value) {
3266+    return;
3267+  }
3268+
3269+  const result = await workspace.executeSystemAction(action);
3270+
3271+  if (result != null) {
3272+    inspectPayload("System action", `System ${action}`, result);
3273+  }
3274+}
3275+
3276+async function handleBrowserAction(action: BrowserActionName): Promise<void> {
3277+  if (!canWrite.value || workspace.actionPending.value) {
3278+    return;
3279+  }
3280+
3281+  if (
3282+    (action === "request_credentials" || action === "tab_focus" || action === "tab_open")
3283+    && browserActionForm.platform.trim() === ""
3284+  ) {
3285+    workspace.actionFeedback.value = {
3286+      at: new Date().toISOString(),
3287+      label: `Browser ${action}`,
3288+      message: `Browser action "${action}" requires a platform.`,
3289+      payload: null,
3290+      status: "error"
3291+    };
3292+    return;
3293+  }
3294+
3295+  const result = await workspace.executeBrowserAction({
3296+    action,
3297+    clientId: browserActionForm.clientId,
3298+    platform: browserActionForm.platform,
3299+    reason: browserActionForm.reason
3300+  });
3301+
3302+  if (result != null) {
3303+    inspectPayload("Browser action", `Browser ${action}`, result);
3304+  }
3305+}
3306+
3307+function inspectPayload(kind: string, label: string, payload: unknown): void {
3308+  inspector.value = {
3309+    kind,
3310+    label,
3311+    payload
3312+  };
3313+}
3314+
3315+function panelError(keys: ControlWorkspaceErrorKey[]): string | null {
3316+  const errors = snapshot.value?.errors ?? {};
3317+  const messages = keys
3318+    .map((key) => errors[key])
3319+    .filter((entry): entry is string => typeof entry === "string" && entry.trim() !== "");
3320+
3321+  return messages.length > 0 ? messages.join(" / ") : null;
3322+}
3323+
3324+function toneClass(value: string | null | undefined): string {
3325+  const normalized = value?.toLowerCase();
3326+
3327+  if (
3328+    normalized === "active"
3329+    || normalized === "auto"
3330+    || normalized === "browser_admin"
3331+    || normalized === "completed"
3332+    || normalized === "connected"
3333+    || normalized === "fresh"
3334+    || normalized === "ok"
3335+    || normalized === "ready"
3336+    || normalized === "resume"
3337+    || normalized === "running"
3338+    || normalized === "success"
3339+  ) {
3340+    return "status-badge-positive";
3341+  }
3342+
3343+  if (
3344+    normalized === "draining"
3345+    || normalized === "manual"
3346+    || normalized === "paused"
3347+    || normalized === "stale"
3348+    || normalized === "warning"
3349+  ) {
3350+    return "status-badge-caution";
3351+  }
3352+
3353+  if (
3354+    normalized === "error"
3355+    || normalized === "failed"
3356+    || normalized === "lost"
3357+    || normalized === "not ready"
3358+    || normalized === "unavailable"
3359+  ) {
3360+    return "status-badge-danger";
3361+  }
3362+
3363+  return "status-badge-neutral";
3364+}
3365+
3366+function formatShortId(value: string | null | undefined): string {
3367+  if (typeof value !== "string" || value.trim() === "") {
3368+    return "n/a";
3369+  }
3370+
3371+  return value.length <= 18 ? value : `${value.slice(0, 8)}…${value.slice(-4)}`;
3372+}
3373+
3374+function formatTimestamp(value: TimestampLike): string {
3375+  const date = toDate(value);
3376+
3377+  if (date == null) {
3378+    return "n/a";
3379+  }
3380+
3381+  return timestampFormatter.format(date);
3382+}
3383+
3384+function formatRelativeTime(value: TimestampLike): string {
3385+  const date = toDate(value);
3386+
3387+  if (date == null) {
3388+    return "n/a";
3389+  }
3390+
3391+  const deltaMs = Date.now() - date.getTime();
3392+  const deltaSeconds = Math.round(deltaMs / 1000);
3393+  const absoluteSeconds = Math.abs(deltaSeconds);
3394+
3395+  if (absoluteSeconds < 60) {
3396+    return `${absoluteSeconds}s ago`;
3397+  }
3398+
3399+  if (absoluteSeconds < 3600) {
3400+    return `${Math.round(absoluteSeconds / 60)}m ago`;
3401+  }
3402+
3403+  if (absoluteSeconds < 86_400) {
3404+    return `${Math.round(absoluteSeconds / 3600)}h ago`;
3405+  }
3406+
3407+  return `${Math.round(absoluteSeconds / 86_400)}d ago`;
3408+}
3409+
3410+function formatJson(value: unknown): string {
3411+  if (value == null) {
3412+    return "null";
3413+  }
3414+
3415+  try {
3416+    return JSON.stringify(value, null, 2);
3417+  } catch {
3418+    return String(value);
3419+  }
3420+}
3421+
3422+function toDate(value: TimestampLike): Date | null {
3423+  if (typeof value === "number" && Number.isFinite(value)) {
3424+    const milliseconds = value > 10_000_000_000 ? value : value * 1000;
3425+    return safeDate(milliseconds);
3426+  }
3427+
3428+  if (typeof value === "string") {
3429+    const trimmed = value.trim();
3430+
3431+    if (trimmed === "") {
3432+      return null;
3433+    }
3434+
3435+    const numericValue = Number(trimmed);
3436+
3437+    if (Number.isFinite(numericValue)) {
3438+      return toDate(numericValue);
3439+    }
3440+
3441+    return safeDate(trimmed);
3442+  }
3443+
3444+  return null;
3445+}
3446+
3447+function safeDate(value: number | string): Date | null {
3448+  const date = new Date(value);
3449+  return Number.isNaN(date.getTime()) ? null : date;
3450+}
3451+
3452+const timestampFormatter = new Intl.DateTimeFormat(undefined, {
3453+  day: "2-digit",
3454+  hour: "2-digit",
3455+  minute: "2-digit",
3456+  month: "short"
3457+});
3458+</script>
3459diff --git a/apps/conductor-ui/src/features/control/views/index.ts b/apps/conductor-ui/src/features/control/views/index.ts
3460new file mode 100644
3461index 0000000000000000000000000000000000000000..4ecb99fe82782af27a4a3d86d280a21e24284a27
3462--- /dev/null
3463+++ b/apps/conductor-ui/src/features/control/views/index.ts
3464@@ -0,0 +1 @@
3465+export { default as ControlWorkspaceView } from "./ControlWorkspaceView.vue";
3466diff --git a/apps/conductor-ui/src/features/system/views/NotFoundView.vue b/apps/conductor-ui/src/features/system/views/NotFoundView.vue
3467new file mode 100644
3468index 0000000000000000000000000000000000000000..c2a267dc9f08f7baf81178e3bedecca3388b7d32
3469--- /dev/null
3470+++ b/apps/conductor-ui/src/features/system/views/NotFoundView.vue
3471@@ -0,0 +1,12 @@
3472+<template>
3473+  <main class="shell not-found-shell">
3474+    <section class="panel not-found-panel">
3475+      <p class="eyebrow">BAA Conductor / App Route</p>
3476+      <h1>Unknown Workspace Route</h1>
3477+      <p class="lede">
3478+        当前 `/app` 仅接入了最小工作台壳。未知路径会保留在同一个前端 shell 内,避免落回 API 404。
3479+      </p>
3480+      <a class="back-link" href="/app/control">返回 Control</a>
3481+    </section>
3482+  </main>
3483+</template>
3484diff --git a/apps/conductor-ui/src/features/system/views/index.ts b/apps/conductor-ui/src/features/system/views/index.ts
3485new file mode 100644
3486index 0000000000000000000000000000000000000000..2655ecbe0e58233afa1d872fc67094c62a1fd79a
3487--- /dev/null
3488+++ b/apps/conductor-ui/src/features/system/views/index.ts
3489@@ -0,0 +1 @@
3490+export { default as NotFoundView } from "./NotFoundView.vue";
3491diff --git a/apps/conductor-ui/src/main.ts b/apps/conductor-ui/src/main.ts
3492index de370f30f625d260c8d695de6ae00b8089a21b27..56bb71b83faa00bf3516a8e44cd0d9c422b4b421 100644
3493--- a/apps/conductor-ui/src/main.ts
3494+++ b/apps/conductor-ui/src/main.ts
3495@@ -1,7 +1,7 @@
3496 import { createApp } from "vue";
3497 
3498 import App from "./App.vue";
3499-import router from "./routes";
3500-import "./style.css";
3501+import { router } from "./routes";
3502+import "./styles/base.css";
3503 
3504 createApp(App).use(router).mount("#app");
3505diff --git a/apps/conductor-ui/src/routes/index.ts b/apps/conductor-ui/src/routes/index.ts
3506index ab6707085940fcf02581c96d965f53dfe3cc29bb..f7f7011c23aa9a9c11b0ab26d199a4728611958a 100644
3507--- a/apps/conductor-ui/src/routes/index.ts
3508+++ b/apps/conductor-ui/src/routes/index.ts
3509@@ -1,41 +1,66 @@
3510 import { createRouter, createWebHistory } from "vue-router";
3511 
3512-import WorkbenchLayout from "../layouts/WorkbenchLayout.vue";
3513-import ChannelsPlaceholderView from "../views/ChannelsPlaceholderView.vue";
3514-import ControlOverviewView from "../views/ControlOverviewView.vue";
3515-import LoginPlaceholderView from "../views/LoginPlaceholderView.vue";
3516+import {
3517+  ensureUiSessionLoaded,
3518+  normalizeRedirectTarget,
3519+  uiSessionState
3520+} from "@/auth/session";
3521+import { LoginView } from "@/features/auth/views";
3522+import { ControlWorkspaceView } from "@/features/control/views";
3523+import { NotFoundView } from "@/features/system/views";
3524 
3525 const router = createRouter({
3526   history: createWebHistory("/app/"),
3527   routes: [
3528     {
3529       path: "/",
3530-      component: WorkbenchLayout,
3531-      children: [
3532-        {
3533-          path: "",
3534-          redirect: {
3535-            name: "control"
3536-          }
3537-        },
3538-        {
3539-          path: "control",
3540-          name: "control",
3541-          component: ControlOverviewView
3542-        },
3543-        {
3544-          path: "channels",
3545-          name: "channels",
3546-          component: ChannelsPlaceholderView
3547-        },
3548-        {
3549-          path: "login",
3550-          name: "login",
3551-          component: LoginPlaceholderView
3552-        }
3553-      ]
3554+      redirect: "/control"
3555+    },
3556+    {
3557+      path: "/login",
3558+      component: LoginView,
3559+      meta: {
3560+        guestOnly: true
3561+      }
3562+    },
3563+    {
3564+      path: "/control",
3565+      component: ControlWorkspaceView,
3566+      meta: {
3567+        requiresSession: true
3568+      }
3569+    },
3570+    {
3571+      path: "/:pathMatch(.*)*",
3572+      component: NotFoundView,
3573+      meta: {
3574+        requiresSession: true
3575+      }
3576     }
3577-  ]
3578+  ],
3579+  scrollBehavior() {
3580+    return { left: 0, top: 0 };
3581+  }
3582+});
3583+
3584+router.beforeEach(async (to) => {
3585+  await ensureUiSessionLoaded();
3586+
3587+  if (to.meta.requiresSession === true && !uiSessionState.authenticated) {
3588+    return {
3589+      path: "/login",
3590+      query: {
3591+        redirect: to.fullPath
3592+      }
3593+    };
3594+  }
3595+
3596+  if (to.meta.guestOnly === true && uiSessionState.authenticated) {
3597+    return normalizeRedirectTarget(to.query.redirect);
3598+  }
3599+
3600+  return true;
3601 });
3602 
3603+export { router };
3604 export default router;
3605diff --git a/apps/conductor-ui/src/styles/base.css b/apps/conductor-ui/src/styles/base.css
3606new file mode 100644
3607index 0000000000000000000000000000000000000000..63f4c07998fec3ccba85d77f217a8221b043b46c
3608--- /dev/null
3609+++ b/apps/conductor-ui/src/styles/base.css
3610@@ -0,0 +1,793 @@
3611+:root {
3612+  color-scheme: light;
3613+  --bg: #efe5d1;
3614+  --bg-deep: #dcc7a2;
3615+  --panel: rgba(255, 250, 241, 0.84);
3616+  --panel-strong: rgba(255, 247, 233, 0.95);
3617+  --ink: #1d1811;
3618+  --muted: #645847;
3619+  --line: rgba(29, 24, 17, 0.12);
3620+  --accent: #0a6c74;
3621+  --accent-soft: rgba(10, 108, 116, 0.12);
3622+  --warn: #a0551b;
3623+  --shadow: 0 24px 60px rgba(78, 54, 28, 0.14);
3624+}
3625+
3626+* {
3627+  box-sizing: border-box;
3628+}
3629+
3630+html,
3631+body,
3632+#app {
3633+  min-height: 100%;
3634+}
3635+
3636+body {
3637+  margin: 0;
3638+  color: var(--ink);
3639+  background:
3640+    radial-gradient(circle at top left, rgba(255, 255, 255, 0.55), transparent 34%),
3641+    radial-gradient(circle at bottom right, rgba(10, 108, 116, 0.12), transparent 26%),
3642+    linear-gradient(135deg, var(--bg), var(--bg-deep));
3643+  font-family: "Iowan Old Style", "Palatino Linotype", "Book Antiqua", Georgia, serif;
3644+}
3645+
3646+body::before {
3647+  content: "";
3648+  position: fixed;
3649+  inset: 0;
3650+  pointer-events: none;
3651+  background-image:
3652+    linear-gradient(rgba(29, 24, 17, 0.03) 1px, transparent 1px),
3653+    linear-gradient(90deg, rgba(29, 24, 17, 0.03) 1px, transparent 1px);
3654+  background-size: 28px 28px;
3655+  mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 0.78), transparent 88%);
3656+}
3657+
3658+a {
3659+  color: inherit;
3660+}
3661+
3662+.app-root {
3663+  min-height: 100vh;
3664+}
3665+
3666+.shell {
3667+  position: relative;
3668+  width: min(1220px, calc(100% - 32px));
3669+  margin: 0 auto;
3670+  padding: 32px 0 48px;
3671+}
3672+
3673+.panel {
3674+  border: 1px solid var(--line);
3675+  border-radius: 24px;
3676+  background: var(--panel);
3677+  box-shadow: var(--shadow);
3678+  backdrop-filter: blur(14px);
3679+}
3680+
3681+.hero {
3682+  display: flex;
3683+  justify-content: space-between;
3684+  gap: 20px;
3685+  padding: 28px;
3686+}
3687+
3688+.eyebrow,
3689+.section-label,
3690+.pill,
3691+.status-copy,
3692+.nav-item,
3693+.roadmap-tag {
3694+  font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, monospace;
3695+}
3696+
3697+.eyebrow,
3698+.section-label {
3699+  margin: 0 0 10px;
3700+  font-size: 12px;
3701+  letter-spacing: 0.16em;
3702+  text-transform: uppercase;
3703+  color: var(--muted);
3704+}
3705+
3706+h1,
3707+h2,
3708+p {
3709+  margin: 0;
3710+}
3711+
3712+h1 {
3713+  max-width: 11ch;
3714+  font-size: clamp(34px, 5vw, 60px);
3715+  line-height: 0.96;
3716+}
3717+
3718+h2 {
3719+  font-size: 24px;
3720+  line-height: 1.1;
3721+}
3722+
3723+.lede {
3724+  margin-top: 16px;
3725+  max-width: 58ch;
3726+  color: var(--muted);
3727+  font-size: 18px;
3728+  line-height: 1.6;
3729+}
3730+
3731+.hero-meta {
3732+  display: flex;
3733+  flex-wrap: wrap;
3734+  gap: 10px;
3735+  align-content: flex-start;
3736+  justify-content: flex-end;
3737+}
3738+
3739+.pill {
3740+  display: inline-flex;
3741+  align-items: center;
3742+  min-height: 40px;
3743+  padding: 0 14px;
3744+  border-radius: 999px;
3745+  border: 1px solid var(--line);
3746+  background: var(--panel-strong);
3747+  font-size: 13px;
3748+}
3749+
3750+.pill-live {
3751+  color: var(--accent);
3752+}
3753+
3754+.pill-muted {
3755+  color: var(--warn);
3756+}
3757+
3758+.ghost-button,
3759+.action-button,
3760+.role-card,
3761+.auth-input {
3762+  font: inherit;
3763+}
3764+
3765+.ghost-button,
3766+.action-button,
3767+.role-card,
3768+.back-link {
3769+  transition:
3770+    transform 160ms ease,
3771+    border-color 160ms ease,
3772+    background-color 160ms ease;
3773+}
3774+
3775+.ghost-button,
3776+.action-button {
3777+  display: inline-flex;
3778+  align-items: center;
3779+  justify-content: center;
3780+  min-height: 40px;
3781+  padding: 0 16px;
3782+  border-radius: 999px;
3783+  border: 1px solid var(--line);
3784+  background: var(--panel-strong);
3785+  color: var(--ink);
3786+  cursor: pointer;
3787+}
3788+
3789+.action-button {
3790+  background: linear-gradient(145deg, rgba(10, 108, 116, 0.18), rgba(255, 255, 255, 0.92));
3791+  color: var(--accent);
3792+}
3793+
3794+.ghost-button:hover,
3795+.action-button:hover,
3796+.role-card:hover,
3797+.back-link:hover {
3798+  transform: translateY(-1px);
3799+  border-color: rgba(10, 108, 116, 0.32);
3800+}
3801+
3802+.ghost-button:disabled,
3803+.action-button:disabled {
3804+  cursor: default;
3805+  opacity: 0.6;
3806+  transform: none;
3807+}
3808+
3809+.workspace-grid {
3810+  display: grid;
3811+  grid-template-columns: 280px minmax(0, 1fr);
3812+  gap: 18px;
3813+  margin-top: 18px;
3814+}
3815+
3816+.rail {
3817+  display: flex;
3818+  flex-direction: column;
3819+  gap: 18px;
3820+  padding: 22px 18px;
3821+}
3822+
3823+.rail-head {
3824+  padding-bottom: 4px;
3825+  border-bottom: 1px solid var(--line);
3826+}
3827+
3828+.rail-title,
3829+.status-title {
3830+  font-size: 28px;
3831+  line-height: 1;
3832+}
3833+
3834+.nav-list {
3835+  display: flex;
3836+  flex-direction: column;
3837+  gap: 8px;
3838+}
3839+
3840+.nav-item {
3841+  display: block;
3842+  padding: 12px 14px;
3843+  border-radius: 16px;
3844+  border: 1px solid var(--line);
3845+  background: rgba(255, 255, 255, 0.36);
3846+  text-decoration: none;
3847+  font-size: 13px;
3848+}
3849+
3850+.nav-item-active {
3851+  background: linear-gradient(160deg, var(--accent-soft), rgba(255, 255, 255, 0.72));
3852+  color: var(--accent);
3853+}
3854+
3855+.nav-item-disabled {
3856+  opacity: 0.55;
3857+}
3858+
3859+.status-box {
3860+  display: grid;
3861+  gap: 10px;
3862+  padding: 18px;
3863+  border-radius: 20px;
3864+  background: linear-gradient(180deg, rgba(255, 255, 255, 0.52), rgba(255, 247, 233, 0.92));
3865+  border: 1px solid var(--line);
3866+}
3867+
3868+.status-copy {
3869+  color: var(--muted);
3870+  font-size: 13px;
3871+  line-height: 1.7;
3872+}
3873+
3874+.content {
3875+  display: grid;
3876+  gap: 18px;
3877+}
3878+
3879+.control-content {
3880+  align-content: start;
3881+}
3882+
3883+.workspace-banner,
3884+.inline-note,
3885+.token-chip,
3886+.detail-key,
3887+.status-badge,
3888+.entity-foot,
3889+.metric-copy,
3890+.empty-state,
3891+.raw-details summary,
3892+.raw-pre {
3893+  font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, monospace;
3894+}
3895+
3896+.workspace-banner {
3897+  display: flex;
3898+  flex-wrap: wrap;
3899+  gap: 10px;
3900+  margin: 18px 0 0;
3901+  padding: 14px 18px;
3902+  border-radius: 18px;
3903+  border: 1px solid var(--line);
3904+  background: var(--panel-strong);
3905+  line-height: 1.7;
3906+}
3907+
3908+.workspace-banner strong {
3909+  font-size: 13px;
3910+}
3911+
3912+.workspace-banner span {
3913+  color: var(--muted);
3914+  font-size: 13px;
3915+}
3916+
3917+.workspace-banner-success {
3918+  border-color: rgba(10, 108, 116, 0.24);
3919+}
3920+
3921+.workspace-banner-error {
3922+  border-color: rgba(160, 85, 27, 0.28);
3923+  color: var(--warn);
3924+}
3925+
3926+.overview-grid {
3927+  display: grid;
3928+  grid-template-columns: repeat(3, minmax(0, 1fr));
3929+  gap: 18px;
3930+}
3931+
3932+.metric-card {
3933+  display: grid;
3934+  gap: 14px;
3935+  padding: 22px;
3936+}
3937+
3938+.metric-head,
3939+.panel-head,
3940+.subpanel-head,
3941+.entity-head,
3942+.detail-row {
3943+  display: flex;
3944+  justify-content: space-between;
3945+  gap: 16px;
3946+  align-items: flex-start;
3947+}
3948+
3949+.metric-value {
3950+  font-size: clamp(28px, 4vw, 42px);
3951+  line-height: 0.95;
3952+}
3953+
3954+.metric-copy {
3955+  color: var(--muted);
3956+  font-size: 12px;
3957+  line-height: 1.7;
3958+}
3959+
3960+.control-panel {
3961+  display: grid;
3962+  gap: 18px;
3963+  padding: 24px;
3964+}
3965+
3966+.panel-head {
3967+  padding-bottom: 18px;
3968+  border-bottom: 1px solid rgba(29, 24, 17, 0.08);
3969+}
3970+
3971+.panel-meta {
3972+  display: flex;
3973+  flex-wrap: wrap;
3974+  gap: 10px;
3975+  justify-content: flex-end;
3976+}
3977+
3978+.inline-note {
3979+  color: var(--muted);
3980+  font-size: 12px;
3981+  line-height: 1.6;
3982+}
3983+
3984+.inline-note-error {
3985+  color: var(--warn);
3986+}
3987+
3988+.detail-grid {
3989+  display: grid;
3990+  grid-template-columns: repeat(4, minmax(0, 1fr));
3991+  gap: 14px;
3992+}
3993+
3994+.detail-card,
3995+.subpanel {
3996+  display: grid;
3997+  gap: 12px;
3998+  padding: 18px;
3999+  border-radius: 20px;
4000+  border: 1px solid var(--line);
4001+  background: linear-gradient(180deg, rgba(255, 255, 255, 0.54), rgba(255, 247, 233, 0.94));
4002+}
4003+
4004+.detail-card strong,
4005+.subpanel strong {
4006+  font-size: 18px;
4007+  line-height: 1.2;
4008+}
4009+
4010+.detail-card span:last-child {
4011+  color: var(--muted);
4012+  line-height: 1.6;
4013+}
4014+
4015+.detail-key {
4016+  color: var(--muted);
4017+  font-size: 11px;
4018+  letter-spacing: 0.12em;
4019+  text-transform: uppercase;
4020+}
4021+
4022+.action-strip {
4023+  display: flex;
4024+  flex-wrap: wrap;
4025+  gap: 12px;
4026+}
4027+
4028+.control-split,
4029+.list-grid {
4030+  display: grid;
4031+  gap: 18px;
4032+}
4033+
4034+.control-split,
4035+.list-grid-double {
4036+  grid-template-columns: repeat(2, minmax(0, 1fr));
4037+}
4038+
4039+.list-grid-triple {
4040+  grid-template-columns: repeat(3, minmax(0, 1fr));
4041+}
4042+
4043+.form-grid {
4044+  display: grid;
4045+  grid-template-columns: repeat(2, minmax(0, 1fr));
4046+  gap: 14px;
4047+}
4048+
4049+.field {
4050+  display: grid;
4051+  gap: 8px;
4052+}
4053+
4054+.field-span {
4055+  grid-column: 1 / -1;
4056+}
4057+
4058+.token-row {
4059+  display: flex;
4060+  flex-wrap: wrap;
4061+  gap: 8px;
4062+}
4063+
4064+.token-chip,
4065+.status-badge {
4066+  display: inline-flex;
4067+  align-items: center;
4068+  min-height: 30px;
4069+  padding: 0 10px;
4070+  border-radius: 999px;
4071+  border: 1px solid var(--line);
4072+  background: rgba(255, 255, 255, 0.72);
4073+  font-size: 12px;
4074+}
4075+
4076+.status-badge-positive {
4077+  color: var(--accent);
4078+  background: rgba(10, 108, 116, 0.12);
4079+}
4080+
4081+.status-badge-caution {
4082+  color: var(--warn);
4083+  background: rgba(160, 85, 27, 0.12);
4084+}
4085+
4086+.status-badge-danger {
4087+  color: #8b2c1e;
4088+  background: rgba(139, 44, 30, 0.11);
4089+}
4090+
4091+.status-badge-neutral {
4092+  color: var(--muted);
4093+}
4094+
4095+.token-chip-muted {
4096+  color: var(--muted);
4097+  opacity: 0.9;
4098+}
4099+
4100+.detail-list,
4101+.entity-stack,
4102+.inspector-body {
4103+  display: grid;
4104+  gap: 12px;
4105+}
4106+
4107+.detail-row {
4108+  padding-bottom: 12px;
4109+  border-bottom: 1px solid rgba(29, 24, 17, 0.08);
4110+}
4111+
4112+.detail-row:last-child {
4113+  padding-bottom: 0;
4114+  border-bottom: none;
4115+}
4116+
4117+.detail-row strong {
4118+  font-size: 13px;
4119+}
4120+
4121+.detail-row span {
4122+  color: var(--muted);
4123+  text-align: right;
4124+  line-height: 1.6;
4125+}
4126+
4127+.entity-card {
4128+  display: grid;
4129+  gap: 10px;
4130+  padding: 16px;
4131+  border-radius: 18px;
4132+  border: 1px solid rgba(29, 24, 17, 0.08);
4133+  background: rgba(255, 255, 255, 0.52);
4134+}
4135+
4136+.entity-head strong,
4137+.subpanel-head strong {
4138+  font-size: 16px;
4139+}
4140+
4141+.entity-copy,
4142+.empty-state {
4143+  color: var(--muted);
4144+  line-height: 1.65;
4145+}
4146+
4147+.entity-copy {
4148+  min-height: 42px;
4149+}
4150+
4151+.entity-foot,
4152+.empty-state {
4153+  font-size: 12px;
4154+}
4155+
4156+.raw-details {
4157+  border-top: 1px solid rgba(29, 24, 17, 0.08);
4158+  padding-top: 16px;
4159+}
4160+
4161+.raw-details summary {
4162+  cursor: pointer;
4163+  color: var(--muted);
4164+  font-size: 12px;
4165+}
4166+
4167+.raw-grid {
4168+  display: grid;
4169+  grid-template-columns: repeat(2, minmax(0, 1fr));
4170+  gap: 14px;
4171+  margin-top: 14px;
4172+}
4173+
4174+.raw-pre {
4175+  margin: 0;
4176+  padding: 14px;
4177+  overflow: auto;
4178+  border-radius: 16px;
4179+  background: rgba(29, 24, 17, 0.92);
4180+  color: #f4e9d7;
4181+  font-size: 12px;
4182+  line-height: 1.7;
4183+}
4184+
4185+.raw-pre-large {
4186+  max-height: 480px;
4187+}
4188+
4189+.inspector-panel {
4190+  margin-bottom: 8px;
4191+}
4192+
4193+.status-pill {
4194+  text-transform: capitalize;
4195+}
4196+
4197+.boot-overlay {
4198+  position: fixed;
4199+  inset: 0;
4200+  display: grid;
4201+  place-items: center;
4202+  padding: 20px;
4203+  background: rgba(239, 229, 209, 0.55);
4204+  backdrop-filter: blur(8px);
4205+}
4206+
4207+.boot-panel {
4208+  width: min(440px, 100%);
4209+  padding: 24px;
4210+}
4211+
4212+.boot-copy,
4213+.auth-note,
4214+.auth-hint,
4215+.auth-error,
4216+.role-card span {
4217+  font-family: "IBM Plex Mono", "SFMono-Regular", Menlo, monospace;
4218+}
4219+
4220+.boot-copy {
4221+  color: var(--muted);
4222+}
4223+
4224+.auth-shell {
4225+  display: grid;
4226+  place-items: center;
4227+  min-height: 100vh;
4228+}
4229+
4230+.auth-panel {
4231+  display: grid;
4232+  grid-template-columns: minmax(0, 1.1fr) minmax(320px, 0.9fr);
4233+  gap: 22px;
4234+  width: min(1040px, 100%);
4235+  padding: 28px;
4236+}
4237+
4238+.auth-copy {
4239+  display: grid;
4240+  align-content: start;
4241+}
4242+
4243+.auth-form {
4244+  display: grid;
4245+  gap: 18px;
4246+  align-content: start;
4247+}
4248+
4249+.auth-section {
4250+  display: grid;
4251+  gap: 10px;
4252+}
4253+
4254+.auth-field {
4255+  align-content: start;
4256+}
4257+
4258+.role-grid {
4259+  display: grid;
4260+  gap: 12px;
4261+  grid-template-columns: repeat(2, minmax(0, 1fr));
4262+}
4263+
4264+.role-card {
4265+  display: grid;
4266+  gap: 8px;
4267+  padding: 16px;
4268+  text-align: left;
4269+  border-radius: 18px;
4270+  border: 1px solid var(--line);
4271+  background: rgba(255, 255, 255, 0.42);
4272+  cursor: pointer;
4273+}
4274+
4275+.role-card strong {
4276+  font-size: 18px;
4277+}
4278+
4279+.role-card span {
4280+  color: var(--muted);
4281+  font-size: 12px;
4282+  line-height: 1.6;
4283+}
4284+
4285+.role-card-active {
4286+  border-color: rgba(10, 108, 116, 0.32);
4287+  background: linear-gradient(160deg, var(--accent-soft), rgba(255, 255, 255, 0.88));
4288+}
4289+
4290+.auth-input {
4291+  width: 100%;
4292+  min-height: 48px;
4293+  padding: 0 14px;
4294+  border-radius: 16px;
4295+  border: 1px solid var(--line);
4296+  background: rgba(255, 255, 255, 0.7);
4297+  color: var(--ink);
4298+}
4299+
4300+.auth-input:focus {
4301+  outline: 2px solid rgba(10, 108, 116, 0.18);
4302+  outline-offset: 1px;
4303+}
4304+
4305+.auth-note,
4306+.auth-hint {
4307+  color: var(--muted);
4308+  font-size: 12px;
4309+  line-height: 1.7;
4310+}
4311+
4312+.auth-error {
4313+  color: var(--warn);
4314+  font-size: 12px;
4315+  line-height: 1.7;
4316+}
4317+
4318+.auth-actions {
4319+  display: flex;
4320+  flex-wrap: wrap;
4321+  gap: 12px;
4322+  align-items: center;
4323+}
4324+
4325+.not-found-shell {
4326+  display: grid;
4327+  place-items: center;
4328+  min-height: 100vh;
4329+}
4330+
4331+.not-found-panel {
4332+  width: min(720px, calc(100% - 32px));
4333+  padding: 28px;
4334+}
4335+
4336+.back-link {
4337+  display: inline-flex;
4338+  align-items: center;
4339+  justify-content: center;
4340+  min-height: 38px;
4341+  padding: 0 14px;
4342+  border: 1px solid var(--line);
4343+  border-radius: 999px;
4344+  background: var(--panel-strong);
4345+  text-decoration: none;
4346+  margin-top: 20px;
4347+  width: fit-content;
4348+}
4349+
4350+@media (max-width: 960px) {
4351+  .auth-panel,
4352+  .workspace-grid,
4353+  .overview-grid,
4354+  .control-split,
4355+  .list-grid-double,
4356+  .list-grid-triple,
4357+  .raw-grid,
4358+  .detail-grid {
4359+    grid-template-columns: 1fr;
4360+  }
4361+
4362+  .panel-head,
4363+  .metric-head,
4364+  .subpanel-head,
4365+  .entity-head,
4366+  .detail-row {
4367+    flex-direction: column;
4368+  }
4369+
4370+  .panel-meta {
4371+    justify-content: flex-start;
4372+  }
4373+
4374+  .role-grid {
4375+    grid-template-columns: 1fr;
4376+  }
4377+}
4378+
4379+@media (max-width: 720px) {
4380+  .shell {
4381+    width: min(100% - 20px, 1220px);
4382+    padding-top: 20px;
4383+    padding-bottom: 28px;
4384+  }
4385+
4386+  .hero {
4387+    flex-direction: column;
4388+  }
4389+
4390+  .hero-meta {
4391+    justify-content: flex-start;
4392+  }
4393+
4394+  .form-grid {
4395+    grid-template-columns: 1fr;
4396+  }
4397+
4398+  .control-panel,
4399+  .subpanel,
4400+  .detail-card {
4401+    padding: 18px;
4402+  }
4403+}
4404diff --git a/apps/conductor-ui/tsconfig.json b/apps/conductor-ui/tsconfig.json
4405index c24b466cf7a7b7f2137a772e61f8f215ed1bcc1c..2c79f1ec147b91d380d62e51ac5c7d1551ca2540 100644
4406--- a/apps/conductor-ui/tsconfig.json
4407+++ b/apps/conductor-ui/tsconfig.json
4408@@ -1,11 +1,20 @@
4409 {
4410   "extends": "../../tsconfig.base.json",
4411   "compilerOptions": {
4412-    "lib": ["ES2022", "DOM", "DOM.Iterable"],
4413+    "baseUrl": ".",
4414+    "target": "ES2022",
4415     "module": "ESNext",
4416     "moduleResolution": "Bundler",
4417+    "lib": ["ES2022", "DOM", "DOM.Iterable"],
4418+    "paths": {
4419+      "@/*": ["src/*"]
4420+    },
4421+    "types": ["vite/client"],
4422     "jsx": "preserve",
4423-    "types": ["vite/client"]
4424+    "isolatedModules": true,
4425+    "useDefineForClassFields": true,
4426+    "allowImportingTsExtensions": true,
4427+    "noEmit": true
4428   },
4429-  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue"]
4430+  "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts"]
4431 }
4432diff --git a/apps/conductor-ui/vite.config.ts b/apps/conductor-ui/vite.config.ts
4433index ca238eab7a41f2a53f18727635ff4b44fc628327..15d57f5735cb8562bb00be6a6e74cc88255f91e6 100644
4434--- a/apps/conductor-ui/vite.config.ts
4435+++ b/apps/conductor-ui/vite.config.ts
4436@@ -1,24 +1,36 @@
4437-import { defineConfig } from "vite";
4438+import { resolve } from "node:path";
4439+import { defineConfig, loadEnv } from "vite";
4440 import vue from "@vitejs/plugin-vue";
4441 
4442-const CONDUCTOR_API_TARGET = "http://100.71.210.78:4317";
4443+export default defineConfig(({ mode }) => {
4444+  const env = loadEnv(mode, process.cwd(), "");
4445+  const apiTarget = env.BAA_UI_API_PROXY_TARGET || "http://100.71.210.78:4317";
4446 
4447-export default defineConfig({
4448-  base: "/app/",
4449-  plugins: [vue()],
4450-  server: {
4451-    host: "127.0.0.1",
4452-    port: 4318,
4453-    proxy: {
4454-      "/artifact": CONDUCTOR_API_TARGET,
4455-      "/describe": CONDUCTOR_API_TARGET,
4456-      "/health": CONDUCTOR_API_TARGET,
4457-      "/robots.txt": CONDUCTOR_API_TARGET,
4458-      "/v1": CONDUCTOR_API_TARGET,
4459-      "/version": CONDUCTOR_API_TARGET
4460+  return {
4461+    base: "/app/",
4462+    plugins: [vue()],
4463+    resolve: {
4464+      alias: {
4465+        "@": resolve(__dirname, "src")
4466+      }
4467+    },
4468+    server: {
4469+      host: "127.0.0.1",
4470+      port: 4318,
4471+      proxy: {
4472+        "/artifact": apiTarget,
4473+        "/describe": apiTarget,
4474+        "/health": apiTarget,
4475+        "/healthz": apiTarget,
4476+        "/readyz": apiTarget,
4477+        "/robots.txt": apiTarget,
4478+        "/rolez": apiTarget,
4479+        "/v1": apiTarget,
4480+        "/version": apiTarget
4481+      }
4482+    },
4483+    build: {
4484+      outDir: "dist"
4485     }
4486-  },
4487-  build: {
4488-    outDir: "dist"
4489-  }
4490+  };
4491 });
4492diff --git a/docs/auth/README.md b/docs/auth/README.md
4493index 6e81f0bf48f5067afcd09106bead2d3828606837..ba244f5248912184e11576e0e6510df7fc153d0b 100644
4494--- a/docs/auth/README.md
4495+++ b/docs/auth/README.md
4496@@ -41,6 +41,38 @@
4497 | `browser_session` | 当前可用 | `browser_admin`、`readonly` | 浏览器侧 bearer token,承载控制面板和只读面板 |
4498 | `ops_session` | 当前可用 | `ops_admin` | 与浏览器控制面板分离的运维 token |
4499 
4500+## 当前已落地的 UI session MVP
4501+
4502+`T-S071` 已经把浏览器工作台的最小 session 闭环接进 `conductor-daemon`:
4503+
4504+- `POST /v1/ui/session/login`
4505+- `POST /v1/ui/session/logout`
4506+- `GET /v1/ui/session/me`
4507+
4508+当前实现边界:
4509+
4510+- 介质:`HttpOnly` cookie
4511+- cookie 属性:`SameSite=Lax`、`Path=/`,在 `https` 下自动加 `Secure`
4512+- session 存储:当前进程内内存表,重启后失效
4513+- 角色:`browser_admin`、`readonly`
4514+
4515+当前环境变量:
4516+
4517+- `BAA_UI_BROWSER_ADMIN_PASSWORD`
4518+- `BAA_UI_READONLY_PASSWORD`
4519+- `BAA_UI_SESSION_TTL_SEC`
4520+
4521+当前 `login` 接口是单用户最小模型:
4522+
4523+- 请求体:`{ "role": "browser_admin" | "readonly", "password": "..." }`
4524+- 不向前端暴露长期 bearer token
4525+- `me` 始终返回当前会话状态和 `available_roles`,供 `/app/login` 与前端路由守卫使用
4526+
4527+当前未覆盖的范围:
4528+
4529+- session 不持久化到数据库
4530+- 现有大部分 `/v1/*` 业务接口仍保留原有访问边界,后续需要把 `browser_session` 逐步接到真正的读写授权中间层
4531+
4532 统一 claim 形状建议包含:
4533 
4534 - `subject`
4535diff --git a/packages/auth/package.json b/packages/auth/package.json
4536index fbbda198b11fab124b27bf18d4385e69d9f33ed2..b0fd9e5a61b6727b0c8d5da9f2808a03730f59b6 100644
4537--- a/packages/auth/package.json
4538+++ b/packages/auth/package.json
4539@@ -2,12 +2,13 @@
4540   "name": "@baa-conductor/auth",
4541   "private": true,
4542   "type": "module",
4543+  "main": "dist/index.js",
4544   "exports": {
4545-    ".": "./src/index.ts"
4546+    ".": "./dist/index.js"
4547   },
4548-  "types": "./src/index.ts",
4549+  "types": "dist/index.d.ts",
4550   "scripts": {
4551-    "build": "pnpm exec tsc --noEmit -p tsconfig.json",
4552+    "build": "pnpm exec tsc -p tsconfig.json",
4553     "typecheck": "pnpm exec tsc --noEmit -p tsconfig.json"
4554   }
4555 }
4556diff --git a/packages/auth/src/browser-session.ts b/packages/auth/src/browser-session.ts
4557new file mode 100644
4558index 0000000000000000000000000000000000000000..c993ced0890e9a26bc64c64782677a286d908b9c
4559--- /dev/null
4560+++ b/packages/auth/src/browser-session.ts
4561@@ -0,0 +1,32 @@
4562+import {
4563+  DEFAULT_AUTH_AUDIENCE,
4564+  type AuthPrincipal
4565+} from "./model.js";
4566+
4567+export const BROWSER_SESSION_ROLES = ["browser_admin", "readonly"] as const;
4568+
4569+export type BrowserSessionRole = (typeof BROWSER_SESSION_ROLES)[number];
4570+
4571+export interface BrowserSessionDescriptor {
4572+  expiresAt: string;
4573+  issuedAt: string;
4574+  role: BrowserSessionRole;
4575+  sessionId: string;
4576+  subject: string;
4577+}
4578+
4579+export function isBrowserSessionRole(value: string): value is BrowserSessionRole {
4580+  return value === "browser_admin" || value === "readonly";
4581+}
4582+
4583+export function createBrowserSessionPrincipal(descriptor: BrowserSessionDescriptor): AuthPrincipal {
4584+  return {
4585+    audience: DEFAULT_AUTH_AUDIENCE,
4586+    expiresAt: descriptor.expiresAt,
4587+    issuedAt: descriptor.issuedAt,
4588+    role: descriptor.role,
4589+    sessionId: descriptor.sessionId,
4590+    subject: descriptor.subject,
4591+    tokenKind: "browser_session"
4592+  };
4593+}
4594diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts
4595index db16e9b9b8d3d7e7233b669514d7f84eacb12953..6fee61442d2a530f48f5e085967c5d95319f399b 100644
4596--- a/packages/auth/src/index.ts
4597+++ b/packages/auth/src/index.ts
4598@@ -1,4 +1,5 @@
4599 export * from "./actions.js";
4600+export * from "./browser-session.js";
4601 export * from "./control-api.js";
4602 export * from "./model.js";
4603 export * from "./policy.js";
4604diff --git a/packages/auth/tsconfig.json b/packages/auth/tsconfig.json
4605index 0f94510416da351efd842201cc52ecc4f5f5885f..666ad9248331f9f9852a3dd51bf0d58b088fdc18 100644
4606--- a/packages/auth/tsconfig.json
4607+++ b/packages/auth/tsconfig.json
4608@@ -1,9 +1,9 @@
4609 {
4610   "extends": "../../tsconfig.base.json",
4611   "compilerOptions": {
4612+    "declaration": true,
4613     "rootDir": "src",
4614     "outDir": "dist"
4615   },
4616   "include": ["src/**/*.ts"]
4617 }
4618-
4619diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
4620index 19be29bc356734af753e96691031962ad5a2c22c..14461e923d49c94f5cc3faa4a91d46889997c95d 100644
4621--- a/pnpm-lock.yaml
4622+++ b/pnpm-lock.yaml
4623@@ -21,6 +21,9 @@ importers:
4624       '@baa-conductor/artifact-db':
4625         specifier: workspace:*
4626         version: link:../../packages/artifact-db
4627+      '@baa-conductor/auth':
4628+        specifier: workspace:*
4629+        version: link:../../packages/auth
4630       '@baa-conductor/d1-client':
4631         specifier: workspace:*
4632         version: link:../../packages/d1-client
4633diff --git a/tasks/T-S071.md b/tasks/T-S071.md
4634index 9edaa9be1b5965727df6eea25c4476356cd220a5..5afb5e716032e2886f3f3ccd53a23be91f546a45 100644
4635--- a/tasks/T-S071.md
4636+++ b/tasks/T-S071.md
4637@@ -2,7 +2,7 @@
4638 
4639 ## 状态
4640 
4641-- 当前状态:`待开始`
4642+- 当前状态:`已完成`
4643 - 规模预估:`M`
4644 - 依赖任务:`T-S070`
4645 - 建议执行者:`Codex`(涉及 HTTP 鉴权边界、session cookie 和前后端联动)
4646@@ -21,7 +21,7 @@
4647 
4648 - 仓库:`/Users/george/code/baa-conductor`
4649 - 分支基线:`main`
4650-- 提交:`b063524`
4651+- 提交:`ebbf8ac`
4652 
4653 ## 分支与 worktree(强制)
4654 
4655@@ -147,21 +147,41 @@
4656 
4657 ### 开始执行
4658 
4659-- 执行者:
4660-- 开始时间:
4661+- 执行者:`Codex`
4662+- 开始时间:`2026-04-02 00:24:22 CST`
4663 - 状态变更:`待开始` → `进行中`
4664 
4665 ### 完成摘要
4666 
4667-- 完成时间:
4668+- 完成时间:`2026-04-02 00:40:40 CST`
4669 - 状态变更:`进行中` → `已完成`
4670 - 修改了哪些文件:
4671+  - `packages/auth/`:补 `dist` 构建输出和浏览器 session 角色 helper
4672+  - `apps/conductor-daemon/src/ui-session.ts`:新增内存 session manager、cookie 生成与 principal 映射
4673+  - `apps/conductor-daemon/src/local-api.ts`:新增 `/v1/ui/session/login`、`/v1/ui/session/logout`、`/v1/ui/session/me`
4674+  - `apps/conductor-daemon/src/index.ts`:接入 UI session 配置、runtime warning 和 HTTP request context
4675+  - `apps/conductor-ui/src/auth/`、`apps/conductor-ui/src/features/auth/`:新增前端 session store 与 `/app/login`
4676+  - `apps/conductor-ui/src/routes/index.ts`:新增登录页和路由守卫
4677+  - `apps/conductor-ui/src/features/control/views/ControlWorkspaceView.vue`:接入登录态展示和登出按钮
4678+  - `apps/conductor-daemon/src/index.test.js`:补 `/app/login` 与 UI session cookie 闭环测试
4679+  - `docs/auth/README.md`:补 UI session MVP 合同与环境变量说明
4680 - 核心实现思路:
4681+  - 服务端使用进程内 session 表和 `HttpOnly` cookie 承载 `browser_admin` / `readonly`
4682+  - `me` 始终返回 `authenticated`、`available_roles`、`session`,作为登录页和前端守卫的统一合同
4683+  - `/app/login` 通过 Vue router guest route 进入,`/app/control` 和未知工作台路由统一走前端 session guard
4684+  - 登录凭据来自环境变量 `BAA_UI_BROWSER_ADMIN_PASSWORD`、`BAA_UI_READONLY_PASSWORD`,不向前端暴露长期 bearer token
4685 - 跑了哪些测试:
4686+  - `pnpm install`
4687+  - `pnpm -C packages/auth build`
4688+  - `pnpm -C apps/conductor-ui typecheck`
4689+  - `pnpm -C apps/conductor-ui build`
4690+  - `pnpm -C apps/conductor-daemon typecheck`
4691+  - `pnpm -C apps/conductor-daemon build`
4692+  - `pnpm -C apps/conductor-daemon test`
4693 
4694 ### 执行过程中遇到的问题
4695 
4696-- 
4697+- HTTP server 最初漏传了 `uiSessionManager` 到 `handleConductorHttpRequest(...)`,导致 `/v1/ui/session/*` 在 runtime 下退回到空 manager;已补 request context 传递并用集成测试覆盖
4698 
4699 ### 剩余风险
4700 
4701diff --git a/tasks/T-S072.md b/tasks/T-S072.md
4702index 84075f29ea7440e9a9c3ccd748eb9a4386f19f6f..ce9aeb91e8993ffb75a651609191a77161f0c9b0 100644
4703--- a/tasks/T-S072.md
4704+++ b/tasks/T-S072.md
4705@@ -2,7 +2,7 @@
4706 
4707 ## 状态
4708 
4709-- 当前状态:`待开始`
4710+- 当前状态:`已完成`
4711 - 规模预估:`L`
4712 - 依赖任务:`T-S070`、`T-S071`
4713 - 建议执行者:`Codex`(涉及多路现有 API 聚合、Vue 3 工作台布局和控制动作接线)
4714@@ -21,7 +21,7 @@
4715 
4716 - 仓库:`/Users/george/code/baa-conductor`
4717 - 分支基线:`main`
4718-- 提交:`b063524`
4719+- 提交:`69fc561`
4720 
4721 ## 分支与 worktree(强制)
4722 
4723@@ -170,21 +170,38 @@
4724 
4725 ### 开始执行
4726 
4727-- 执行者:
4728-- 开始时间:
4729+- 执行者:`Codex`
4730+- 开始时间:`2026-04-02 08:14:40 CST`
4731 - 状态变更:`待开始` → `进行中`
4732 
4733 ### 完成摘要
4734 
4735-- 完成时间:
4736+- 完成时间:`2026-04-02 08:37:37 CST`
4737 - 状态变更:`进行中` → `已完成`
4738 - 修改了哪些文件:
4739+  - `apps/conductor-ui/src/api/http.ts`
4740+  - `apps/conductor-ui/src/api/control.ts`
4741+  - `apps/conductor-ui/src/features/control/useControlWorkspace.ts`
4742+  - `apps/conductor-ui/src/features/control/views/ControlWorkspaceView.vue`
4743+  - `apps/conductor-ui/src/styles/base.css`
4744+  - `tasks/T-S072.md`
4745+  - `tasks/TASK_OVERVIEW.md`
4746 - 核心实现思路:
4747+  - 新增 `src/api/control.ts`,把 `system / browser / renewal / tasks / runs / codex / artifacts` 多路接口聚合成统一的 `ControlWorkspaceSnapshot`
4748+  - 使用 `useControlWorkspace` 管理首屏加载、15 秒轮询、系统动作、browser actions 和结果反馈,避免把视图层写成大段分散 `fetch`
4749+  - 将 `/app/control` 从占位壳升级为正式工作台:总览卡片、system/browser/renewal/tasks-runs/codex/artifacts 面板、raw inspector 和移动端可用布局
4750+  - 写角色和只读角色在 UI 上明确区分;只读会话下禁用 `Pause / Resume / Drain` 与 browser actions
4751 - 跑了哪些测试:
4752+  - `pnpm -C apps/conductor-ui typecheck`
4753+  - `pnpm -C apps/conductor-ui build`
4754+  - `pnpm -C apps/conductor-daemon typecheck`
4755+  - `pnpm -C apps/conductor-daemon build`
4756+  - `pnpm -C apps/conductor-daemon test`
4757 
4758 ### 执行过程中遇到的问题
4759 
4760-- 
4761+- 新 worktree 初始没有安装依赖,先执行了 `pnpm install` 才能跑 `vue-tsc` 和前端构建
4762+- `setInterval` 在当前 TS 配置下被推成 Node 侧 `Timeout`,已改为 `globalThis.setInterval` / `globalThis.clearInterval` 统一句柄类型
4763 
4764 ### 剩余风险
4765 
4766diff --git a/tasks/TASK_OVERVIEW.md b/tasks/TASK_OVERVIEW.md
4767index 36bcd74a05a04777d0420a35aa412158d8af2612..581e106292133d9cce60f9d329c204218fba81a8 100644
4768--- a/tasks/TASK_OVERVIEW.md
4769+++ b/tasks/TASK_OVERVIEW.md
4770@@ -115,27 +115,25 @@
4771 | [`T-S073`](./T-S073.md) | 移除 conductor 内的 stagit 仓库静态页能力 | M | 无 | Codex | 已完成 |
4772 | [`T-S074`](./T-S074.md) | 删除旧版 watchdog 与 Safari a11y 续命方案 | S | 无 | Codex | 已完成 |
4773 | [`T-S070`](./T-S070.md) | Conductor UI 基础设施:Vue 3 脚手架与 `/app` 静态托管 | M | 无 | Codex | 已完成 |
4774+| [`T-S071`](./T-S071.md) | Conductor UI 会话鉴权:登录页与浏览器 session | M | T-S070 | Codex | 已完成 |
4775+| [`T-S072`](./T-S072.md) | Conductor UI `Control` 工作区首版 | L | T-S070, T-S071 | Codex | 已完成 |
4776 | [`T-S077`](./T-S077.md) | Safari ChatGPT final-message — 共享 relay 接线 | M | Safari 插件首轮接入 | Codex | 已完成 |
4777 
4778 ### 当前下一波任务
4779 
4780-当前优先待开始任务:
4781+这轮 3 张 Web UI 任务卡已经全部完成,正式 `Control` 工作区已具备首版可用形态。
4782 
4783-| 任务 | 标题 | 规模 | 依赖 | 建议 AI | 状态 |
4784-|---|---|---|---|---|---|
4785-| [`T-S071`](./T-S071.md) | Conductor UI 会话鉴权:登录页与浏览器 session | M | T-S070 | Codex | 待开始 |
4786-| [`T-S072`](./T-S072.md) | Conductor UI `Control` 工作区首版 | L | T-S070, T-S071 | Codex | 待开始 |
4787-
4788-当前没有 open bug / open opt。
4789+下一波如果继续推进,建议改拆:
4790 
4791-如继续推进,建议直接进入 `T-S071 -> T-S072` 的 Web UI 主线。
4792+- `Channels` 工作区
4793+- 正式 `channel` 后端域模型
4794+- Control 的 SSE / WS 即时刷新版本
4795+- Safari 插件的 Claude / Gemini final-message relay 与共享控制面收口
4796 
4797 说明:
4798 
4799 - `T-S073` / `T-S074` 已完成,仓库边界收缩已结束
4800-- `T-S070` 已完成,`T-S071` / `T-S072` 是后续正式 Web UI 主线
4801-- Safari 插件目前只完成了 ChatGPT final-message relay;Claude / Gemini relay、共享 WS/控制面抽象还没正式进主线任务队列
4802-- 正式 `channel` 域模型、`Channels` 写路径与事件流当前只存在于 `feat/conductor-channel-domain` 分支快照,还没整理到可合并状态
4803+- `T-S071` / `T-S072` 现已进主线,但 `feat/conductor-channel-domain` 里的 channel 写路径与事件流仍未整理到可合并状态
4804 
4805 ### 已完成但保留作参考
4806