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("resourceKey(params.workerRef)") &&
routeLoad.includes("/workers/${encodeURIComponent(reference)}") && routeLoad.includes("/workers/${encodeURIComponent(reference)}") &&
routeLoad.includes("canonicalResourceReference") && routeLoad.includes("canonicalResourceReference") &&
routeLoad.includes("runtimeId: result.data?.runtime_id") && routeLoad.includes("runtimeId: result.data?.runtime_id ?? null") &&
routeLoad.includes("workerId: result.data?.worker_id"), routeLoad.includes("workerId: result.data?.worker_id ?? null") &&
"route load should resolve the logical Worker reference before exposing its execution target", !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( assert(
consolePage.includes("workspaceApiPath(workspaceId, path)") && 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("const targetWorker = data.worker") &&
consolePage.includes("worker = targetWorker") && consolePage.includes("worker = targetWorker") &&
consolePage.includes( 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 { import {
isCurrentWorkerSessionRequest, isCurrentWorkerSessionRequest,
resolveWorkerSessionTarget,
workerSessionAction, workerSessionAction,
type WorkerSessionRequestIdentity, type WorkerSessionRequestIdentity,
workerSessionRequestInit, workerSessionRequestInit,
@@ -24,6 +25,30 @@ const snapshot: SessionSnapshot = {
entries: [], 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", () => { Deno.test("worker session request includes same-origin credentials", () => {
const controller = new AbortController(); const controller = new AbortController();
const init = workerSessionRequestInit(controller.signal); const init = workerSessionRequestInit(controller.signal);
@@ -10,13 +10,33 @@ export type WorkerSessionAction =
| { kind: "apply_retained"; snapshot: SessionSnapshot } | { kind: "apply_retained"; snapshot: SessionSnapshot }
| { kind: "show_unavailable"; message: string }; | { kind: "show_unavailable"; message: string };
export interface WorkerSessionRequestIdentity { export interface WorkerSessionTarget {
token: number;
workspaceId: string; workspaceId: string;
runtimeId: string; runtimeId: string;
workerId: 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 { export function workerSessionRequestInit(signal: AbortSignal): RequestInit {
return { return {
signal, signal,
@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { invalidateAll } from "$app/navigation";
import { tick, untrack, type SvelteComponent } from "svelte"; import { tick, untrack, type SvelteComponent } from "svelte";
import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte"; import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte";
import ConsoleDisplayStateView from "$lib/workspace/console/ConsoleDisplayState.svelte"; import ConsoleDisplayStateView from "$lib/workspace/console/ConsoleDisplayState.svelte";
@@ -61,18 +62,20 @@
import { workspaceMultiplexer, type WorkspaceMultiplexerSubscription } from "$lib/workspace/multiplexer"; import { workspaceMultiplexer, type WorkspaceMultiplexerSubscription } from "$lib/workspace/multiplexer";
import { import {
isCurrentWorkerSessionRequest, isCurrentWorkerSessionRequest,
resolveWorkerSessionTarget,
workerSessionAction, workerSessionAction,
workerSessionRequestInit, workerSessionRequestInit,
type WorkerSessionObservation, type WorkerSessionObservation,
type WorkerSessionRequestIdentity, type WorkerSessionRequestIdentity,
type WorkerSessionTarget,
} from "$lib/workspace/session-observation"; } from "$lib/workspace/session-observation";
import type { Diagnostic, Worker } from "$lib/workspace/sidebar/types"; import type { Diagnostic, Worker } from "$lib/workspace/sidebar/types";
type Props = { type Props = {
data: { data: {
workspaceId: string; workspaceId: string;
runtimeId: string; runtimeId: string | null;
workerId: string; workerId: string | null;
worker: Worker | null; worker: Worker | null;
workerError: string | null; workerError: string | null;
}; };
@@ -201,8 +204,8 @@
(SvelteComponent & ComposerInputHandle) | null (SvelteComponent & ComposerInputHandle) | null
>(null); >(null);
const composerDrafts = new Map<string, ComposerDraftCache>(); const composerDrafts = new Map<string, ComposerDraftCache>();
let activeComposerTargetKey = untrack( let activeComposerTargetKey = untrack(() =>
() => `${workspaceId}:${runtimeId}:${workerId}`, runtimeId && workerId ? `${workspaceId}:${runtimeId}:${workerId}` : "",
); );
let timelineRailDragCleanup: (() => void) | null = null; let timelineRailDragCleanup: (() => void) | null = null;
let autoFollowConsole = $state(true); let autoFollowConsole = $state(true);
@@ -231,15 +234,13 @@
let nextReloadToken = 0; let nextReloadToken = 0;
let reloadToken = $state(0); let reloadToken = $state(0);
type ConsoleTarget = { type ConsoleTarget = WorkerSessionTarget;
workspaceId: string;
runtimeId: string;
workerId: string;
};
const consoleTarget = $derived({ workspaceId, runtimeId, workerId }); const consoleTarget = $derived(
resolveWorkerSessionTarget(workspaceId, runtimeId, workerId),
);
const controlAlertId = $derived( const controlAlertId = $derived(
`worker-console-control:${runtimeId}:${workerId}`, `worker-console-control:${runtimeId ?? "unresolved"}:${workerId ?? "unresolved"}`,
); );
const workerViews = $derived(consoleWorkerViews(consoleProjection)); const workerViews = $derived(consoleWorkerViews(consoleProjection));
@@ -405,6 +406,10 @@
const target = consoleTarget; const target = consoleTarget;
beginConsoleLoad(); beginConsoleLoad();
const token = advanceReloadToken(); const token = advanceReloadToken();
if (!target) {
void invalidateAll();
return;
}
if (!worker) void loadWorker(target, token); if (!worker) void loadWorker(target, token);
} }
@@ -837,7 +842,11 @@
} }
function attachmentPath(): string { 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 { function updateAttachment(id: number, update: Partial<ComposerAttachment>): void {
@@ -1059,11 +1068,11 @@
return (await response.json()) as WorkerSessionObservation; return (await response.json()) as WorkerSessionObservation;
}) })
.then((observation) => { .then((observation) => {
const currentTarget = consoleTarget;
if (!currentTarget) return;
const currentIdentity: WorkerSessionRequestIdentity = { const currentIdentity: WorkerSessionRequestIdentity = {
token: reloadToken, token: reloadToken,
workspaceId, ...currentTarget,
runtimeId,
workerId,
}; };
if ( if (
!isCurrentWorkerSessionRequest(requestIdentity, currentIdentity) || !isCurrentWorkerSessionRequest(requestIdentity, currentIdentity) ||
@@ -1826,7 +1835,6 @@
$effect(() => { $effect(() => {
const target = consoleTarget; const target = consoleTarget;
switchComposerTarget(target);
const targetWorker = data.worker; const targetWorker = data.worker;
const targetWorkerError = data.workerError; const targetWorkerError = data.workerError;
workerViewSelectionGeneration += 1; workerViewSelectionGeneration += 1;
@@ -1839,9 +1847,22 @@
workerError = targetWorkerError; workerError = targetWorkerError;
liveWorkerState = targetWorker?.state ?? null; liveWorkerState = targetWorker?.state ?? null;
streamDiagnostics = []; streamDiagnostics = [];
protocolState = "connecting";
consoleDisplayState = { kind: "loading", stage: "session" }; consoleDisplayState = { kind: "loading", stage: "session" };
const token = advanceReloadToken(); 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); if (!targetWorker) void loadWorker(target, token);
}); });
@@ -1853,6 +1874,7 @@
const target = consoleTarget; const target = consoleTarget;
const targetWorker = worker; const targetWorker = worker;
const token = reloadToken; const token = reloadToken;
if (!target) return;
if ( if (
targetWorker && targetWorker &&
(targetWorker.runtime_id !== target.runtimeId || (targetWorker.runtime_id !== target.runtimeId ||
@@ -27,15 +27,17 @@ export const load = (async ({ fetch, params }) => {
if (params.workerRef !== canonical) { if (params.workerRef !== canonical) {
redirect( redirect(
308, 308,
`/w/${encodeURIComponent(params.workspaceId)}/workers/${encodeURIComponent(canonical)}/console`, `/w/${encodeURIComponent(params.workspaceId)}/workers/${
encodeURIComponent(canonical)
}/console`,
); );
} }
} }
return { return {
workspaceId: params.workspaceId, workspaceId: params.workspaceId,
runtimeId: result.data?.runtime_id ?? "", runtimeId: result.data?.runtime_id ?? null,
workerId: result.data?.worker_id ?? "", workerId: result.data?.worker_id ?? null,
worker: result.data, worker: result.data,
workerError: result.error, workerError: result.error,
}; };
@@ -167,6 +167,29 @@ afterEach(() => {
vi.restoreAllMocks(); 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 () => { test("keeps delayed Session and live initial snapshot work in loading state until projection applies", async () => {
const sessionResponse = deferred<Response>(); const sessionResponse = deferred<Response>();
vi.stubGlobal("fetch", vi.fn(() => sessionResponse.promise)); vi.stubGlobal("fetch", vi.fn(() => sessionResponse.promise));