diff --git a/internal/cloud/access-control.test.ts b/internal/cloud/access-control.test.ts index 9f3719ba..fcf8b519 100644 --- a/internal/cloud/access-control.test.ts +++ b/internal/cloud/access-control.test.ts @@ -288,6 +288,76 @@ test("cloud api access control grants visible device pods and requires device-po } }); +test("cloud api stores and restores Code Agent conversations by authenticated account", async () => { + const server = createCloudApiServer({ + env: { + HWLAB_ACCESS_CONTROL_REQUIRED: "1", + HWLAB_BOOTSTRAP_ADMIN_USERNAME: "admin", + HWLAB_BOOTSTRAP_ADMIN_PASSWORD: "admin-pass" + }, + now: () => "2026-05-31T07:30:00.000Z" + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const adminLogin = await postJson(port, "/auth/login", { username: "admin", password: "admin-pass" }); + const adminCookie = adminLogin.cookie; + const aliceCreate = await postJson(port, "/v1/admin/users", { username: "alice", password: "alice-pass" }, adminCookie); + const bobCreate = await postJson(port, "/v1/admin/users", { username: "bob", password: "bob-pass" }, adminCookie); + assert.equal(aliceCreate.status, 201); + assert.equal(bobCreate.status, 201); + + const aliceLogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" }); + const bobLogin = await postJson(port, "/auth/login", { username: "bob", password: "bob-pass" }); + + const stored = await putJson(port, "/v1/agent/conversations/cnv_account_sync", { + projectId: "prj_device_pod_workbench", + sessionId: "ses_account_sync", + threadId: "thread-account-sync", + lastTraceId: "trc_account_sync", + sessionStatus: "idle", + messages: [ + { id: "msg_user", role: "user", title: "用户", text: "在吗", status: "sent", conversationId: "cnv_account_sync", sessionId: "ses_account_sync", threadId: "thread-account-sync" }, + { id: "msg_agent", role: "agent", title: "Agent", text: "在", status: "completed", traceId: "trc_account_sync", conversationId: "cnv_account_sync", sessionId: "ses_account_sync", threadId: "thread-account-sync" } + ] + }, aliceLogin.cookie); + assert.equal(stored.status, 200); + assert.equal(stored.body.conversation.conversationId, "cnv_account_sync"); + assert.equal(stored.body.conversation.sessionId, "ses_account_sync"); + assert.equal(stored.body.conversation.threadId, "thread-account-sync"); + assert.equal(stored.body.conversation.status, "idle"); + assert.equal(stored.body.conversation.messages.length, 2); + + const aliceList = await getJson(port, "/v1/agent/conversations?projectId=prj_device_pod_workbench", aliceLogin.cookie); + assert.equal(aliceList.status, 200); + assert.equal(aliceList.body.count, 1); + assert.equal(aliceList.body.defaultConversation.conversationId, "cnv_account_sync"); + assert.equal(aliceList.body.defaultConversation.messages[1].text, "在"); + assert.equal(aliceList.body.defaultConversation.valuesRedacted, true); + + const bobList = await getJson(port, "/v1/agent/conversations?projectId=prj_device_pod_workbench", bobLogin.cookie); + assert.equal(bobList.status, 200); + assert.deepEqual(bobList.body.conversations, []); + + const bobDirect = await getJson(port, "/v1/agent/conversations/cnv_account_sync", bobLogin.cookie); + assert.equal(bobDirect.status, 404); + assert.equal(bobDirect.body.error.code, "agent_conversation_not_found"); + + const logout = await postJson(port, "/auth/logout", {}, aliceLogin.cookie); + assert.equal(logout.status, 200); + const relogin = await postJson(port, "/auth/login", { username: "alice", password: "alice-pass" }); + const restored = await getJson(port, "/v1/agent/conversations/cnv_account_sync", relogin.cookie); + assert.equal(restored.status, 200); + assert.equal(restored.body.conversation.threadId, "thread-account-sync"); + assert.equal(restored.body.conversation.messages[0].text, "在吗"); + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + test("cloud api protects device-pod routes when access control is required", async () => { const server = createCloudApiServer({ env: { HWLAB_ACCESS_CONTROL_REQUIRED: "1" }, @@ -988,6 +1058,23 @@ function devicePodInternalHeaders(extra = {}) { return { ...extra, "x-hwlab-internal-service": "hwlab-device-pod", "x-hwlab-internal-token": INTERNAL_TOKEN }; } +async function putJson(port, path, body, cookie = null, extraHeaders = {}) { + const response = await fetch(`http://127.0.0.1:${port}${path}`, { + method: "PUT", + headers: { + "content-type": "application/json", + ...(cookie ? { cookie } : {}), + ...extraHeaders + }, + body: JSON.stringify(body) + }); + return { + status: response.status, + body: await response.json(), + cookie: response.headers.get("set-cookie") + }; +} + async function getJson(port, path, cookie = null) { const response = await fetch(`http://127.0.0.1:${port}${path}`, { headers: cookie ? { cookie } : {} diff --git a/internal/cloud/access-control.ts b/internal/cloud/access-control.ts index 43e1a17a..24549dca 100644 --- a/internal/cloud/access-control.ts +++ b/internal/cloud/access-control.ts @@ -523,6 +523,77 @@ class AccessController { }) ?? null; } + async listAgentConversations(request, response, url) { + await this.ensureBootstrap(); + const auth = await this.authenticate(request, { required: true }); + if (!auth.ok) return sendJson(response, auth.status, auth); + const projectId = textOr(url.searchParams.get("projectId"), ""); + const limit = boundedListLimit(url.searchParams.get("limit")); + const sessions = await this.store.listAgentSessionsForUser?.({ + ownerUserId: auth.actor.id, + actorRole: auth.actor.role, + ownerScoped: true, + projectId, + limit, + now: this.now() + }) ?? []; + const conversations = conversationsFromAgentSessions(sessions); + return sendJson(response, 200, { + ok: true, + status: "succeeded", + contractVersion: "agent-conversations-v1", + actor: publicActor(auth.actor), + projectId: projectId || null, + conversations, + defaultConversation: conversations[0] ?? null, + count: conversations.length + }); + } + + async getAgentConversation(request, response, conversationId) { + await this.ensureBootstrap(); + const auth = await this.authenticate(request, { required: true }); + if (!auth.ok) return sendJson(response, auth.status, auth); + if (!safeConversationIdLocal(conversationId)) { + return sendJson(response, 400, errorPayload("invalid_conversation_id", "conversationId must start with cnv_", 400)); + } + const sessions = await this.store.listAgentSessionsForUser?.({ + ownerUserId: auth.actor.id, + actorRole: auth.actor.role, + ownerScoped: true, + conversationId, + limit: 20, + now: this.now() + }) ?? []; + const conversation = conversationsFromAgentSessions(sessions).find((item) => item.conversationId === conversationId) ?? null; + if (!conversation) return sendJson(response, 404, errorPayload("agent_conversation_not_found", "Agent conversation is not visible to the current actor", 404)); + return sendJson(response, 200, { ok: true, status: "found", contractVersion: "agent-conversations-v1", actor: publicActor(auth.actor), conversation }); + } + + async updateAgentConversation(request, response, conversationId) { + await this.ensureBootstrap(); + const auth = await this.authenticate(request, { required: true }); + if (!auth.ok) return sendJson(response, auth.status, auth); + if (!safeConversationIdLocal(conversationId)) { + return sendJson(response, 400, errorPayload("invalid_conversation_id", "conversationId must start with cnv_", 400)); + } + const body = await jsonBody(request); + const sessionId = safeAgentSessionId(body.sessionId) || safeAgentSessionId(body.session?.sessionId) || `ses_${conversationId.slice(4)}`; + const session = await this.recordAgentSessionOwner({ + ownerUserId: auth.actor.id, + sessionId, + projectId: textOr(body.projectId, "prj_device_pod_workbench"), + agentId: textOr(body.agentId, "hwlab-code-agent"), + status: textOr(body.status ?? body.sessionStatus, "active"), + conversationId, + threadId: textOr(body.threadId, null), + traceId: textOr(body.lastTraceId ?? body.traceId, null), + session: normalizeConversationSnapshot(body, auth.actor) + }); + const conversation = conversationsFromAgentSessions(session ? [session] : []).find((item) => item.conversationId === conversationId) ?? null; + return sendJson(response, 200, { ok: true, status: "stored", contractVersion: "agent-conversations-v1", conversation }); + } + async requireGrantTargets({ devicePodId, userId }) { const pod = await this.store.getDevicePod(devicePodId); if (!pod || pod.status !== "active") { @@ -1097,6 +1168,19 @@ class MemoryAccessStore { return session; } async getAgentSession(id) { return this.agentSessions.get(id) ?? null; } + async listAgentSessionsForUser(input = {}) { + const ownerUserId = textOr(input.ownerUserId, ""); + const role = textOr(input.actorRole, "user"); + const projectId = textOr(input.projectId, ""); + const conversationId = textOr(input.conversationId, ""); + const limit = boundedListLimit(input.limit); + return [...this.agentSessions.values()] + .filter((session) => input.ownerScoped === true || role !== "admin" ? session.ownerUserId === ownerUserId : true) + .filter((session) => !projectId || session.projectId === projectId) + .filter((session) => !conversationId || session.conversationId === conversationId) + .sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? ""))) + .slice(0, limit); + } } class PostgresAccessStore extends MemoryAccessStore { @@ -1174,10 +1258,38 @@ class PostgresAccessStore extends MemoryAccessStore { async recordAgentSessionOwner(input) { await this.ensureSchema(); const now = input.now ?? this.now(); - const session = normalizeAgentSessionOwnerRecord(input, null, now); + const existing = input.sessionId ? await this.getAgentSession(input.sessionId) : null; + const session = normalizeAgentSessionOwnerRecord(input, existing, now); const result = await this.query("INSERT INTO agent_sessions (id, project_id, agent_id, status, started_at, ended_at, owner_user_id, conversation_id, thread_id, last_trace_id, session_json, updated_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) ON CONFLICT (id) DO UPDATE SET project_id = COALESCE(EXCLUDED.project_id, agent_sessions.project_id), agent_id = EXCLUDED.agent_id, status = EXCLUDED.status, ended_at = EXCLUDED.ended_at, owner_user_id = EXCLUDED.owner_user_id, conversation_id = COALESCE(EXCLUDED.conversation_id, agent_sessions.conversation_id), thread_id = COALESCE(EXCLUDED.thread_id, agent_sessions.thread_id), last_trace_id = COALESCE(EXCLUDED.last_trace_id, agent_sessions.last_trace_id), session_json = EXCLUDED.session_json, updated_at = EXCLUDED.updated_at RETURNING id, project_id, agent_id, status, started_at, ended_at, owner_user_id, conversation_id, thread_id, last_trace_id, session_json, updated_at", [session.id, session.projectId, session.agentId, session.status, session.startedAt, session.endedAt, session.ownerUserId, session.conversationId, session.threadId, session.lastTraceId, stableJson(session.session ?? {}), session.updatedAt]); return pgAgentSession(result.rows?.[0]); } + async getAgentSession(id) { await this.ensureSchema(); const result = await this.query("SELECT id, project_id, agent_id, status, started_at, ended_at, owner_user_id, conversation_id, thread_id, last_trace_id, session_json, updated_at FROM agent_sessions WHERE id = $1 LIMIT 1", [id]); return pgAgentSession(result.rows?.[0]); } + async listAgentSessionsForUser(input = {}) { + await this.ensureSchema(); + const role = textOr(input.actorRole, "user"); + const ownerUserId = textOr(input.ownerUserId, ""); + const projectId = textOr(input.projectId, ""); + const conversationId = textOr(input.conversationId, ""); + const limit = boundedListLimit(input.limit); + const clauses = []; + const params = []; + if (input.ownerScoped === true || role !== "admin") { + params.push(ownerUserId); + clauses.push(`owner_user_id = $${params.length}`); + } + if (projectId) { + params.push(projectId); + clauses.push(`project_id = $${params.length}`); + } + if (conversationId) { + params.push(conversationId); + clauses.push(`conversation_id = $${params.length}`); + } + params.push(limit); + const sql = `SELECT id, project_id, agent_id, status, started_at, ended_at, owner_user_id, conversation_id, thread_id, last_trace_id, session_json, updated_at FROM agent_sessions ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY updated_at DESC NULLS LAST LIMIT $${params.length}`; + const result = await this.query(sql, params); + return result.rows.map(pgAgentSession); + } } function parseDevicePodPath(pathname) { @@ -1295,6 +1407,92 @@ function formatEventLine(event) { return [event.ts?.slice(11, 19) ?? "00:00:00", function normalizeJob(input) { return { id: input.id, devicePodId: input.devicePodId, ownerUserId: input.ownerUserId, status: input.status, intent: input.intent, args: normalizeObject(input.args), reason: input.reason ?? "", traceId: input.traceId, operationId: input.operationId, output: normalizeObject(input.output), blocker: input.blocker ?? null, createdAt: input.now, updatedAt: input.now, completedAt: input.completedAt ?? null }; } function normalizeAgentSessionOwnerRecord(input, existing, now) { return { id: input.sessionId, projectId: input.projectId ?? existing?.projectId ?? "prj_v02_code_agent", agentId: input.agentId ?? existing?.agentId ?? "hwlab-code-agent", status: input.status ?? existing?.status ?? "active", startedAt: existing?.startedAt ?? input.startedAt ?? now, endedAt: input.endedAt ?? existing?.endedAt ?? null, ownerUserId: input.ownerUserId, conversationId: input.conversationId ?? existing?.conversationId ?? null, threadId: input.threadId ?? existing?.threadId ?? null, lastTraceId: input.traceId ?? input.lastTraceId ?? existing?.lastTraceId ?? null, session: normalizeObject(input.session ?? existing?.session), updatedAt: now }; } function agentSessionIdForRecord(sessionId, traceId) { const sessionText = textOr(sessionId, ""); if (/^ses_[A-Za-z0-9_.:-]+$/u.test(sessionText)) return sessionText; const traceText = textOr(traceId, ""); return /^trc_[A-Za-z0-9_.:-]+$/u.test(traceText) ? `ses_pending_${traceText.slice(4)}` : null; } +function safeConversationIdLocal(value) { return /^cnv_[A-Za-z0-9_.:-]+$/u.test(textOr(value, "")); } +function boundedListLimit(value) { const parsed = Number.parseInt(String(value ?? ""), 10); return Math.min(Math.max(Number.isInteger(parsed) && parsed > 0 ? parsed : 20, 1), 100); } +function normalizeConversationSnapshot(body = {}, actor = null) { + const snapshot = normalizeObject(body.snapshot ?? body); + const messagesSource = Array.isArray(body.messages) ? body.messages : snapshot.messages; + const chatMessagesSource = Array.isArray(body.chatMessages) ? body.chatMessages : snapshot.chatMessages; + return { + ...snapshot, + messages: Array.isArray(messagesSource) ? messagesSource.slice(-50).map(redactConversationMessage).filter(Boolean) : [], + chatMessages: Array.isArray(chatMessagesSource) ? chatMessagesSource.slice(-50).map(redactConversationMessage).filter(Boolean) : undefined, + actor: actor ? publicActor(actor) : undefined, + secretMaterialStored: false, + valuesRedacted: true + }; +} +function redactConversationMessage(message) { + if (!message || typeof message !== "object") return null; + const runnerTrace = message.runnerTrace && typeof message.runnerTrace === "object" ? message.runnerTrace : null; + return pruneEmpty({ + id: textOr(message.id, ""), + role: textOr(message.role, ""), + title: textOr(message.title, ""), + text: boundedText(message.text, 12000), + status: textOr(message.status, ""), + traceId: textOr(message.traceId, ""), + conversationId: textOr(message.conversationId, ""), + sessionId: textOr(message.sessionId, ""), + threadId: textOr(message.threadId, ""), + retryOf: textOr(message.retryOf, ""), + createdAt: textOr(message.createdAt, ""), + updatedAt: textOr(message.updatedAt, ""), + runnerTrace: runnerTrace ? pruneEmpty({ + traceId: runnerTrace.traceId, + status: runnerTrace.status, + sessionId: runnerTrace.sessionId, + threadId: runnerTrace.threadId, + eventCount: runnerTrace.eventCount, + eventsCompacted: runnerTrace.eventsCompacted === true, + fullTraceLoaded: runnerTrace.fullTraceLoaded === true, + lastEvent: runnerTrace.lastEvent + }) : undefined + }); +} +function conversationsFromAgentSessions(sessions = []) { + const byConversation = new Map(); + for (const session of sessions) { + const conversationId = textOr(session.conversationId, ""); + if (!conversationId) continue; + const previous = byConversation.get(conversationId); + if (previous && String(previous.updatedAt ?? "") >= String(session.updatedAt ?? "")) continue; + byConversation.set(conversationId, publicAgentConversation(session)); + } + return [...byConversation.values()].sort((a, b) => String(b.updatedAt ?? "").localeCompare(String(a.updatedAt ?? ""))); +} +function publicAgentConversation(session) { + const snapshot = normalizeObject(session.session); + const messages = Array.isArray(snapshot.messages) ? snapshot.messages : Array.isArray(snapshot.chatMessages) ? snapshot.chatMessages : []; + return { + conversationId: session.conversationId, + sessionId: session.id, + threadId: session.threadId, + status: session.status, + projectId: session.projectId, + agentId: session.agentId, + ownerUserId: session.ownerUserId, + lastTraceId: session.lastTraceId, + updatedAt: session.updatedAt, + startedAt: session.startedAt, + endedAt: session.endedAt, + session: pruneEmpty({ sessionId: session.id, threadId: session.threadId, status: session.status }), + messages, + snapshot: pruneEmpty({ + sessionStatus: snapshot.sessionStatus, + source: snapshot.source, + updatedAt: snapshot.updatedAt, + valuesRedacted: snapshot.valuesRedacted !== false + }), + valuesRedacted: true + }; +} +function boundedText(value, maxBytes) { + const text = String(value ?? ""); + const buffer = Buffer.from(text, "utf8"); + return buffer.length > maxBytes ? buffer.subarray(0, maxBytes).toString("utf8") : text; +} +function pruneEmpty(value) { return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)); } function sessionTokenFromRequest(request) { const auth = getHeader(request, "authorization"); if (/^Bearer\s+/iu.test(String(auth ?? ""))) return String(auth).replace(/^Bearer\s+/iu, "").trim(); const header = textOr(getHeader(request, "x-hwlab-session-token"), ""); if (header) return header; const cookie = parseCookie(getHeader(request, "cookie")); return cookie[SESSION_COOKIE] ?? ""; } function parseCookie(value) { return Object.fromEntries(String(value ?? "").split(";").map((part) => part.trim()).filter(Boolean).map((part) => { const index = part.indexOf("="); return index > 0 ? [part.slice(0, index), decodeURIComponent(part.slice(index + 1))] : [part, ""]; })); } function setSessionCookie(response, token, maxAge) { response.setHeader("set-cookie", `${SESSION_COOKIE}=${encodeURIComponent(token)}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}`); } diff --git a/internal/cloud/code-agent-chat.ts b/internal/cloud/code-agent-chat.ts index a14a7ace..9ade2ffc 100644 --- a/internal/cloud/code-agent-chat.ts +++ b/internal/cloud/code-agent-chat.ts @@ -125,6 +125,7 @@ export async function handleCodeAgentChat(params = {}, options = {}) { message, conversationId, sessionId: requestedSessionId, + threadId: params.threadId, traceId, env: options.env ?? process.env, now: options.now, @@ -839,13 +840,14 @@ function notRequestedSkills() { }; } -async function callCodexStdioRunner({ message, conversationId, sessionId, traceId, env, now, workspace, timeoutMs, hardTimeoutMs, model, codexStdioManager, skillsDirs, skillsDirsExact, traceRecorder, conversationFacts, externalNetworkIntent = null }) { +async function callCodexStdioRunner({ message, conversationId, sessionId, threadId, traceId, env, now, workspace, timeoutMs, hardTimeoutMs, model, codexStdioManager, skillsDirs, skillsDirsExact, traceRecorder, conversationFacts, externalNetworkIntent = null }) { const manager = resolveCodexStdioSessionManager({ codexStdioManager }); try { return await manager.chat({ message, conversationId, sessionId, + threadId, traceId, env, now, diff --git a/internal/cloud/code-agent-session-registry.test.ts b/internal/cloud/code-agent-session-registry.test.ts index d2bc5c1a..8621ee26 100644 --- a/internal/cloud/code-agent-session-registry.test.ts +++ b/internal/cloud/code-agent-session-registry.test.ts @@ -1230,6 +1230,134 @@ function createPartialAssistantNoTurnCompletedClient({ calls, text = "partial as }; } +test("Codex stdio resumes an idle-expired thread instead of returning no-event timeout", async () => { + const calls = []; + const fakeCodex = await createFakeCodexCommand(); + const codexHome = await prepareFakeCodexHome(); + const manager = createCodexStdioSessionManager({ + idleTimeoutMs: 10, + idFactory: () => "ses_stdio_idle_resume", + createRpcClient: async () => createFakeAppServerClient({ + calls, + responses: ["first response", "resumed response"] + }) + }); + const env = { + PATH: process.env.PATH, + OPENAI_API_KEY: "test-openai-key-material", + CODEX_HOME: codexHome, + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, + HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", + HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", + HWLAB_CODE_AGENT_CODEX_WORKSPACE: process.cwd(), + HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access", + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:49280/responses" + }; + + try { + const first = await handleCodeAgentChat( + { + conversationId: "cnv_stdio_idle_resume", + traceId: "trc_stdio_idle_resume_first", + message: "第一轮" + }, + { + now: () => "2026-05-31T00:00:00.000Z", + env, + codexStdioManager: manager + } + ); + validateCodeAgentChatSchema(first); + assert.equal(first.status, "completed"); + assert.equal(first.session.threadId, "thread_stdio_ready"); + + const second = await handleCodeAgentChat( + { + conversationId: "cnv_stdio_idle_resume", + sessionId: first.sessionId, + threadId: first.session.threadId, + traceId: "trc_stdio_idle_resume_second", + message: "一小时后继续" + }, + { + now: () => "2026-05-31T01:00:00.000Z", + env, + codexStdioManager: manager + } + ); + + validateCodeAgentChatSchema(second); + assert.equal(second.status, "completed"); + assert.equal(second.error, undefined); + assert.equal(second.session.sessionId, "ses_stdio_idle_resume"); + assert.equal(second.session.threadId, "thread_stdio_ready"); + assert.equal(second.session.turn, 2); + assert.equal(second.sessionReuse.reused, true); + assert.deepEqual(calls.filter((call) => call.method === "thread/start").map((call) => call.args.serviceName), ["hwlab-cloud-api"]); + assert.deepEqual(calls.filter((call) => call.method === "thread/resume").map((call) => call.args.threadId), ["thread_stdio_ready"]); + assert.equal(calls.filter((call) => call.method === "turn/start").length, 2); + } finally { + await rm(fakeCodex.root, { recursive: true, force: true }); + await rm(codexHome, { recursive: true, force: true }); + } +}); + +test("Codex stdio uses a persisted thread id after manager restart", async () => { + const calls = []; + const fakeCodex = await createFakeCodexCommand(); + const codexHome = await prepareFakeCodexHome(); + const manager = createCodexStdioSessionManager({ + idFactory: () => "ses_should_not_replace_persisted", + createRpcClient: async () => createFakeAppServerClient({ + calls, + responses: ["resumed after restart"] + }) + }); + const env = { + PATH: process.env.PATH, + OPENAI_API_KEY: "test-openai-key-material", + CODEX_HOME: codexHome, + HWLAB_CODE_AGENT_PROVIDER: "codex-stdio", + HWLAB_CODE_AGENT_MODEL: "gpt-test", + HWLAB_CODE_AGENT_CODEX_COMMAND: fakeCodex.command, + HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED: "1", + HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR: "repo-owned", + HWLAB_CODE_AGENT_CODEX_WORKSPACE: process.cwd(), + HWLAB_CODE_AGENT_CODEX_SANDBOX: "danger-full-access", + HWLAB_CODE_AGENT_OPENAI_BASE_URL: "http://127.0.0.1:49280/responses" + }; + + try { + const payload = await handleCodeAgentChat( + { + conversationId: "cnv_stdio_restart_resume", + sessionId: "ses_stdio_restart_resume", + threadId: "thread_persisted_account", + traceId: "trc_stdio_restart_resume", + message: "恢复上次账号会话" + }, + { + now: () => "2026-05-31T01:05:00.000Z", + env, + codexStdioManager: manager + } + ); + + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "completed"); + assert.equal(payload.session.sessionId, "ses_stdio_restart_resume"); + assert.equal(payload.session.threadId, "thread_persisted_account"); + assert.deepEqual(calls.filter((call) => call.method === "thread/start"), []); + assert.deepEqual(calls.filter((call) => call.method === "thread/resume").map((call) => call.args.threadId), ["thread_persisted_account"]); + assert.equal(calls.filter((call) => call.method === "turn/start")[0].args.threadId, "thread_persisted_account"); + } finally { + await rm(fakeCodex.root, { recursive: true, force: true }); + await rm(codexHome, { recursive: true, force: true }); + } +}); + test("Codex stdio manager reports concrete blockers without falling back to readonly", async () => { const manager = createCodexStdioSessionManager({ idFactory: () => "ses_stdio_blocked" diff --git a/internal/cloud/codex-stdio-session.ts b/internal/cloud/codex-stdio-session.ts index 38914b26..626d9838 100644 --- a/internal/cloud/codex-stdio-session.ts +++ b/internal/cloud/codex-stdio-session.ts @@ -488,6 +488,7 @@ export function createCodexStdioSessionManager(options = {}) { const acquire = await acquireSession({ conversationId, requestedSessionId: params.sessionId, + threadId: params.threadId, traceId, workspace, sandbox, @@ -1109,6 +1110,7 @@ export function createCodexStdioSessionManager(options = {}) { const timestampMs = Date.parse(timestamp); const conversationId = requiredId(params.conversationId, "cnv"); const requestedSessionId = optionalId(params.requestedSessionId); + const requestedThreadId = optionalId(params.threadId); const mappedSessionId = conversations.get(conversationId) ?? null; if (mappedSessionId && requestedSessionId && mappedSessionId !== requestedSessionId) { @@ -1127,17 +1129,28 @@ export function createCodexStdioSessionManager(options = {}) { let reused = Boolean(session); if (session && sessionExpired(session, timestampMs)) { - session.status = "expired"; - session.updatedAt = timestamp; - session.currentTraceId = null; - session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId; - return blockedAcquire({ - code: "session_expired", - summary: `Codex stdio session ${effectiveSessionId} expired after ${session.idleTimeoutMs}ms idle timeout.`, - session, - timestamp, - traceId: params.traceId - }); + const resumeThreadId = requestedThreadId ?? optionalId(session.threadId); + if (["idle", "expired"].includes(session.status) && resumeThreadId) { + session.status = "idle"; + session.threadId = resumeThreadId; + session.updatedAt = timestamp; + session.expiresAt = plusMs(timestampMs, idleTimeoutMs); + session.currentTraceId = null; + session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId; + session.statusReason = "idle_timeout_resume"; + } else { + session.status = "expired"; + session.updatedAt = timestamp; + session.currentTraceId = null; + session.lastTraceId = optionalId(params.traceId) ?? session.lastTraceId; + return blockedAcquire({ + code: "session_expired", + summary: `Codex stdio session ${effectiveSessionId} expired after ${session.idleTimeoutMs}ms idle timeout.`, + session, + timestamp, + traceId: params.traceId + }); + } } if (session?.status === "busy") { @@ -1185,7 +1198,7 @@ export function createCodexStdioSessionManager(options = {}) { lastTraceId: optionalId(params.traceId), currentTraceId: null, turn: 0, - threadId: null, + threadId: requestedThreadId, durable: true, longLivedSession: true, codexStdio: true, @@ -1193,9 +1206,11 @@ export function createCodexStdioSessionManager(options = {}) { secretMaterialStored: false }; sessions.set(effectiveSessionId, session); + reused = Boolean(requestedThreadId); } session.conversationIds.add(conversationId); + session.threadId = requestedThreadId ?? session.threadId; session.workspace = params.workspace ?? session.workspace; session.sandbox = params.sandbox ?? session.sandbox; session.status = "busy"; diff --git a/internal/cloud/server-code-agent-http.ts b/internal/cloud/server-code-agent-http.ts index 3cb8a5a4..72837d2d 100644 --- a/internal/cloud/server-code-agent-http.ts +++ b/internal/cloud/server-code-agent-http.ts @@ -583,6 +583,7 @@ async function recordCodeAgentSessionOwner({ payload = {}, params = {}, options ownerUserId, ownerRole: options.actor?.role ?? params.ownerRole ?? null, sessionId, + projectId: params.projectId ?? payload.projectId ?? null, conversationId, threadId, traceId, @@ -618,6 +619,7 @@ function codeAgentSessionOwnerEvidence(payload = {}, params = {}) { sessionId: payload.sessionId ?? payload.session?.sessionId ?? payload.sessionReuse?.sessionId ?? params.sessionId ?? null, threadId: payload.session?.threadId ?? payload.sessionReuse?.threadId ?? payload.threadId ?? params.threadId ?? null, traceId: payload.traceId ?? params.traceId ?? null, + projectId: payload.projectId ?? params.projectId ?? null, secretMaterialStored: false, valuesRedacted: true }; diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index 2f8f695f..04c69c50 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -314,6 +314,22 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (url.pathname === "/v1/agent/conversations" && request.method === "GET") { + await options.accessController.listAgentConversations(request, response, url); + return; + } + + const agentConversationMatch = url.pathname.match(/^\/v1\/agent\/conversations\/([^/]+)$/u); + if (agentConversationMatch && request.method === "GET") { + await options.accessController.getAgentConversation(request, response, decodeURIComponent(agentConversationMatch[1])); + return; + } + + if (agentConversationMatch && (request.method === "PUT" || request.method === "PATCH")) { + await options.accessController.updateAgentConversation(request, response, decodeURIComponent(agentConversationMatch[1])); + return; + } + if (url.pathname === "/v1/access/status" || url.pathname === "/v1/setup/status") { await options.accessController.handleAccessStatusRoute(request, response, url); return; diff --git a/tools/hwlab-cli/client.test.ts b/tools/hwlab-cli/client.test.ts index 26bfd8d2..1c8f9184 100644 --- a/tools/hwlab-cli/client.test.ts +++ b/tools/hwlab-cli/client.test.ts @@ -163,6 +163,137 @@ test("hwlab-cli client agent trace shows assistant stream text in compact output assert.equal(result.payload.body.eventsCount, 1); }); +test("hwlab-cli client agent trace auto logs in when protected trace has no local session", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-auth-")); + const calls: any[] = []; + const result = await runHwlabCli([ + "client", + "agent", + "trace", + "trc_protected", + "--base-url", + "http://web.test", + "--username", + "admin" + ], { + cwd, + env: { HWLAB_PASSWORD: "secret-password" }, + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null }); + if (String(url).endsWith("/auth/login")) { + return new Response(JSON.stringify({ authenticated: true, user: { username: "admin" } }), { + status: 200, + headers: { "set-cookie": "hwlab_session=session-auto; Path=/; HttpOnly" } + }); + } + return new Response(JSON.stringify({ status: "completed", traceId: "trc_protected", events: [] }), { status: 200 }); + }, + now: () => "2026-05-31T07:20:00.000Z" + }); + + assert.equal(result.exitCode, 0); + assert.deepEqual(calls.map((call) => call.url), [ + "http://web.test/auth/login", + "http://web.test/v1/agent/chat/trace/trc_protected" + ]); + assert.equal(calls[1].init.headers.cookie, "hwlab_session=session-auto"); + assert.equal(JSON.stringify(result.payload).includes("secret-password"), false); + const session = JSON.parse(await readFile(path.join(cwd, ".state/hwlab-cli/session.json"), "utf8")); + assert.equal(session.cookie, "hwlab_session=session-auto"); +}); + +test("hwlab-cli client agent result refreshes expired session after 401 and reports compact result", async () => { + const cwd = await mkdtemp(path.join(os.tmpdir(), "hwlab-cli-client-auth-refresh-")); + await runHwlabCli(["client", "auth", "login", "--base-url", "http://web.test", "--username", "admin", "--password", "old"], { + cwd, + fetchImpl: async () => new Response(JSON.stringify({ authenticated: true, user: { username: "admin" } }), { status: 200, headers: { "set-cookie": "hwlab_session=session-old; Path=/" } }) + }); + const calls: any[] = []; + const result = await runHwlabCli([ + "client", + "agent", + "result", + "trc_refresh", + "--base-url", + "http://web.test", + "--username", + "admin" + ], { + cwd, + env: { HWLAB_PASSWORD: "new-password" }, + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init, body: init?.body ? JSON.parse(String(init.body)) : null }); + if (String(url).endsWith("/auth/login")) { + return new Response(JSON.stringify({ authenticated: true, user: { username: "admin" } }), { + status: 200, + headers: { "set-cookie": "hwlab_session=session-new; Path=/" } + }); + } + if (init?.headers?.cookie === "hwlab_session=session-old") { + return new Response(JSON.stringify({ error: { code: "auth_required" } }), { status: 401 }); + } + return new Response(JSON.stringify({ status: "completed", traceId: "trc_refresh", reply: { role: "assistant", content: "done" } }), { status: 200 }); + }, + sleep: async () => {}, + now: () => "2026-05-31T07:21:00.000Z" + }); + + assert.equal(result.exitCode, 0); + assert.deepEqual(calls.map((call) => call.url), [ + "http://web.test/v1/agent/chat/result/trc_refresh", + "http://web.test/auth/login", + "http://web.test/v1/agent/chat/result/trc_refresh" + ]); + assert.equal(calls[0].init.headers.cookie, "hwlab_session=session-old"); + assert.equal(calls[2].init.headers.cookie, "hwlab_session=session-new"); + assert.equal(result.payload.action, "client.agent.result"); + assert.equal(result.payload.body.assistantText, "done"); +}); + +test("hwlab-cli client protected agent commands return structured auth diagnosis for forbidden trace", async () => { + const result = await runHwlabCli(["client", "agent", "trace", "trc_forbidden", "--base-url", "http://web.test", "--cookie", "hwlab_session=session-a"], { + fetchImpl: async () => new Response(JSON.stringify({ error: { code: "agent_session_owner_required" } }), { status: 403 }) + }); + + assert.equal(result.exitCode, 1); + assert.equal(result.payload.httpStatus, 403); + assert.equal(result.payload.authDiagnosis.code, "auth_forbidden"); + assert.equal(result.payload.authDiagnosis.cookieSource, "explicit"); + assert.match(result.payload.authDiagnosis.message, /无权访问/u); +}); + +test("hwlab-cli client agent inspect builds protected inspect query", async () => { + const calls: any[] = []; + const result = await runHwlabCli([ + "client", + "agent", + "inspect", + "--trace-id", + "trc_inspect", + "--conversation-id", + "cnv_inspect", + "--session-id", + "ses_inspect", + "--thread-id", + "thread-1", + "--base-url", + "http://web.test", + "--cookie", + "hwlab_session=session-a" + ], { + fetchImpl: async (url, init) => { + calls.push({ url: String(url), init }); + return new Response(JSON.stringify({ ok: true, status: "found", latestTraceId: "trc_inspect" }), { status: 200 }); + } + }); + + assert.equal(result.exitCode, 0); + assert.equal(calls[0].url, "http://web.test/v1/agent/chat/inspect?traceId=trc_inspect&conversationId=cnv_inspect&sessionId=ses_inspect&threadId=thread-1"); + assert.equal(calls[0].init.headers.cookie, "hwlab_session=session-a"); + assert.equal(result.payload.action, "client.agent.inspect"); + assert.equal(result.payload.body.status, "found"); +}); + test("hwlab-cli client harness submits waits and audits trace friction", async () => { const calls: any[] = []; const result = await runHwlabCli([ diff --git a/tools/src/hwlab-cli-lib.ts b/tools/src/hwlab-cli-lib.ts index 2a28fec6..f4d82c6c 100644 --- a/tools/src/hwlab-cli-lib.ts +++ b/tools/src/hwlab-cli-lib.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; const VERSION = "0.2.0-client"; @@ -84,7 +84,9 @@ function help() { "hwlab-cli client request GET /v1/access/status [--full]", "hwlab-cli client rpc system.health [--full]", "hwlab-cli client agent send --message TEXT --provider-profile deepseek --timeout-ms 120000", + "hwlab-cli client agent result TRACE_ID", "hwlab-cli client agent trace TRACE_ID", + "hwlab-cli client agent inspect --trace-id TRACE_ID", "hwlab-cli client agent cancel TRACE_ID", "hwlab-cli client harness submit --message TEXT --provider-profile deepseek", "hwlab-cli client harness wait TRACE_ID --timeout-ms 55000", @@ -267,12 +269,23 @@ async function devicePodProbe(context: any, podId: string) { async function agentCommand(context: any) { const subcommand = context.rest[0] || "send"; if (subcommand === "send") return agentSend(context); + if (subcommand === "result") { + const traceId = requiredTraceId(context.rest[1] ?? context.parsed.traceId); + const pathName = `/v1/agent/chat/result/${encodeURIComponent(traceId)}`; + const response = await requestJson({ ...context, method: "GET", path: pathName }); + return responsePayload("client.agent.result", response, context, { route: route("GET", pathName), traceId, body: responseBodyForCli(response.body, context.parsed) }); + } if (subcommand === "trace") { - const traceId = requiredText(context.rest[1] ?? context.parsed.traceId, "traceId"); + const traceId = requiredTraceId(context.rest[1] ?? context.parsed.traceId); const pathName = `/v1/agent/chat/trace/${encodeURIComponent(traceId)}`; const response = await requestJson({ ...context, method: "GET", path: pathName }); return responsePayload("client.agent.trace", response, context, { route: route("GET", pathName), traceId, body: responseBodyForCli(response.body, context.parsed) }); } + if (subcommand === "inspect") { + const pathName = agentInspectPath(context); + const response = await requestJson({ ...context, method: "GET", path: pathName }); + return responsePayload("client.agent.inspect", response, context, { route: route("GET", pathName), body: responseBodyForCli(response.body, context.parsed) }); + } if (subcommand === "cancel") { const traceId = requiredText(context.rest[1] ?? context.parsed.traceId, "traceId"); const response = await requestJson({ ...context, method: "POST", path: "/v1/agent/chat/cancel", body: clean({ traceId, conversationId: text(context.parsed.conversationId), sessionId: text(context.parsed.sessionId) }), extraHeaders: { "x-trace-id": traceId } }); @@ -281,6 +294,22 @@ async function agentCommand(context: any) { throw cliError("unsupported_agent_command", `unsupported agent command: ${subcommand}`, { subcommand }); } +function agentInspectPath(context: any) { + const params = new URLSearchParams(); + const traceId = text(context.rest[1] ?? context.parsed.traceId); + const conversationId = text(context.parsed.conversationId); + const sessionId = text(context.parsed.sessionId); + const threadId = text(context.parsed.threadId); + if (traceId) params.set("traceId", requiredTraceId(traceId)); + if (conversationId) params.set("conversationId", conversationId); + if (sessionId) params.set("sessionId", sessionId); + if (threadId) params.set("threadId", threadId); + if ([...params.keys()].length === 0) { + throw cliError("missing_inspect_query", "client agent inspect requires --trace-id, --conversation-id, --session-id, or --thread-id"); + } + return `/v1/agent/chat/inspect?${params.toString()}`; +} + async function harnessCommand(context: any) { const subcommand = context.rest[0] || "help"; if (["help", "--help", "-h"].includes(subcommand)) return harnessHelp(); @@ -647,26 +676,91 @@ function normalizeRequestPath(value: string) { return pathName; } -async function requestJson({ parsed, env, fetchImpl, cwd, method, path: pathName, body, auth = true, extraHeaders = {}, timeoutMs }: any) { +async function requestJson(context: any) { + const { parsed, env, cwd, method, path: pathName, body, auth = true, extraHeaders = {}, timeoutMs } = context; const url = `${baseUrl(parsed, env)}${pathName}`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs ?? numberOption(parsed.timeoutMs) ?? DEFAULT_TIMEOUT_MS); try { - const cookie = auth ? await authCookie({ parsed, env, cwd: cwd ?? process.cwd() }) : null; - const headers = clean({ - accept: "application/json", - ...(body ? { "content-type": "application/json" } : {}), - ...(cookie ? { cookie } : {}), - ...extraHeaders - }); - const response = await fetchImpl(url, { method, headers, body: body ? JSON.stringify(body) : undefined, signal: controller.signal }); - const textBody = await response.text(); - return { status: response.status, headers: response.headers, body: parseJson(textBody), url, method, path: pathName }; + const effectiveAuth = auth && parsed.noAuth !== true; + const explicitCookie = explicitAuthCookie(parsed, env); + const session = effectiveAuth && !explicitCookie && parsed.noSession !== true + ? await loadSession({ parsed, env, cwd: cwd ?? process.cwd() }) + : null; + let cookie = effectiveAuth ? explicitCookie || session?.cookie || null : null; + const authState: any = { + required: effectiveAuth, + cookieSource: cookie ? explicitCookie ? "explicit" : "state" : null, + autoLoginAttempted: false, + autoLoginStatus: null, + retryAfterLogin: false + }; + if (effectiveAuth && !cookie && autoAuthAllowed(parsed)) { + const login = await autoLoginSession(context, controller.signal); + authState.autoLoginAttempted = true; + authState.autoLoginStatus = login.status; + authState.cookieSource = login.cookie ? "auto-login" : null; + cookie = login.cookie ?? null; + } + let response = await sendJsonRequest({ context, url, method, body, cookie, extraHeaders, signal: controller.signal, pathName }); + if (effectiveAuth && response.status === 401 && !explicitCookie && autoAuthAllowed(parsed)) { + const login = await autoLoginSession(context, controller.signal, { force: true }); + authState.autoLoginAttempted = true; + authState.autoLoginStatus = login.status; + if (login.cookie) { + authState.cookieSource = "auto-login"; + authState.retryAfterLogin = true; + cookie = login.cookie; + response = await sendJsonRequest({ context, url, method, body, cookie, extraHeaders, signal: controller.signal, pathName }); + } + } + return { ...response, authDiagnosis: authDiagnosis(response, authState) }; } finally { clearTimeout(timer); } } +async function sendJsonRequest({ context, url, method, body, cookie, extraHeaders, signal, pathName }: any) { + const headers = clean({ + accept: "application/json", + ...(body ? { "content-type": "application/json" } : {}), + ...(cookie ? { cookie } : {}), + ...extraHeaders + }); + const response = await context.fetchImpl(url, { method, headers, body: body ? JSON.stringify(body) : undefined, signal }); + const textBody = await response.text(); + return { status: response.status, headers: response.headers, body: parseJson(textBody), url, method, path: pathName }; +} + +async function autoLoginSession(context: any, signal: AbortSignal, { force = false } = {}) { + const password = await optionalPasswordValue(context); + if (!password) return { status: "credentials_missing", cookie: null }; + const username = text(context.parsed.username ?? context.env.HWLAB_USERNAME) || "admin"; + const response = await sendJsonRequest({ + context, + url: `${baseUrl(context.parsed, context.env)}/auth/login`, + method: "POST", + pathName: "/auth/login", + body: { username, password }, + cookie: null, + extraHeaders: {}, + signal + }); + const cookie = cookieFromResponse(response); + const success = isHttpSuccess(response) && response.body?.authenticated === true && cookie; + if (success) { + await saveSession(context, { + baseUrl: baseUrl(context.parsed, context.env), + cookie, + user: safeUser(response.body?.user ?? response.body?.actor), + expiresAt: textOrNull(response.body?.expiresAt), + updatedAt: context.now(), + refreshedAfter401: force === true || undefined + }); + } + return { status: success ? "succeeded" : `failed_http_${response.status}`, cookie: success ? cookie : null }; +} + function parseOptions(argv: string[]): ParsedArgs { const out: ParsedArgs = { _: [] }; for (let index = 0; index < argv.length; index += 1) { @@ -684,9 +778,14 @@ function parseOptions(argv: string[]): ParsedArgs { } async function passwordValue({ parsed, env, stdinText }: any) { - if (typeof parsed.passwordEnv === "string") return requiredText(env[parsed.passwordEnv], parsed.passwordEnv); + const password = await optionalPasswordValue({ parsed, env, stdinText }); + return requiredText(password, "password"); +} + +async function optionalPasswordValue({ parsed, env, stdinText }: any) { + if (typeof parsed.passwordEnv === "string") return text(env[parsed.passwordEnv]); if (parsed.passwordStdin === true) return stdinText !== undefined ? stdinText.trimEnd() : await readStdin(); - return requiredText(parsed.password ?? env.HWLAB_PASSWORD, "password"); + return text(parsed.password ?? env.HWLAB_PASSWORD); } async function readStdin() { @@ -700,13 +799,22 @@ function baseUrl(parsed: ParsedArgs, env: EnvLike) { } async function authCookie({ parsed, env, cwd }: { parsed: ParsedArgs; env: EnvLike; cwd: string }) { - const explicit = text(parsed.cookie ?? env.HWLAB_SESSION_COOKIE); + const explicit = explicitAuthCookie(parsed, env); if (explicit) return explicit.includes("=") ? explicit : `hwlab_session=${encodeURIComponent(explicit)}`; if (parsed.noSession === true) return null; const session = await loadSession({ parsed, env, cwd }); return session?.cookie ?? null; } +function explicitAuthCookie(parsed: ParsedArgs, env: EnvLike) { + const explicit = text(parsed.cookie ?? env.HWLAB_SESSION_COOKIE); + return explicit ? explicit.includes("=") ? explicit : `hwlab_session=${encodeURIComponent(explicit)}` : null; +} + +function autoAuthAllowed(parsed: ParsedArgs) { + return parsed.noAuth !== true && parsed.noAutoAuth !== true; +} + async function loadSession({ parsed, env, cwd }: { parsed: ParsedArgs; env: EnvLike; cwd: string }) { try { const raw = await readFile(stateFile(parsed, cwd), "utf8"); @@ -722,7 +830,8 @@ async function loadSession({ parsed, env, cwd }: { parsed: ParsedArgs; env: EnvL async function saveSession(context: any, session: Record) { const file = stateFile(context.parsed, context.cwd); await mkdir(path.dirname(file), { recursive: true }); - await writeFile(file, `${JSON.stringify(session, null, 2)}\n`, "utf8"); + await writeFile(file, `${JSON.stringify(session, null, 2)}\n`, { encoding: "utf8", mode: 0o600 }); + await chmod(file, 0o600).catch(() => {}); } async function clearSession(context: any) { @@ -752,11 +861,41 @@ function responsePayload(action: string, response: any, context: any, extra: Rec baseUrl: baseUrl(context.parsed, context.env), httpStatus: response.status, route: extra.route ?? route(response.method, response.path), + ...(response.authDiagnosis ? { authDiagnosis: response.authDiagnosis } : {}), ...extra, body: extra.body ?? response.body }; } +function authDiagnosis(response: any, authState: any) { + if (!authState?.required) return null; + if (response.status !== 401 && response.status !== 403) return null; + if (response.status === 403) { + return { + code: "auth_forbidden", + status: "forbidden", + message: "当前账号无权访问该受保护资源,可能是 trace/result 不属于当前 owner,或当前用户不是 admin。", + httpStatus: response.status, + cookieSource: authState.cookieSource, + autoLoginAttempted: authState.autoLoginAttempted, + retryAfterLogin: authState.retryAfterLogin + }; + } + const missingCredentials = authState.autoLoginAttempted && authState.autoLoginStatus === "credentials_missing"; + return { + code: missingCredentials ? "auth_credentials_missing" : "auth_required_or_expired", + status: missingCredentials ? "credentials_missing" : "unauthorized", + message: missingCredentials + ? "需要登录后访问该受保护资源;请提供 --password-env、--password、--password-stdin 或 HWLAB_PASSWORD,让 CLI 自动登录。" + : "登录态不存在或已过期;CLI 已按可用凭据尝试自动登录,仍未通过认证。", + httpStatus: response.status, + cookieSource: authState.cookieSource, + autoLoginAttempted: authState.autoLoginAttempted, + autoLoginStatus: authState.autoLoginStatus, + retryAfterLogin: authState.retryAfterLogin + }; +} + function compactProbe(method: string, pathName: string, response: any) { return { ok: isHttpSuccess(response) && response.body?.ok !== false, httpStatus: response.status, route: route(method, pathName), body: compactApiBody(response.body) }; } diff --git a/web/hwlab-cloud-web/app.ts b/web/hwlab-cloud-web/app.ts index 32728189..da76f72f 100644 --- a/web/hwlab-cloud-web/app.ts +++ b/web/hwlab-cloud-web/app.ts @@ -244,6 +244,9 @@ const state = { }; let codeAgentSessionPersistTimer = null; +let accountConversationHydrated = false; +let accountConversationPersistInFlight = false; +let accountConversationPersistPending = false; restoreCodeAgentSessionState(); @@ -260,7 +263,6 @@ initConversationScrollMemory(); initCommandBar(); initLiveBuildOverlay(); initWorkbenchLogout(el.logoutButton); -el.logoutButton.addEventListener("click", clearCodeAgentSessionState); initGateControls(); installWorkbenchTestHooks(); renderStaticWorkbench(); @@ -268,6 +270,7 @@ renderProbePending(); renderCodeAgentSummary(); loadHelpSurface(); loadLiveSurface().then(renderLiveSurface); +hydrateAccountCodeAgentSessionState(); window.setTimeout(refreshRestoredCodeAgentTraces, 0); function byId(id) { @@ -460,7 +463,17 @@ function persistCodeAgentSessionState() { clearCodeAgentSessionState(); return; } - const payload = { + const payload = codeAgentSessionSnapshotPayload(); + try { + window.localStorage?.setItem(CODE_AGENT_SESSION_STORAGE_KEY, JSON.stringify(payload)); + } catch { + // Keep the in-memory conversation when localStorage is unavailable or full. + } + persistAccountCodeAgentSessionState(payload); +} + +function codeAgentSessionSnapshotPayload() { + return { version: CODE_AGENT_SESSION_STORAGE_VERSION, updatedAtMs: Date.now(), conversationId: state.conversationId, @@ -469,11 +482,117 @@ function persistCodeAgentSessionState() { sessionStatus: state.sessionStatus, chatMessages: state.chatMessages.slice(-CODE_AGENT_SESSION_STORAGE_MESSAGE_LIMIT).map(storedChatMessage) }; - try { - window.localStorage?.setItem(CODE_AGENT_SESSION_STORAGE_KEY, JSON.stringify(payload)); - } catch { - // Keep the in-memory conversation when localStorage is unavailable or full. +} + +async function hydrateAccountCodeAgentSessionState() { + const response = await fetchJson(`/v1/agent/conversations?projectId=${encodeURIComponent(WORKBENCH_PROJECT_ID)}&limit=1`, { + timeoutMs: Math.min(API_TIMEOUT_MS, 5000), + timeoutName: "Code Agent account conversation hydrate" + }); + if (!response.ok) return; + const conversation = response.data?.defaultConversation ?? (Array.isArray(response.data?.conversations) ? response.data.conversations[0] : null); + if (!conversation) { + accountConversationHydrated = true; + if (state.conversationId) persistCodeAgentSessionState(); + return; } + const payload = accountConversationToStoredSessionPayload(conversation); + if (!payload) return; + const shouldReplace = !state.conversationId || Number(payload.updatedAtMs) > latestLocalCodeAgentSessionUpdatedAt(); + if (!shouldReplace) { + accountConversationHydrated = true; + persistCodeAgentSessionState(); + return; + } + state.conversationId = nonEmptyString(payload.conversationId); + state.sessionId = nonEmptyString(payload.sessionId); + state.threadId = nonEmptyString(payload.threadId); + state.sessionStatus = nonEmptyString(payload.sessionStatus); + state.chatMessages = Array.isArray(payload.chatMessages) + ? payload.chatMessages.map(restoreStoredChatMessage).filter(Boolean) + : []; + accountConversationHydrated = true; + persistCodeAgentSessionState(); + renderAgentChatStatus(latestChatResult()?.status ?? "idle", latestChatResult()); + renderCodeAgentSummary(); + renderConversation(); + renderDrafts(); + window.setTimeout(refreshRestoredCodeAgentTraces, 0); +} + +function accountConversationToStoredSessionPayload(conversation) { + if (!conversation || typeof conversation !== "object") return null; + const conversationId = nonEmptyString(conversation.conversationId); + if (!conversationId) return null; + const messages = Array.isArray(conversation.messages) ? conversation.messages : []; + const updatedAtMs = Date.parse(conversation.updatedAt ?? conversation.snapshot?.updatedAt ?? ""); + return { + version: CODE_AGENT_SESSION_STORAGE_VERSION, + updatedAtMs: Number.isFinite(updatedAtMs) ? updatedAtMs : Date.now(), + conversationId, + sessionId: nonEmptyString(conversation.sessionId ?? conversation.session?.sessionId), + threadId: nonEmptyString(conversation.threadId ?? conversation.session?.threadId), + sessionStatus: nonEmptyString(conversation.snapshot?.sessionStatus ?? conversation.status), + chatMessages: messages.slice(-CODE_AGENT_SESSION_STORAGE_MESSAGE_LIMIT) + }; +} + +function latestLocalCodeAgentSessionUpdatedAt() { + let latest = 0; + for (const message of state.chatMessages) { + const value = Date.parse(message.updatedAt ?? message.createdAt ?? ""); + if (Number.isFinite(value) && value > latest) latest = value; + } + return latest; +} + +function persistAccountCodeAgentSessionState(payload) { + if (!accountConversationHydrated) return; + if (!payload.conversationId) return; + accountConversationPersistPending = true; + if (accountConversationPersistInFlight) return; + void flushAccountCodeAgentSessionState(); +} + +async function flushAccountCodeAgentSessionState() { + accountConversationPersistInFlight = true; + try { + while (accountConversationPersistPending) { + accountConversationPersistPending = false; + const payload = codeAgentSessionSnapshotPayload(); + if (!payload.conversationId) continue; + await fetchJson(`/v1/agent/conversations/${encodeURIComponent(payload.conversationId)}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + timeoutMs: Math.min(API_TIMEOUT_MS, 5000), + timeoutName: "Code Agent account conversation persist", + body: JSON.stringify({ + projectId: WORKBENCH_PROJECT_ID, + conversationId: payload.conversationId, + sessionId: payload.sessionId, + threadId: payload.threadId, + sessionStatus: payload.sessionStatus, + lastTraceId: latestTraceIdFromStoredMessages(payload.chatMessages), + messages: payload.chatMessages, + snapshot: { + source: "cloud-web-account-sync", + updatedAt: new Date(payload.updatedAtMs).toISOString(), + sessionStatus: payload.sessionStatus + } + }) + }); + } + } finally { + accountConversationPersistInFlight = false; + } +} + +function latestTraceIdFromStoredMessages(messages) { + for (const message of [...(messages ?? [])].reverse()) { + const traceId = nonEmptyString(message?.traceId ?? message?.runnerTrace?.traceId); + if (traceId) return traceId; + } + return null; } function clearCodeAgentSessionState() {