fix: reject unresolved worker console targets
This commit is contained in:
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
+23
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user