Skip to content
Draft
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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,7 @@ not open the PR against the fork unless the user explicitly asks you to.
| [docs/architecture/prompt-evaluations.md](docs/architecture/prompt-evaluations.md) | Static prompt quality, datasets, commands, and maintenance rules |
| [docs/architecture/data-organization-projects.md](docs/architecture/data-organization-projects.md) | Projects (a single container) to isolate/group data within a cluster; composes with data-tags and auth-rbac |
| [docs/architecture/auth-rbac.md](docs/architecture/auth-rbac.md) | Explicit-login IdP auth, Redis user-access cache, Portal handshake; deferred RBAC/internal-token roadmap |
| [docs/architecture/authenticated-media.md](docs/architecture/authenticated-media.md) | Client-bound authenticated video delivery, progressive ranges, lifecycle and browser/deployment acceptance |
| [docs/architecture/vscode-web-worker.md](docs/architecture/vscode-web-worker.md) | XState chat machine, GitHub auth flow, ARIA snapshots |
| [docs/architecture/token-manager.md](docs/architecture/token-manager.md) | Token storage, validation, round-robin distribution |
| [docs/architecture/criteria-provider.md](docs/architecture/criteria-provider.md) | CriteriaProvider abstraction, filesystem vs REST backends |
Expand Down
30 changes: 30 additions & 0 deletions NOTICE

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

82 changes: 81 additions & 1 deletion apps/api/src/routes/requests/video.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
* - GET /api/v1/requests/:id/runs/:runId/video
*/
import { describe, it, expect, beforeAll, beforeEach, vi } from "vitest";
import { PassThrough } from "node:stream";
import { get } from "node:http";
import supertest from "supertest";
import { app, _injectTestDependencies } from "../../index.js";
import { useTestServer } from "../../test-server.js";
Expand Down Expand Up @@ -84,6 +86,8 @@ describe("Video endpoints", () => {
expect(res.status).toBe(200);
expect(res.headers["content-type"]).toMatch("video/webm");
expect(res.headers["accept-ranges"]).toBe("bytes");
expect(res.headers["cache-control"]).toBe("no-store");
expect(res.headers["content-length"]).toBe("17");
expect(mockGetBlockBlobClient).toHaveBeenCalledWith("req-1/video-0.webm");
});

Expand Down Expand Up @@ -137,7 +141,43 @@ describe("Video endpoints", () => {
expect(res.status).toBe(206);
expect(res.headers["content-range"]).toBe("bytes 0-499/1000");
expect(res.headers["content-length"]).toBe("500");
expect(mockDownload).toHaveBeenCalledWith(0, 500);
expect(mockDownload).toHaveBeenCalledWith(0, 500, { abortSignal: expect.any(AbortSignal) });
});

it.each([
["bytes=5-", 5, 16],
["bytes=-5", 12, 16],
["bytes=10-9999", 10, 16],
["bytes=-9999", 0, 16],
])("honors the bounded range %s", async (header, start, end) => {
variant.mockRequest(mocks, "req-1", { _id: "run-1", status: "done", videoUrls: [blobUrl("req-1/video-0.webm")] });
mockDownload.mockResolvedValue({ readableStreamBody: readableFrom("a".repeat(end - start + 1)) });
const res = await supertest(testServer()).get(vidUrl("req-1")).set("Range", header).buffer(true).parse(rawParser);
expect(res.status).toBe(206);
expect(res.headers["content-range"]).toBe(`bytes ${start}-${end}/17`);
expect(res.headers["content-length"]).toBe(String(end - start + 1));
expect(res.headers["cache-control"]).toBe("no-store");
expect(mockDownload).toHaveBeenCalledWith(start, end - start + 1, { abortSignal: expect.any(AbortSignal) });
});

