From ec679a8f52ea4c29548a5e2e2c5f005f3ed62fa7 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 13 Nov 2026 11:14:50 -0800 Subject: [PATCH 1/2] fix(cloud): sandbox-control typecheck to zero (SANDBOX-CONTROL-TYPECHECK) --- .../image/golden-image.test.ts | 2 +- cloud/apps/sandbox-control/package.json | 2 +- .../sandbox-control/scripts/typecheck.mjs | 52 +++++++++++++++++++ .../sandbox-control/src/agent-credentials.ts | 2 +- cloud/apps/sandbox-control/src/store.ts | 2 + cloud/apps/sandbox-control/src/tar-gzip.ts | 2 +- cloud/apps/sandbox-control/src/workspaces.ts | 8 +-- .../sandbox-control/test/api-sessions.test.ts | 2 +- .../apps/sandbox-control/test/fake-vercel.ts | 5 +- .../test/secrets-hardening.test.ts | 2 +- .../test/typecheck-script.test.ts | 29 +++++++++++ cloud/apps/sandbox-control/tsconfig.json | 22 ++++++-- .../sandbox-control/types/xterm-theme.d.ts | 34 ++++++++++++ 13 files changed, 149 insertions(+), 15 deletions(-) create mode 100644 cloud/apps/sandbox-control/scripts/typecheck.mjs create mode 100644 cloud/apps/sandbox-control/test/typecheck-script.test.ts create mode 100644 cloud/apps/sandbox-control/types/xterm-theme.d.ts diff --git a/cloud/apps/sandbox-control/image/golden-image.test.ts b/cloud/apps/sandbox-control/image/golden-image.test.ts index 5ed39285fb2..27818b28ee0 100644 --- a/cloud/apps/sandbox-control/image/golden-image.test.ts +++ b/cloud/apps/sandbox-control/image/golden-image.test.ts @@ -189,7 +189,7 @@ describe('Worker-embedded image scripts', () => { it('installs every VM script and makes zsh read the session env hook once', () => { const provision = readFileSync(fileURLToPath(new URL('./provision-golden.sh', import.meta.url).href), 'utf8') - const loop = provision.match(/^for name in (.+); do$/m)?.[1].split(' ') ?? [] + const loop = provision.match(/^for name in (.+); do$/m)?.[1]?.split(' ') ?? [] expect(new Set(loop)).toEqual(new Set(VM_SCRIPTS)) expect(provision).toContain( "grep -q zz-orca-session-env /etc/zsh/zshenv ||\n echo '[ -r /etc/profile.d/zz-orca-session-env.sh ] && . /etc/profile.d/zz-orca-session-env.sh' | sudo tee -a /etc/zsh/zshenv" diff --git a/cloud/apps/sandbox-control/package.json b/cloud/apps/sandbox-control/package.json index 0b519f0dbdb..57c811fdca8 100644 --- a/cloud/apps/sandbox-control/package.json +++ b/cloud/apps/sandbox-control/package.json @@ -7,7 +7,7 @@ "dev": "wrangler dev -c wrangler.jsonc -c wrangler.build-runner.jsonc --port 8791 --ip 127.0.0.1", "db:migrate:local": "wrangler d1 migrations apply SANDBOX_DB --local", "golden-image": "tsx image/build-golden-image.ts", - "typecheck": "tsc --noEmit", + "typecheck": "node scripts/typecheck.mjs", "test": "vitest run" }, "dependencies": { diff --git a/cloud/apps/sandbox-control/scripts/typecheck.mjs b/cloud/apps/sandbox-control/scripts/typecheck.mjs new file mode 100644 index 00000000000..b15de8792af --- /dev/null +++ b/cloud/apps/sandbox-control/scripts/typecheck.mjs @@ -0,0 +1,52 @@ +// SANDBOX-CONTROL-TYPECHECK: `pnpm typecheck` for this Worker. tsc checks every file the program reaches, and +// the Worker imports desktop code from src/shared (wire types, parsers). Those files are written for the +// desktop's compiler options (no noUncheckedIndexedAccess) and globals (window, MessageChannel), and the +// desktop's own typecheck covers them; TypeScript has no per-file options. So this fails on any diagnostic in +// this package's own files, or one tsc reports without a file (a config error), and counts the rest. +// node scripts/typecheck.mjs +import { spawnSync } from 'node:child_process' +import { createRequire } from 'node:module' +import { fileURLToPath } from 'node:url' + +/** Splits tsc --pretty false output into this package's diagnostics (with their detail lines) and a count of others. */ +export function splitDiagnostics(output) { + const own = [] + let foreign = 0 + let keep = false + for (const line of output.split('\n')) { + const located = /^(.+?)\(\d+,\d+\): error TS\d+/.exec(line) + if (located) { + // Paths are relative to this package: anything reached through `..` is not its own. + keep = !located[1].startsWith('..') && !located[1].startsWith('/') + if (keep) { + own.push(line) + } else { + foreign++ + } + } else if (/^error TS\d+/.test(line)) { + keep = true + own.push(line) + } else if (keep && line.startsWith(' ') && line.trim()) { + own.push(line) + } else if (line.trim()) { + keep = false + } + } + return { own, foreign } +} + +if (process.argv[1] === fileURLToPath(import.meta.url)) { + const tsc = createRequire(import.meta.url).resolve('typescript/bin/tsc') + const run = spawnSync(process.execPath, [tsc, '--noEmit', '-p', '.', '--pretty', 'false'], { encoding: 'utf8' }) + const { own, foreign } = splitDiagnostics(`${run.stdout ?? ''}${run.stderr ?? ''}`) + if (own.length > 0) { + process.stdout.write(`${own.join('\n')}\n`) + } + if (foreign > 0) { + process.stdout.write(`(${foreign} diagnostics in imported desktop files: checked by the desktop typecheck, not here)\n`) + } + const ownErrors = own.filter((line) => / error TS\d+/.test(line) || /^error TS\d+/.test(line)).length + process.stdout.write(`sandbox-control typecheck: ${ownErrors} error(s) in its own files\n`) + // A crash, or a non-zero exit with nothing parsed, is never a pass. + process.exit(ownErrors > 0 || run.status === null || (run.status !== 0 && foreign === 0) ? 1 : 0) +} diff --git a/cloud/apps/sandbox-control/src/agent-credentials.ts b/cloud/apps/sandbox-control/src/agent-credentials.ts index 6abcb744ddf..9467871548a 100644 --- a/cloud/apps/sandbox-control/src/agent-credentials.ts +++ b/cloud/apps/sandbox-control/src/agent-credentials.ts @@ -69,7 +69,7 @@ export function providerEnabled(env: Pick | null, now: number): CredentialState { +export function credentialState(row: Pick | null, now: number): CredentialState { if (!row) { return 'none' } diff --git a/cloud/apps/sandbox-control/src/store.ts b/cloud/apps/sandbox-control/src/store.ts index 8e57a23de77..a5c3bab676a 100644 --- a/cloud/apps/sandbox-control/src/store.ts +++ b/cloud/apps/sandbox-control/src/store.ts @@ -16,6 +16,8 @@ export type SandboxStatus = export type SandboxRow = { id: string + /** What made a machine-made workspace (a routine run: its routine's id); null for desktop creates. */ + origin_ref?: string | null account_id: string user_id: string workspace_id: string diff --git a/cloud/apps/sandbox-control/src/tar-gzip.ts b/cloud/apps/sandbox-control/src/tar-gzip.ts index c9ff3a940b7..d973542abb3 100644 --- a/cloud/apps/sandbox-control/src/tar-gzip.ts +++ b/cloud/apps/sandbox-control/src/tar-gzip.ts @@ -63,6 +63,6 @@ export async function tarGzipFile(input: { dirs: string[]; path: string; bytes: new Uint8Array(padding), new Uint8Array(BLOCK * 2) // end-of-archive marker ] - const stream = new Blob(parts as BlobPart[]).stream().pipeThrough(new CompressionStream('gzip')) + const stream = new Blob(parts).stream().pipeThrough(new CompressionStream('gzip')) return new Response(stream).arrayBuffer() } diff --git a/cloud/apps/sandbox-control/src/workspaces.ts b/cloud/apps/sandbox-control/src/workspaces.ts index ec27a5c1248..a781111a536 100644 --- a/cloud/apps/sandbox-control/src/workspaces.ts +++ b/cloud/apps/sandbox-control/src/workspaces.ts @@ -307,7 +307,7 @@ export async function listWorkspaces(db: D1Database, caller: SandboxCaller, url: /** sandbox id -> its routine's name, for routine runs being listed. */ async function routineNamesFor(db: D1Database, accountId: string, rows: readonly SandboxRow[]): Promise> { - const refs = [...new Set(rows.flatMap((row) => ((row as { origin_ref?: string | null }).origin_ref ? [(row as { origin_ref: string }).origin_ref] : [])))] + const refs = [...new Set(rows.flatMap((row) => (row.origin_ref ? [row.origin_ref] : [])))] if (refs.length === 0) { return new Map() } @@ -318,7 +318,7 @@ async function routineNamesFor(db: D1Database, accountId: string, rows: readonly const byRoutine = new Map(results.map((r) => [r.id, r.name])) const out = new Map() for (const row of rows) { - const name = byRoutine.get((row as { origin_ref?: string | null }).origin_ref ?? '') + const name = byRoutine.get(row.origin_ref ?? '') if (name) { out.set(row.id, name) } @@ -438,8 +438,8 @@ export async function patchWorkspace( } const ownerChanged = transferring const wentPrivate = row.visibility === 'team' && next.visibility === 'private' - const archived = patch.archived === true && row.archived_at === null - return { row: next, revoke: ownerChanged ? 'all_but_new_owner' : wentPrivate || archived ? 'non_owner' : 'none' } + // Archiving goes through the lifecycle (archive_via_lifecycle above); the team route revokes on archive itself. + return { row: next, revoke: ownerChanged ? 'all_but_new_owner' : wentPrivate ? 'non_owner' : 'none' } } export const PresenceSchema = z diff --git a/cloud/apps/sandbox-control/test/api-sessions.test.ts b/cloud/apps/sandbox-control/test/api-sessions.test.ts index 75e2b457ada..5e92d9357af 100644 --- a/cloud/apps/sandbox-control/test/api-sessions.test.ts +++ b/cloud/apps/sandbox-control/test/api-sessions.test.ts @@ -214,7 +214,7 @@ describe('public API: attachments', () => { const header = Buffer.from(tar.subarray(block * 512, block * 512 + 512)) const stored = parseInt(field(block, 148, 8), 8) header.fill(0x20, 148, 156) - expect(header.reduce((sum, b) => sum + b, 0)).toBe(stored) + expect(header.reduce((sum: number, b: number) => sum + b, 0)).toBe(stored) } expect(tar.subarray(3 * 512, 3 * 512 + 5).toString()).toBe('hello') await expect(tarGzipFile({ dirs: [], path: '../x', bytes, mtime: 0 })).rejects.toThrow('tar_bad_path') diff --git a/cloud/apps/sandbox-control/test/fake-vercel.ts b/cloud/apps/sandbox-control/test/fake-vercel.ts index 69fbbdce75c..e1d310e0c21 100644 --- a/cloud/apps/sandbox-control/test/fake-vercel.ts +++ b/cloud/apps/sandbox-control/test/fake-vercel.ts @@ -67,7 +67,10 @@ export type FakeVercel = SandboxProviderApi & GoldenImageProviderApi & { snapshots: Map commands: { sessionId: string; command: string; args?: string[]; env: Record }[] /** Answers commands the fake does not know (in-VM orca CLI, routine runners); undefined falls through. */ - commandHandler?: (params: RunCommandParams, sessionId: string) => { exitCode: number; stdout: string; stderr: string } | undefined + commandHandler?: ( + params: RunCommandParams, + sessionId: string + ) => { exitCode: number; stdout: string; stderr: string } | Promise<{ exitCode: number; stdout: string; stderr: string }> | undefined failStart: boolean failEnvRefresh: boolean /** Exit code orca-ssh-keys answers with (0 = success; 3 = image without sshd). */ diff --git a/cloud/apps/sandbox-control/test/secrets-hardening.test.ts b/cloud/apps/sandbox-control/test/secrets-hardening.test.ts index ca05eaefeb2..4e726e476ca 100644 --- a/cloud/apps/sandbox-control/test/secrets-hardening.test.ts +++ b/cloud/apps/sandbox-control/test/secrets-hardening.test.ts @@ -124,7 +124,7 @@ describe('SEC-6c: secret-handling code logs only through the redacting sink', () it('the secret-handling modules never call console directly', () => { for (const file of FILES) { - const source = readFileSync(fileURLToPath(new URL(`../src/${file}`, import.meta.url)), 'utf8') + const source = readFileSync(fileURLToPath(new URL(`../src/${file}`, import.meta.url).href), 'utf8') expect({ file, direct: /\bconsole\s*\.\s*[a-z]+\s*\(/.test(source) }).toEqual({ file, direct: false }) } }) diff --git a/cloud/apps/sandbox-control/test/typecheck-script.test.ts b/cloud/apps/sandbox-control/test/typecheck-script.test.ts new file mode 100644 index 00000000000..ddddf882c0c --- /dev/null +++ b/cloud/apps/sandbox-control/test/typecheck-script.test.ts @@ -0,0 +1,29 @@ +// SANDBOX-CONTROL-TYPECHECK: the package typecheck fails on this package's own diagnostics (and config errors), +// and only counts those in imported desktop files. +import { describe, expect, it } from 'vitest' +// @ts-expect-error -- plain .mjs script; no type declarations +import { splitDiagnostics } from '../scripts/typecheck.mjs' + +describe('scripts/typecheck.mjs', () => { + it('keeps own diagnostics with their detail lines, counts imported desktop ones', () => { + const out = [ + "src/a.ts(1,2): error TS2322: Type 'x' is not assignable to type 'y'.", + " Type 'x' is missing properties.", + "../../../src/shared/b.ts(3,4): error TS2532: Object is possibly 'undefined'.", + " detail of a foreign error", + "test/c.test.ts(5,6): error TS7006: Parameter 'p' implicitly has an 'any' type.", + '' + ].join('\n') + const { own, foreign } = splitDiagnostics(out) + expect(own).toEqual([ + "src/a.ts(1,2): error TS2322: Type 'x' is not assignable to type 'y'.", + " Type 'x' is missing properties.", + "test/c.test.ts(5,6): error TS7006: Parameter 'p' implicitly has an 'any' type." + ]) + expect(foreign).toBe(1) + }) + + it('a diagnostic without a file (config error) is its own', () => { + expect(splitDiagnostics("error TS2688: Cannot find type definition file for 'x'.").own).toHaveLength(1) + }) +}) diff --git a/cloud/apps/sandbox-control/tsconfig.json b/cloud/apps/sandbox-control/tsconfig.json index 043cf347090..13effc0cc62 100644 --- a/cloud/apps/sandbox-control/tsconfig.json +++ b/cloud/apps/sandbox-control/tsconfig.json @@ -3,12 +3,26 @@ "target": "es2022", "module": "es2022", "moduleResolution": "bundler", - "lib": ["es2022"], - "types": ["@cloudflare/workers-types", "@types/node"], + "lib": [ + "es2024" + ], + "types": [ + "@cloudflare/workers-types", + "@types/node" + ], "strict": true, "noUncheckedIndexedAccess": true, "noEmit": true, - "skipLibCheck": true + "skipLibCheck": true, + "paths": { + "@xterm/xterm": [ + "./types/xterm-theme.d.ts" + ] + } }, - "include": ["src", "test", "image"] + "include": [ + "src", + "test", + "image" + ] } diff --git a/cloud/apps/sandbox-control/types/xterm-theme.d.ts b/cloud/apps/sandbox-control/types/xterm-theme.d.ts new file mode 100644 index 00000000000..5053984b4bc --- /dev/null +++ b/cloud/apps/sandbox-control/types/xterm-theme.d.ts @@ -0,0 +1,34 @@ +// SANDBOX-CONTROL-TYPECHECK: desktop shared settings types reach `import type { ITheme } from '@xterm/xterm'`, and +// xterm's typings pull in `/// `. DOM's Headers, streams and crypto then clash with +// @cloudflare/workers-types across this Worker. tsconfig `paths` maps the import here: the theme shape only (copied +// from @xterm/xterm's ITheme), no DOM. +export interface ITheme { + foreground?: string + background?: string + cursor?: string + cursorAccent?: string + selectionBackground?: string + selectionForeground?: string + selectionInactiveBackground?: string + scrollbarSliderBackground?: string + scrollbarSliderHoverBackground?: string + scrollbarSliderActiveBackground?: string + overviewRulerBorder?: string + black?: string + red?: string + green?: string + yellow?: string + blue?: string + magenta?: string + cyan?: string + white?: string + brightBlack?: string + brightRed?: string + brightGreen?: string + brightYellow?: string + brightBlue?: string + brightMagenta?: string + brightCyan?: string + brightWhite?: string + extendedAnsi?: string[] +} From 84a776923c18fe45785e52351e72d7f3d8521624 Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 13 Nov 2026 11:16:23 -0800 Subject: [PATCH 2/2] docs(cloud): sandbox-control typecheck split pinned by path; README on why raw tsc shows imported diagnostics --- cloud/apps/sandbox-control/README.md | 15 +++++++++++++++ cloud/apps/sandbox-control/scripts/typecheck.mjs | 13 +++++++++---- .../sandbox-control/test/typecheck-script.test.ts | 9 +++++++++ 3 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 cloud/apps/sandbox-control/README.md diff --git a/cloud/apps/sandbox-control/README.md b/cloud/apps/sandbox-control/README.md new file mode 100644 index 00000000000..7212ef3aa20 --- /dev/null +++ b/cloud/apps/sandbox-control/README.md @@ -0,0 +1,15 @@ +# sandbox-control + +The Orca Cloud control plane Worker: workspace lifecycle, the egress credential proxy, environment builds and the golden image. Its routes are listed at the top of `src/index.ts`. + +## Typecheck + +`pnpm typecheck` runs `scripts/typecheck.mjs`. It fails on any TypeScript diagnostic in this package's own files, and on any diagnostic without a file (a config error). + +**Why a raw `tsc --noEmit` shows about 247 diagnostics.** The Worker imports desktop code from `src/shared` (wire types, parsers), so tsc checks those files under this package's options: +- `noUncheckedIndexedAccess`, which the desktop doesn't use, accounts for about 239; +- the Workers globals, which lack the desktop's `window` and `MessageChannel`, account for the rest. + +TypeScript can't apply options per file. Those files are type-checked by the root (desktop) typecheck under their own config. So the script counts them, and prints the count on every run so a jump is visible, but doesn't fail on them. + +**Why there's an xterm stub.** Desktop settings types import `ITheme` from `@xterm/xterm`, whose typings pull in the DOM library. DOM's `Headers`, streams and `crypto` clash with `@cloudflare/workers-types`. `tsconfig.json` `paths` maps that import to `types/xterm-theme.d.ts`, the theme shape only. diff --git a/cloud/apps/sandbox-control/scripts/typecheck.mjs b/cloud/apps/sandbox-control/scripts/typecheck.mjs index b15de8792af..1fca6695f9d 100644 --- a/cloud/apps/sandbox-control/scripts/typecheck.mjs +++ b/cloud/apps/sandbox-control/scripts/typecheck.mjs @@ -8,7 +8,13 @@ import { spawnSync } from 'node:child_process' import { createRequire } from 'node:module' import { fileURLToPath } from 'node:url' -/** Splits tsc --pretty false output into this package's diagnostics (with their detail lines) and a count of others. */ +/** + * Splits tsc --pretty false output into this package's diagnostics (with their detail lines) and a count of others. + * The split is by path prefix, and nothing else: tsc prints paths relative to this package, so a path that starts + * with `..` (or is absolute) is an imported file: desktop src/shared and node_modules. Every other located + * diagnostic is own: src/, test/, image/, scripts/, types/ (the xterm stub) and tsconfig.json itself, including + * any file added later. A diagnostic with no file (a config or option error) is own too. + */ export function splitDiagnostics(output) { const own = [] let foreign = 0 @@ -42,9 +48,8 @@ if (process.argv[1] === fileURLToPath(import.meta.url)) { if (own.length > 0) { process.stdout.write(`${own.join('\n')}\n`) } - if (foreign > 0) { - process.stdout.write(`(${foreign} diagnostics in imported desktop files: checked by the desktop typecheck, not here)\n`) - } + // Always printed, so a jump in imported diagnostics shows in every run. + process.stdout.write(`imported desktop files: ${foreign} diagnostic(s), checked by the root (desktop) typecheck, not here\n`) const ownErrors = own.filter((line) => / error TS\d+/.test(line) || /^error TS\d+/.test(line)).length process.stdout.write(`sandbox-control typecheck: ${ownErrors} error(s) in its own files\n`) // A crash, or a non-zero exit with nothing parsed, is never a pass. diff --git a/cloud/apps/sandbox-control/test/typecheck-script.test.ts b/cloud/apps/sandbox-control/test/typecheck-script.test.ts index ddddf882c0c..747a2506260 100644 --- a/cloud/apps/sandbox-control/test/typecheck-script.test.ts +++ b/cloud/apps/sandbox-control/test/typecheck-script.test.ts @@ -23,6 +23,15 @@ describe('scripts/typecheck.mjs', () => { expect(foreign).toBe(1) }) + it('the split is by path prefix: own package paths (src, test, image, scripts, types stub, tsconfig) fail; `..` and absolute paths are imported', () => { + const line = (path: string) => `${path}(1,1): error TS2322: x` + const own = ['src/a.ts', 'test/b.test.ts', 'image/c.ts', 'scripts/d.mjs', 'types/xterm-theme.d.ts', 'tsconfig.json', 'new-dir/added-later.ts'] + const imported = ['../../../src/shared/e.ts', '../../node_modules/f/index.d.ts', '/abs/node_modules/g.d.ts'] + const { own: kept, foreign } = splitDiagnostics([...own, ...imported].map(line).join('\n')) + expect(kept).toEqual(own.map(line)) + expect(foreign).toBe(imported.length) + }) + it('a diagnostic without a file (config error) is its own', () => { expect(splitDiagnostics("error TS2688: Cannot find type definition file for 'x'.").own).toHaveLength(1) })