fix: reject unresolved worker console targets

This commit is contained in:
2026-09-29 07:36:03 +09:00
parent c979883463
commit 09f8cfb718
6 changed files with 124 additions and 27 deletions
@@ -972,9 +972,14 @@ Deno.test("Worker Console route resolves logical Worker authority before Runtime
routeLoad.includes("resourceKey(params.workerRef)") &&
routeLoad.includes("/workers/${encodeURIComponent(reference)}") &&
routeLoad.includes("canonicalResourceReference") &&
routeLoad.includes("runtimeId: result.data?.runtime_id") &&
routeLoad.includes("workerId: result.data?.worker_id"),
"route load should resolve the logical Worker reference before exposing its execution target",
routeLoad.includes("runtimeId: result.data?.runtime_id ?? null") &&
routeLoad.includes("workerId: result.data?.worker_id ?? null") &&
!routeLoad.includes('result.data?.runtime_id ?? ""') &&
consolePage.includes(
"resolveWorkerSessionTarget(workspaceId, runtimeId, workerId)",
) &&
consolePage.includes("if (!target) return;"),
"route load should retain an unresolved Worker target as null and the Console must fail closed before Runtime APIs",
);
assert(
consolePage.includes("workspaceApiPath(workspaceId, path)") &&
@@ -1228,9 +1233,9 @@ Deno.test("Workspace Worker list and Console share the multiplexed connection",
consolePage.includes("const targetWorker = data.worker") &&
consolePage.includes("worker = targetWorker") &&
consolePage.includes(
"const consoleTarget = $derived({ workspaceId, runtimeId, workerId })",
"resolveWorkerSessionTarget(workspaceId, runtimeId, workerId)",
),
"A reused Console route should subscribe immediately on the live Workspace socket and install the new route Worker",
"A reused Console route should subscribe immediately only after resolving the new route Worker execution target",
);
});
@@ -1,5 +1,6 @@
import {
isCurrentWorkerSessionRequest,
resolveWorkerSessionTarget,
workerSessionAction,
type WorkerSessionRequestIdentity,
workerSessionRequestInit,
@@ -24,6 +25,30 @@ const snapshot: SessionSnapshot = {
entries: [],
};
Deno.test("worker session target requires every routing identity", () => {
const target = resolveWorkerSessionTarget(
"workspace-a",
"runtime-a",
"worker-a",
);
assert(
target?.runtimeId === "runtime-a" && target.workerId === "worker-a",
"resolved identities must produce an API target",
);
for (
const unresolved of [
resolveWorkerSessionTarget("", "runtime-a", "worker-a"),
resolveWorkerSessionTarget("workspace-a", "", "worker-a"),
resolveWorkerSessionTarget("workspace-a", "runtime-a", ""),
resolveWorkerSessionTarget("workspace-a", null, "worker-a"),
resolveWorkerSessionTarget("workspace-a", "runtime-a", undefined),
resolveWorkerSessionTarget("workspace-a", " ", "worker-a"),
]
) {
assert(unresolved === null, "incomplete identities must fail closed");
}
});
Deno.test("worker session request includes same-origin credentials", () => {
const controller = new AbortController();
const init = workerSessionRequestInit(controller.signal);
@@ -10,13 +10,33 @@ export type WorkerSessionAction =
| { kind: "apply_retained"; snapshot: SessionSnapshot }
| { kind: "show_unavailable"; message: string };
export interface WorkerSessionRequestIdentity {
token: number;
export interface WorkerSessionTarget {
workspaceId: string;
runtimeId: string;
workerId: string;
}
export interface WorkerSessionRequestIdentity extends WorkerSessionTarget {
token: number;
}
export function resolveWorkerSessionTarget(
workspaceId: string,
runtimeId: string | null | undefined,
workerId: string | null | undefined,
): WorkerSessionTarget | null {
if (
workspaceId.trim().length === 0 ||
runtimeId?.trim().length === 0 ||
workerId?.trim().length === 0 ||
runtimeId == null ||
workerId == null
) {
return null;
}
return { workspaceId, runtimeId, workerId };
}
export function workerSessionRequestInit(signal: AbortSignal): RequestInit {
return {
signal,
@@ -1,4 +1,5 @@
<script lang="ts">
import { invalidateAll } from "$app/navigation";
import { tick, untrack, type SvelteComponent } from "svelte";
import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte";
import ConsoleDisplayStateView from "$lib/workspace/console/ConsoleDisplayState.svelte";
@@ -61,18 +62,20 @@
import { workspaceMultiplexer, type WorkspaceMultiplexerSubscription } from "$lib/workspace/multiplexer";
import {
isCurrentWorkerSessionRequest,
resolveWorkerSessionTarget,
workerSessionAction,
workerSessionRequestInit,
type WorkerSessionObservation,
type WorkerSessionRequestIdentity,
type WorkerSessionTarget,
} from "$lib/workspace/session-observation";
import type { Diagnostic, Worker } from "$lib/workspace/sidebar/types";
type Props = {
data: {
workspaceId: string;
runtimeId: string;
workerId: string;
runtimeId: string | null;
workerId: string | null;
worker: Worker | null;
workerError: string | null;
};
@@ -201,8 +204,8 @@
(SvelteComponent & ComposerInputHandle) | null
>(null);
const composerDrafts = new Map<string, ComposerDraftCache>();
let activeComposerTargetKey = untrack(
() => `${workspaceId}:${runtimeId}:${workerId}`,
let activeComposerTargetKey = untrack(() =>
runtimeId && workerId ? `${workspaceId}:${runtimeId}:${workerId}` : "",
);
let timelineRailDragCleanup: (() => void) | null = null;
let autoFollowConsole = $state(true);
@@ -231,15 +234,13 @@
let nextReloadToken = 0;
let reloadToken = $state(0);
type ConsoleTarget = {
workspaceId: string;
runtimeId: string;
workerId: string;
};
type ConsoleTarget = WorkerSessionTarget;
const consoleTarget = $derived({ workspaceId, runtimeId, workerId });
const consoleTarget = $derived(
resolveWorkerSessionTarget(workspaceId, runtimeId, workerId),
);
const controlAlertId = $derived(
`worker-console-control:${runtimeId}:${workerId}`,
`worker-console-control:${runtimeId ?? "unresolved"}:${workerId ?? "unresolved"}`,
);
const workerViews = $derived(consoleWorkerViews(consoleProjection));
@@ -405,6 +406,10 @@
const target = consoleTarget;
beginConsoleLoad();
const token = advanceReloadToken();
if (!target) {
void invalidateAll();
return;
}
if (!worker) void loadWorker(target, token);
}
@@ -837,7 +842,11 @@
}
function attachmentPath(): string {
return `/api/w/${encodeURIComponent(workspaceId)}/runtimes/${encodeURIComponent(runtimeId)}/workers/${encodeURIComponent(workerId)}`;
const target = consoleTarget;
if (!target) {
throw new Error("Worker execution target is unavailable.");
}
return `/api/w/${encodeURIComponent(target.workspaceId)}/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}`;
}
function updateAttachment(id: number, update: Partial<ComposerAttachment>): void {
@@ -1059,11 +1068,11 @@
return (await response.json()) as WorkerSessionObservation;
})
.then((observation) => {
const currentTarget = consoleTarget;
if (!currentTarget) return;
const currentIdentity: WorkerSessionRequestIdentity = {
token: reloadToken,
workspaceId,
runtimeId,
workerId,
...currentTarget,
};
if (
!isCurrentWorkerSessionRequest(requestIdentity, currentIdentity) ||
@@ -1826,7 +1835,6 @@
$effect(() => {
const target = consoleTarget;
switchComposerTarget(target);
const targetWorker = data.worker;
const targetWorkerError = data.workerError;
workerViewSelectionGeneration += 1;
@@ -1839,9 +1847,22 @@
workerError = targetWorkerError;
liveWorkerState = targetWorker?.state ?? null;
streamDiagnostics = [];
protocolState = "connecting";
consoleDisplayState = { kind: "loading", stage: "session" };
const token = advanceReloadToken();
if (!target) {
untrack(discardAllAttachments);
protocolState = "error";
consoleDisplayState = {
kind: "unavailable",
reason: boundedConsoleReason(
targetWorkerError,
"Worker execution target is unavailable.",
),
};
return;
}
switchComposerTarget(target);
protocolState = "connecting";
if (!targetWorker) void loadWorker(target, token);
});
@@ -1853,6 +1874,7 @@
const target = consoleTarget;
const targetWorker = worker;
const token = reloadToken;
if (!target) return;
if (
targetWorker &&
(targetWorker.runtime_id !== target.runtimeId ||
@@ -27,15 +27,17 @@ export const load = (async ({ fetch, params }) => {
if (params.workerRef !== canonical) {
redirect(
308,
`/w/${encodeURIComponent(params.workspaceId)}/workers/${encodeURIComponent(canonical)}/console`,
`/w/${encodeURIComponent(params.workspaceId)}/workers/${
encodeURIComponent(canonical)
}/console`,
);
}
}
return {
workspaceId: params.workspaceId,
runtimeId: result.data?.runtime_id ?? "",
workerId: result.data?.worker_id ?? "",
runtimeId: result.data?.runtime_id ?? null,
workerId: result.data?.worker_id ?? null,
worker: result.data,
workerError: result.error,
};
@@ -167,6 +167,29 @@ afterEach(() => {
vi.restoreAllMocks();
});
test("does not call Runtime APIs before the Worker execution target resolves", async () => {
const request = vi.fn();
vi.stubGlobal("fetch", request);
render(ConsolePage, {
data: {
workspaceId: "workspace-a",
runtimeId: null,
workerId: null,
worker: null,
workerError: "Worker API request failed with HTTP 404",
},
});
const unavailable = await screen.findByRole("alert");
expect(unavailable.textContent).toContain("Conversation unavailable");
expect(unavailable.textContent).toContain(
"Worker API request failed with HTTP 404",
);
await settleMicrotasks();
expect(request).not.toHaveBeenCalled();
expect(multiplexer.subscribe).not.toHaveBeenCalled();
});
test("keeps delayed Session and live initial snapshot work in loading state until projection applies", async () => {
const sessionResponse = deferred<Response>();
vi.stubGlobal("fetch", vi.fn(() => sessionResponse.promise));