it.each(["bytes=17-", "bytes=9-2", "bytes=-0", "bytes=-", "bytes=0-1,3-4", "junk bytes=0-1", "bytes=9007199254740992-"])(
"rejects an invalid or unsatisfiable range %s without downloading", async header => {
variant.mockRequest(mocks, "req-1", { _id: "run-1", status: "done", videoUrls: [blobUrl("req-1/video-0.webm")] });
const res = await supertest(testServer()).get(vidUrl("req-1")).set("Range", header);
expect(res.status).toBe(416);
expect(res.headers["content-range"]).toBe("bytes */17");
expect(res.headers["accept-ranges"]).toBe("bytes");
expect(mockDownload).not.toHaveBeenCalled();
},
);

it("returns 416 for a range on an empty recording", async () => {
variant.mockRequest(mocks, "req-1", { _id: "run-1", status: "done", videoUrls: [blobUrl("req-1/video-0.webm")] });
mockGetProperties.mockResolvedValue({ contentLength: 0 });
const res = await supertest(testServer()).get(vidUrl("req-1")).set("Range", "bytes=0-");
expect(res.status).toBe(416);
expect(res.headers["content-range"]).toBe("bytes */0");
expect(mockDownload).not.toHaveBeenCalled();
});

it("returns 400 for invalid iteration number", async () => {
Expand All @@ -146,5 +186,45 @@ describe("Video endpoints", () => {
expect(res.status).toBe(400);
expect(res.body).toMatchObject({ error: "Invalid iteration number" });
});

it.each(["index=1junk", "index=0.5", "index=9007199254740992", "index=1&index=0", "iteration=1junk", "iteration=0", "phase=unknown"])(
"rejects an ambiguous video selector %s", async query => {
variant.mockRequest(mocks, "req-1", { _id: "run-1", status: "done", videoUrls: [blobUrl("req-1/video-0.webm")] });
const res = await supertest(testServer()).get(vidUrl("req-1", `?${query}`));
expect(res.status).toBe(400);
expect(res.headers["cache-control"]).toBe("no-store");
expect(mockDownload).not.toHaveBeenCalled();
},
);

it("aborts blob streaming when the native player disconnects during a range", async () => {
variant.mockRequest(mocks, "req-1", { _id: "run-1", status: "done", videoUrls: [blobUrl("req-1/video-0.webm")] });
const body = new PassThrough();
let upstreamSignal: AbortSignal | undefined;
mockDownload.mockImplementation(async (_start, _count, options: { abortSignal: AbortSignal }) => {
upstreamSignal = options.abortSignal;
return { readableStreamBody: body };
});
const address = testServer().address();
if (!address || typeof address === "string") throw new Error("Test server has no TCP address.");
const request = get({
hostname: "127.0.0.1", port: address.port, path: vidUrl("req-1"), headers: { Range: "bytes=0-16" },
});
const received = new Promise<void>((resolve, reject) => {
request.once("error", reject);
request.once("response", response => response.once("data", () => resolve()));
});
try {
await vi.waitFor(() => expect(upstreamSignal).toBeDefined());
body.write("first");
await received;
request.destroy();
await vi.waitFor(() => expect(upstreamSignal?.aborted).toBe(true));
await vi.waitFor(() => expect(body.destroyed).toBe(true));
} finally {
request.destroy();
body.destroy();
}
});
});
});
124 changes: 67 additions & 57 deletions apps/api/src/routes/requests/video.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,49 +5,70 @@ import { RestError } from "@azure/storage-blob";
import { z } from "zod";
import type { RunState } from "shared";
import type { Request, Response } from "express";
import { pipeline } from "node:stream/promises";
import { apiRoute } from "../../openapi/api-route.js";
import type { RouteContext } from "../../route-context.js";
import { resolveRunForRequest } from "./resolve-run.js";
import { createBlobServiceClient, extractBlobName } from "./blob-helpers.js";

