fix: persist user code agent sessions
This commit is contained in:
@@ -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 } : {}
|
||||
|
||||
@@ -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}`); }
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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([
|
||||
|
||||
+156
-17
@@ -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<string, unknown>) {
|
||||
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) };
|
||||
}
|
||||
|
||||
+125
-6
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user