Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 32 additions & 8 deletions apps/desktop/src/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,28 +86,39 @@ export function registerDesktopIpcHandlers({
};
});

ipcMain.handle("covel:retry-startup", () => {
// Audit 2026-07-16 L-4: these mutate the app / open OS paths, so gate them on
// the same trusted-sender check the secret channels use — defense in depth
// behind nav-pinning, so an untrusted frame can't drive restart/dir actions.
ipcMain.handle("covel:retry-startup", (event) => {
if (!isTrustedSender(event, "covel:retry-startup")) return;
retryStartup();
});

ipcMain.handle("covel:open-logs-dir", async () => {
ipcMain.handle("covel:open-logs-dir", async (event) => {
if (!isTrustedSender(event, "covel:open-logs-dir")) return;
await shell.openPath(paths.logsDir);
});

ipcMain.handle("covel:open-config-dir", async () => {
ipcMain.handle("covel:open-config-dir", async (event) => {
if (!isTrustedSender(event, "covel:open-config-dir")) return;
await shell.openPath(paths.covelHome);
});

ipcMain.handle("covel:open-data-dir", async () => {
ipcMain.handle("covel:open-data-dir", async (event) => {
if (!isTrustedSender(event, "covel:open-data-dir")) return;
await shell.openPath(paths.dataRoot);
});

ipcMain.handle("covel:restart-server", () => restartServer());
ipcMain.handle("covel:restart-server", (event) => {
if (!isTrustedSender(event, "covel:restart-server")) return;
return restartServer();
});

// Pick a directory for the next data_root. Does NOT move data — that's
// deliberate per the "drop-old-data" UX contract; app restart starts fresh
// in the new location.
ipcMain.handle("covel:pick-data-dir", async () => {
ipcMain.handle("covel:pick-data-dir", async (event) => {
if (!isTrustedSender(event, "covel:pick-data-dir")) return { path: null };
const result = await dialog.showOpenDialog({
title: t("dialog.dataDir.title"),
properties: ["openDirectory", "createDirectory"],
Expand Down Expand Up @@ -265,6 +276,19 @@ export function registerDesktopIpcHandlers({
return handleImport(kind, { sourcePath: picked.filePaths[0] });
}

ipcMain.handle("covel:import:pick-plugin", () => pickAndImport("plugin"));
ipcMain.handle("covel:import:pick-world", () => pickAndImport("world"));
// Audit 2026-07-16 L-4: these drive a native import dialog (installs a
// plugin/world) — a strictly worse action than opening a dir — so gate them
// on the same trusted-sender check as the other dialog-backed channels.
ipcMain.handle("covel:import:pick-plugin", (event) => {
if (!isTrustedSender(event, "covel:import:pick-plugin")) {
return { ok: false, kind: "plugin", message: t("import.cancelled") };
}
return pickAndImport("plugin");
});
ipcMain.handle("covel:import:pick-world", (event) => {
if (!isTrustedSender(event, "covel:import:pick-world")) {
return { ok: false, kind: "world", message: t("import.cancelled") };
}
return pickAndImport("world");
});
}
3 changes: 2 additions & 1 deletion apps/server/src/routes/api/bootstrap/compactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export function createBootstrapCompactorRunner({
};

return {
async run(sessionId, systemPromptPreview, messages, locale) {
async run(sessionId, systemPromptPreview, messages, locale, traceId) {
// The compactor only ships zh-CN / en-US prompt templates; map the
// session locale by prefix (en* → en-US, else the zh-CN default).
const compactorLocale =
Expand All @@ -68,6 +68,7 @@ export function createBootstrapCompactorRunner({
{
focusSections,
locale: compactorLocale,
...(traceId ? { traceId } : {}),
},
);
},
Expand Down
7 changes: 7 additions & 0 deletions apps/server/src/routes/api/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { DataStore } from "@covel/store";
import type { CovelMessage } from "@covel/shared";
import { isEnvTruthy, readRuntimeEnv } from "@covel/shared";
import { errorBody } from "../../api-error.js";
import { checkSessionOwnerById } from "./session/session-guard.js";

type Env = {
Variables: {
Expand Down Expand Up @@ -41,6 +42,12 @@ eventRoutes.post("/emit", async (c) => {
return c.json(errorBody("topic and sessionId are required"), 400);
}

// Audit 2026-07-16 L-3: on hosted tiers, gate event injection on the target
// session's owner token so a non-production demo boot can't accept arbitrary
// cross-session events. Strict no-op on self/desktop (unenforced tiers).
const denied = await checkSessionOwnerById(c, c.get("store"), body.sessionId);
if (denied) return denied;

const message: CovelMessage = {
id: crypto.randomUUID(),
type: "event",
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/routes/api/resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,9 @@ resumeRoutes.post("/:id/resume", async (c) => {
// ── DELETE (abandon) ─────────────────────────────────────────────

resumeRoutes.delete("/:id/suspensions/:suspensionId", async (c) => {
const guard = await resolveSessionParam(c);
if (!guard.ok) return guard.response;

const sessionId = c.req.param("id");
const suspensionId = c.req.param("suspensionId");
const store = c.get("store");
Expand Down
27 changes: 23 additions & 4 deletions apps/server/src/routes/api/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,20 @@ type Env = {

export const sessionRoutes = new Hono<Env>();

/**
* Strip the persisted owner-token hash before returning a session over the
* wire (audit 2026-07-16 L-2). It is an internal credential check; a caller
* has no use for its own hash and it should not travel in responses.
*/
function sanitizeSessionForResponse<
T extends { readonly metadata?: Record<string, unknown> | null },
>(session: T): T {
const metadata = session.metadata;
if (!metadata || !(SESSION_OWNER_TOKEN_HASH_KEY in metadata)) return session;
const { [SESSION_OWNER_TOKEN_HASH_KEY]: _omit, ...rest } = metadata;
return { ...session, metadata: rest };
}

/**
* Keep persisted active plugins aligned with the in-memory approval gate.
* Community server code is never restored implicitly after create/fork or a
Expand Down Expand Up @@ -177,7 +191,7 @@ sessionRoutes.get("/", async (c) => {
// can show RAG status badges without an extra round-trip per row.
// listVectorModels is called once and shared across all sessions.
const decorated = await decorateSessionList(store, filtered);
return c.json({ items: decorated });
return c.json({ items: decorated.map(sanitizeSessionForResponse) });
});

// POST /sessions
Expand Down Expand Up @@ -335,7 +349,10 @@ sessionRoutes.post("/", async (c) => {

// `ownerToken` is returned exactly once — it is never readable again
// (only its hash is stored). Clients on hosted tiers must persist it.
return c.json({ ...session, ownerToken: owner.token });
return c.json({
...sanitizeSessionForResponse(session),
ownerToken: owner.token,
});
});

// ── Instance endpoints ──────────────────────────────────────────
Expand Down Expand Up @@ -421,7 +438,9 @@ sessionRoutes.get("/:id", async (c) => {
const guard = await resolveSessionParam(c);
if (!guard.ok) return guard.response;
const session = guard.session;
return c.json(await withEmbeddingMetadata(store, session));
return c.json(
sanitizeSessionForResponse(await withEmbeddingMetadata(store, session)),
);
});

// PATCH /sessions/:id
Expand Down Expand Up @@ -458,7 +477,7 @@ sessionRoutes.patch("/:id", async (c) => {
}

// Return merged result to avoid a second DB read
return c.json({ ...session, ...updates });
return c.json(sanitizeSessionForResponse({ ...session, ...updates }));
});

// DELETE /sessions/:id
Expand Down
38 changes: 38 additions & 0 deletions apps/server/tests/api/resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,44 @@ describe("Resume Routes", () => {

expect(res.status).toBe(404);
});

// Audit 2026-07-16 H-1: this DELETE must enforce the session-owner guard
// on hosted tiers like its sibling routes, not just a sessionId consistency
// check. Anonymous deletion of another session's suspension is a
// cross-tenant destructive write.
it("denies anonymous suspension deletion on a hosted tier (owner guard)", async () => {
const prevTier = process.env.DEPLOYMENT_TIER;
const prevOp = process.env.COVEL_DESKTOP_REST_TOKEN;
process.env.DEPLOYMENT_TIER = "commercial";
process.env.COVEL_DESKTOP_REST_TOKEN = "operator-secret";
try {
await createSuspension(store);
const app = createTestApp(makeDefaultDeps(store));

const anon = await app.request(
"/api/sessions/sess-1/suspensions/susp-1",
{ method: "DELETE" },
);
expect(anon.status).toBe(401);
// The denied attempt must not have deleted the suspension.
expect(await store.getSuspension("susp-1")).not.toBeNull();

// The operator master credential passes the guard and deletes.
const op = await app.request(
"/api/sessions/sess-1/suspensions/susp-1",
{
method: "DELETE",
headers: { authorization: "Bearer operator-secret" },
},
);
expect(op.status).toBe(200);
} finally {
if (prevTier === undefined) delete process.env.DEPLOYMENT_TIER;
else process.env.DEPLOYMENT_TIER = prevTier;
if (prevOp === undefined) delete process.env.COVEL_DESKTOP_REST_TOKEN;
else process.env.COVEL_DESKTOP_REST_TOKEN = prevOp;
}
});
});

describe("GET /api/sessions/:id/suspensions", () => {
Expand Down
14 changes: 11 additions & 3 deletions apps/web/src/hooks/use-auto-scroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,17 +48,25 @@ export function useAutoScroll(
[thresholdPx],
);

const scrollHandlerRef = useRef<(() => void) | null>(null);
const scrollRef = useCallback(
(node: HTMLElement | null) => {
const prev = viewportRef.current;
if (prev) prev.onscroll = null;
// `addEventListener` handlers are not removed by clearing `.onscroll`, so
// keep the handler ref and detach it explicitly on node swap (L-12).
if (prev && scrollHandlerRef.current) {
prev.removeEventListener("scroll", scrollHandlerRef.current);
}
viewportRef.current = node;
scrollHandlerRef.current = null;
if (!node) return;
node.addEventListener("scroll", () => {
const handler = () => {
const atBottom = computeIsAtBottom(node);
isPinnedRef.current = atBottom;
setShowJumpButton((cur) => (cur === !atBottom ? cur : !atBottom));
});
};
scrollHandlerRef.current = handler;
node.addEventListener("scroll", handler);
},
[computeIsAtBottom],
);
Expand Down
9 changes: 8 additions & 1 deletion apps/web/src/routes/session.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Loader2, AlertCircle } from "lucide-react";
import { useSession } from "@/stores/session-store.js";
import { getDataService } from "@/services/data-service.js";
import { mergeChatExportMessages } from "@/lib/chat-export.js";
import { getStreamingText } from "@/stores/streaming-text-store.js";
import { emitToast } from "@/lib/toast-channel.js";
import { useSlotConfig } from "@/hooks/use-slot-config.js";
import { useSettingsDialog } from "@/hooks/use-settings-dialog.js";
Expand Down Expand Up @@ -172,7 +173,13 @@ function SessionPage() {
}
try {
const text = msgs
.map((m) => `[${m.role}] ${m.content}`)
// A message still streaming has an empty `content` (live text
// lives in the external store); resolve it so a mid-stream export
// keeps the partial assistant text instead of a blank row (L-10).
.map(
(m) =>
`[${m.role}] ${m.content || getStreamingText(m.id) || ""}`,
)
.join("\n\n");
const blob = new Blob([text], { type: "text/plain" });
const url = URL.createObjectURL(blob);
Expand Down
38 changes: 21 additions & 17 deletions apps/web/src/stores/session-store/sse-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,25 +256,29 @@ export function createSseEventHandler(
const completedKind =
(payload.kind as string) ??
deps.runtimeKindRef.current.get(runtimeId);
if (content && completedKind === "story") {
// Flush a same-frame delta before replacing its placeholder, then drop
// the external live buffer. The completed payload is authoritative.
if (completedKind === "story") {
// A story runtime's stream is finished. Always flush the same-frame
// delta and drop the external live buffer — even on empty content —
// so stale partial text can't linger on screen (audit 2026-07-16
// L-11). Only publish an authoritative message when content exists.
flushNarrativeDeltaBuffer(deps);
clearStreamingText(`stream_${turnId ?? "unknown"}_${runtimeId}`);
const msg: StreamMessage = {
id: msgId,
role: "assistant",
content,
timestamp: envelope.timestamp,
turnId,
runtimeId: runtimeId !== "unknown" ? runtimeId : undefined,
};
deps.dispatch({
type: "COMPLETE_MESSAGE",
turnId: turnId ?? "unknown",
runtimeId,
message: msg,
});
if (content) {
const msg: StreamMessage = {
id: msgId,
role: "assistant",
content,
timestamp: envelope.timestamp,
turnId,
runtimeId: runtimeId !== "unknown" ? runtimeId : undefined,
};
deps.dispatch({
type: "COMPLETE_MESSAGE",
turnId: turnId ?? "unknown",
runtimeId,
message: msg,
});
}
}

if (content) {
Expand Down
14 changes: 13 additions & 1 deletion packages/ai-provider/src/plugin-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,11 +106,23 @@ export async function fetchWithRetry(
// following changed DNS to an address that was never policy-checked.
const dispatcher = await createPinnedDispatcher(url);
try {
return await fetch(input, {
// Only the initial URL is SSRF-checked; a redirect Location is not, and
// undici skips the pinning lookup for an IP-literal host. Follow the core
// provider path: force manual redirect handling and fail closed on a 3xx
// so a `302 Location: http://169.254.169.254/…` can't reach internal
// hosts. `redirect` is placed after `...rest` to override any caller value.
const response = await fetch(input, {
...rest,
redirect: "manual",
...(signal ? { signal } : {}),
dispatcher,
} as RequestInit);
if (response.status >= 300 && response.status < 400) {
throw new Error(
`baseUrl rejected by SSRF policy: refusing to follow redirect (HTTP ${response.status}) from "${url}".`,
);
}
return response;
} finally {
// close() drains active response bodies before releasing the pool, so it
// is safe to start shutdown once fetch has returned the response headers.
Expand Down
16 changes: 16 additions & 0 deletions packages/ai-provider/tests/plugin-utils-dns-safety.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,22 @@ describe("DNS SSRF safety", () => {
});
});

it("refuses to follow a redirect (SSRF via 3xx Location to an IP-literal)", async () => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(null, {
status: 302,
headers: { location: "http://169.254.169.254/latest/meta-data/" },
}),
);

await expect(
fetchWithRetry("https://provider.example.test/v1", { maxRetries: 0 }),
).rejects.toThrow(/refusing to follow redirect/);
// The initial request must have opted out of automatic redirect following.
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ redirect: "manual" });
});

it("only allows localhost when every DNS answer is loopback", async () => {
lookupMock.mockResolvedValue([{ address: "10.0.0.8", family: 4 }]);
const fetchMock = vi.spyOn(globalThis, "fetch");
Expand Down
Loading