export function parseVideoRange(header: string, size: number): { start: number; end: number } | undefined {
const match = /^bytes=(\d*)-(\d*)$/.exec(header);
if (!match || !size || (!match[1] && !match[2])) return;
const first = match[1] ? Number(match[1]) : undefined;
const last = match[2] ? Number(match[2]) : undefined;
if ((first !== undefined && !Number.isSafeInteger(first)) || (last !== undefined && !Number.isSafeInteger(last))) return;
if (first === undefined) {
if (!last) return;
return { start: Math.max(0, size - last), end: size - 1 };
}
if (first >= size || (last !== undefined && last < first)) return;
return { start: first, end: Math.min(last ?? size - 1, size - 1) };
}

async function handleVideo(
ctx: RouteContext,
req: Request,
res: Response,
targetRun: RunState,
targetRun: RunState | undefined,
id: string,
): Promise<void> {
const iterationParam = req.query.iteration as string | undefined;
const phaseParam = req.query.phase as string | undefined;
const indexParam = req.query.index as string | undefined;
const videoIndex = indexParam ? parseInt(indexParam, 10) : 0;

if (isNaN(videoIndex) || videoIndex < 0) {
res.setHeader("Cache-Control", "no-store");
const iterationParam = req.query.iteration;
const phaseParam = req.query.phase;
const indexParam = req.query.index;
const videoIndex = indexParam === undefined ? 0
: typeof indexParam === "string" && /^\d+$/.test(indexParam) ? Number(indexParam) : NaN;
if (!Number.isSafeInteger(videoIndex) || videoIndex < 0) {
res.status(400).json({ error: "Invalid video index" });
return;
}
if (phaseParam !== undefined && phaseParam !== "setup") {
res.status(400).json({ error: "Invalid video phase" });
return;
}
if (iterationParam !== undefined && (typeof iterationParam !== "string" ||
!/^[1-9]\d*$/.test(iterationParam) || !Number.isSafeInteger(Number(iterationParam)))) {
res.status(400).json({ error: "Invalid iteration number" });
return;
}

// Determine the videoUrls array — from setup, a specific turn, or from the top-level document
let videoUrls: string[] | undefined;
let label: string;

if (phaseParam === "setup") {
videoUrls = targetRun.setupVideoUrls;
videoUrls = targetRun?.setupVideoUrls;
label = `${id}-setup-video-${videoIndex}`;
} else if (iterationParam) {
const iterNum = parseInt(iterationParam, 10);
if (isNaN(iterNum) || iterNum < 1) {
res.status(400).json({ error: "Invalid iteration number" });
return;
}
const turns = targetRun.turns;
const iterNum = Number(iterationParam);
const turns = targetRun?.turns;
const turn = turns?.find((t: { iteration: number }) => t.iteration === iterNum);
videoUrls = turn?.videoUrls;
label = `${id}-iteration-${iterNum}-video-${videoIndex}`;
} else {
// One-shot: videoUrls on run; multi-turn fallback: last turn
const turns = targetRun.turns;
videoUrls = targetRun.videoUrls ?? turns?.[turns.length - 1]?.videoUrls;
const turns = targetRun?.turns;
videoUrls = targetRun?.videoUrls ?? turns?.[turns.length - 1]?.videoUrls;
label = `${id}-video-${videoIndex}`;
}

Expand All @@ -73,49 +94,38 @@ async function handleVideo(
const containerClient = blobServiceClient.getContainerClient("snapshots");
const blobClient = containerClient.getBlockBlobClient(blobName);

// Get blob properties for content length
const properties = await blobClient.getProperties();
const totalSize = properties.contentLength ?? 0;

// Support HTTP Range requests for video seeking
const rangeHeader = req.headers.range;
if (rangeHeader && totalSize > 0) {
const match = rangeHeader.match(/bytes=(\d+)-(\d*)/);
if (match) {
const start = parseInt(match[1], 10);
const end = match[2] ? parseInt(match[2], 10) : totalSize - 1;
const chunkSize = end - start + 1;

const downloadResponse = await blobClient.download(start, chunkSize);
if (!downloadResponse.readableStreamBody) {
res.status(500).json({ error: "Failed to download video file" });
return;
}

res.status(206);
res.setHeader("Content-Type", "video/webm");
res.setHeader("Content-Range", `bytes ${start}-${end}/${totalSize}`);
res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Content-Length", chunkSize);
downloadResponse.readableStreamBody.pipe(res);
const controller = new AbortController();
const disconnected = () => { if (!res.writableFinished) controller.abort(); };
res.once("close", disconnected);
if (req.aborted || res.destroyed) controller.abort();
try {
const properties = await blobClient.getProperties({ abortSignal: controller.signal });
const totalSize = properties.contentLength ?? 0;
const rangeHeader = req.headers.range;
const range = rangeHeader === undefined ? undefined : parseVideoRange(rangeHeader, totalSize);
res.setHeader("Accept-Ranges", "bytes");
if (rangeHeader !== undefined && !range) {
res.setHeader("Content-Range", `bytes */${totalSize}`);
res.status(416).json({ error: "Requested video range is not satisfiable" });
return;
}
const count = range ? range.end - range.start + 1 : undefined;
const downloadResponse = await blobClient.download(range?.start ?? 0, count, { abortSignal: controller.signal });
if (!downloadResponse.readableStreamBody) {
res.status(500).json({ error: "Failed to download video file" });
return;
}
res.status(range ? 206 : 200);
res.setHeader("Content-Type", "video/webm");
res.setHeader("Content-Disposition", `inline; filename="${label}.webm"`);
res.setHeader("Content-Length", count ?? totalSize);
if (range) res.setHeader("Content-Range", `bytes ${range.start}-${range.end}/${totalSize}`);
await pipeline(downloadResponse.readableStreamBody, res, { signal: controller.signal });
} catch (error) {
if (!controller.signal.aborted) throw error;
} finally {
res.removeListener("close", disconnected);
}

const downloadResponse = await blobClient.download();
if (!downloadResponse.readableStreamBody) {
res.status(500).json({ error: "Failed to download video file" });
return;
}

res.setHeader("Content-Type", "video/webm");
res.setHeader("Accept-Ranges", "bytes");
res.setHeader("Content-Disposition", `inline; filename="${label}.webm"`);
if (totalSize > 0) {
res.setHeader("Content-Length", totalSize);
}

downloadResponse.readableStreamBody.pipe(res);
}

export function registerRequestsVideoRoutes(ctx: RouteContext): void {
Expand All @@ -141,7 +151,7 @@ apiRoute(ctx.app, ctx.registry, {
return;
}

await handleVideo(ctx, req, res, resource.run!, id);
await handleVideo(ctx, req, res, resource.run, id);
} catch (error) {
if (error instanceof RestError && (error.statusCode === 404 || error.code === "ContainerNotFound" || error.code === "BlobNotFound")) {
res.status(404).json({ error: "Video file not found — the blob may have been deleted or is no longer available" });
Expand Down
13 changes: 13 additions & 0 deletions apps/portal/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,19 @@ server {
root /usr/share/nginx/html;
index index.html;

location = /scope-media-worker.js {
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
try_files $uri =404;
}

# Uncontrolled/expired media URLs must never fall through to the SPA.
location ^~ /__scope_media__/ {
add_header Cache-Control "no-store" always;
add_header X-Content-Type-Options "nosniff" always;
return 403;
}

# API reverse proxy — same origin
location /api/ {
proxy_pass http://api:80;
Expand Down
1 change: 1 addition & 0 deletions apps/portal/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dagre": "^0.8.5",
"eventsource-parser": "3.0.6",
"jsqr": "^1.4.0",
"ky": "^2.0.2",
"lucide-react": "^0.469.0",
Expand Down
Loading
Loading