- 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