6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
260 lines
10 KiB
TypeScript
260 lines
10 KiB
TypeScript
/**
|
|
* Desktop bridge contract audit.
|
|
*
|
|
* The desktop shell is an installed binary users update on their own
|
|
* schedule, while the web app it loads is deployed continuously. The preload
|
|
* bridge contract (`@sim/desktop-bridge`, which embeds `@sim/browser-protocol`
|
|
* types) must therefore stay backward compatible: an already-installed shell
|
|
* has to satisfy whatever the newest web deployment expects.
|
|
*
|
|
* This script keeps a frozen snapshot of the full bridge type surface
|
|
* (`packages/desktop-bridge/contract-snapshot.ts`) and type-checks that a
|
|
* shell built from the snapshot is still assignable to the current
|
|
* `SimDesktopApi` — additive/optional changes pass, removals, renames, and
|
|
* new required members fail.
|
|
*
|
|
* Modes:
|
|
* - `--check` (default, CI): fails when the current types break
|
|
* compatibility with the snapshot, or when the snapshot's recorded floor
|
|
* drifts from `MIN_DESKTOP_VERSION`.
|
|
* - `--update`: regenerates the snapshot from the current sources. A
|
|
* breaking regeneration is refused unless `MIN_DESKTOP_VERSION`
|
|
* (`apps/sim/lib/desktop/min-version.ts`) was raised above the previous
|
|
* snapshot's floor — bumping the floor is the deliberate escape hatch that
|
|
* makes outdated shells show the "update to continue" takeover.
|
|
*
|
|
* Known limitation: TypeScript checks method parameters bivariantly, so
|
|
* widening a request union or callback payload is not flagged. Those changes
|
|
* are additive for the shell (unknown requests fail soft), but semantic
|
|
* changes to callback payloads still need review.
|
|
*/
|
|
import { spawnSync } from 'node:child_process'
|
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { readFile, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { dirname, join, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { formatGeneratedSource } from './format-generated-source'
|
|
import { localBin } from './local-bin'
|
|
|
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
|
|
const ROOT = resolve(SCRIPT_DIR, '..')
|
|
const BRIDGE_SOURCE_PATH = resolve(ROOT, 'packages/desktop-bridge/src/index.ts')
|
|
const PROTOCOL_SOURCE_PATH = resolve(ROOT, 'packages/browser-protocol/src/index.ts')
|
|
const TERMINAL_PROTOCOL_SOURCE_PATH = resolve(ROOT, 'packages/terminal-protocol/src/index.ts')
|
|
/**
|
|
* Protocol modules folded into the snapshot verbatim. Anything the bridge
|
|
* imports that carries wire shape belongs here — a package left out stays
|
|
* outside the freeze, and changes to it pass the audit unnoticed.
|
|
*/
|
|
const INLINED_PROTOCOL_PACKAGES = ['@sim/browser-protocol', '@sim/terminal-protocol'] as const
|
|
const SNAPSHOT_PATH = resolve(ROOT, 'packages/desktop-bridge/contract-snapshot.ts')
|
|
const MIN_VERSION_PATH = resolve(ROOT, 'apps/sim/lib/desktop/min-version.ts')
|
|
|
|
const FLOOR_PATTERN = /^ \* min-desktop-version: (\S+)$/m
|
|
|
|
async function readMinDesktopVersion(): Promise<string> {
|
|
const source = await readFile(MIN_VERSION_PATH, 'utf8')
|
|
const match = /export const MIN_DESKTOP_VERSION = '([^']+)'/.exec(source)
|
|
if (!match) {
|
|
throw new Error(`Could not find MIN_DESKTOP_VERSION in ${MIN_VERSION_PATH}`)
|
|
}
|
|
return match[1]
|
|
}
|
|
|
|
async function readSnapshot(): Promise<{ source: string; floor: string } | null> {
|
|
let source: string
|
|
try {
|
|
source = await readFile(SNAPSHOT_PATH, 'utf8')
|
|
} catch {
|
|
return null
|
|
}
|
|
const floor = FLOOR_PATTERN.exec(source)?.[1]
|
|
if (!floor) {
|
|
throw new Error(`${SNAPSHOT_PATH} is missing its min-desktop-version header`)
|
|
}
|
|
return { source, floor }
|
|
}
|
|
|
|
/** Plain x.y.z ordering for floor versions; throws on anything else. */
|
|
function isFloorRaised(next: string, previous: string): boolean {
|
|
const parse = (version: string): number[] => {
|
|
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version)
|
|
if (!match) {
|
|
throw new Error(`MIN_DESKTOP_VERSION must be a plain x.y.z version, got '${version}'`)
|
|
}
|
|
return [Number(match[1]), Number(match[2]), Number(match[3])]
|
|
}
|
|
const [nextParts, previousParts] = [parse(next), parse(previous)]
|
|
for (let i = 0; i < 3; i++) {
|
|
if (nextParts[i] !== previousParts[i]) {
|
|
return nextParts[i] > previousParts[i]
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
async function buildSnapshot(floor: string): Promise<string> {
|
|
const protocol = await readFile(PROTOCOL_SOURCE_PATH, 'utf8')
|
|
const terminalProtocol = await readFile(TERMINAL_PROTOCOL_SOURCE_PATH, 'utf8')
|
|
const bridgeRaw = await readFile(BRIDGE_SOURCE_PATH, 'utf8')
|
|
// The snapshot must be self-contained. A surviving import resolves to the
|
|
// CURRENT module on BOTH sides of the comparison, so every change to it is
|
|
// invisible to this audit — which is exactly what happened to
|
|
// @sim/terminal-protocol, leaving the whole terminal wire surface unfrozen.
|
|
const bridge = INLINED_PROTOCOL_PACKAGES.reduce(
|
|
(source, pkg) => source.replace(new RegExp(`import type \\{[^}]*\\} from '${pkg}'\\n`), ''),
|
|
bridgeRaw
|
|
)
|
|
for (const pkg of INLINED_PROTOCOL_PACKAGES) {
|
|
if (bridge.includes(pkg)) {
|
|
throw new Error(
|
|
`packages/desktop-bridge/src/index.ts references ${pkg} in an unexpected shape — ` +
|
|
'update scripts/check-desktop-bridge-contract.ts to inline it.'
|
|
)
|
|
}
|
|
}
|
|
const header = [
|
|
'/**',
|
|
' * GENERATED FILE — DO NOT EDIT.',
|
|
' *',
|
|
' * Frozen snapshot of the desktop preload bridge type surface',
|
|
' * (@sim/browser-protocol + @sim/terminal-protocol inlined into',
|
|
' * @sim/desktop-bridge) as of the last accepted contract change.',
|
|
' * CI type-checks that a shell built from this',
|
|
' * snapshot still satisfies the current SimDesktopApi, so bridge changes',
|
|
' * stay backward compatible with already-installed shells.',
|
|
' *',
|
|
' * Regenerate with: bun run desktop-bridge-contract:update',
|
|
' * Full rules: scripts/check-desktop-bridge-contract.ts',
|
|
' *',
|
|
` * min-desktop-version: ${floor}`,
|
|
' */',
|
|
'',
|
|
].join('\n')
|
|
const source = `${header}${protocol}\n${terminalProtocol}\n${bridge}`
|
|
return formatGeneratedSource(source, SNAPSHOT_PATH, ROOT)
|
|
}
|
|
|
|
/**
|
|
* Type-checks that an old shell (the committed snapshot) is assignable to
|
|
* the current SimDesktopApi — i.e. new web code can run against it.
|
|
*/
|
|
function checkCompatibility(): { compatible: boolean; output: string } {
|
|
const compatSource = [
|
|
`import type { SimDesktopApi as CurrentApi } from '${BRIDGE_SOURCE_PATH}'`,
|
|
`import type { SimDesktopApi as OldShellApi } from '${SNAPSHOT_PATH}'`,
|
|
'',
|
|
'declare const oldInstalledShell: OldShellApi',
|
|
'// If this assignment fails, the current web app expects something an',
|
|
'// already-installed shell cannot provide — a breaking bridge change.',
|
|
'const currentWebAppExpectation: CurrentApi = oldInstalledShell',
|
|
'void currentWebAppExpectation',
|
|
'',
|
|
].join('\n')
|
|
const tsconfig = {
|
|
compilerOptions: {
|
|
strict: true,
|
|
noEmit: true,
|
|
target: 'ES2022',
|
|
module: 'ESNext',
|
|
moduleResolution: 'bundler',
|
|
allowImportingTsExtensions: true,
|
|
skipLibCheck: true,
|
|
types: [],
|
|
paths: {
|
|
'@sim/browser-protocol': [PROTOCOL_SOURCE_PATH],
|
|
'@sim/terminal-protocol': [TERMINAL_PROTOCOL_SOURCE_PATH],
|
|
},
|
|
},
|
|
files: ['./compat.ts'],
|
|
}
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), 'sim-desktop-bridge-contract-'))
|
|
try {
|
|
writeFileSync(join(dir, 'compat.ts'), compatSource)
|
|
writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2))
|
|
const result = spawnSync(localBin('tsc'), ['-p', dir, '--pretty', 'false'], {
|
|
cwd: ROOT,
|
|
encoding: 'utf8',
|
|
})
|
|
return {
|
|
compatible: result.status === 0,
|
|
output: `${result.stdout ?? ''}${result.stderr ?? ''}`.trim(),
|
|
}
|
|
} finally {
|
|
rmSync(dir, { recursive: true, force: true })
|
|
}
|
|
}
|
|
|
|
const BREAKING_GUIDANCE = `
|
|
A shell built from the committed contract snapshot no longer satisfies the
|
|
current SimDesktopApi — installed desktop apps would break against this web
|
|
deployment. Either:
|
|
|
|
1. Make the change backward compatible: new fields, methods, and surfaces
|
|
on the bridge must be optional so older shells (which lack them) still
|
|
type-check. This is the default — prefer it.
|
|
|
|
2. If the change is genuinely breaking: bump MIN_DESKTOP_VERSION in
|
|
apps/sim/lib/desktop/min-version.ts to the desktop release your shell
|
|
change ships in, then run:
|
|
|
|
bun run desktop-bridge-contract:update
|
|
|
|
Shells older than that floor will show a blocking "Update Sim to
|
|
continue" screen until they update.
|
|
`
|
|
|
|
async function runCheck(): Promise<void> {
|
|
const [minVersion, snapshot] = await Promise.all([readMinDesktopVersion(), readSnapshot()])
|
|
if (!snapshot) {
|
|
console.error(
|
|
`Missing ${SNAPSHOT_PATH}.\nRun: bun run desktop-bridge-contract:update and commit the result.`
|
|
)
|
|
process.exit(1)
|
|
}
|
|
if (snapshot.floor !== minVersion) {
|
|
console.error(
|
|
`Contract snapshot floor (${snapshot.floor}) does not match MIN_DESKTOP_VERSION ` +
|
|
`(${minVersion}).\nRun: bun run desktop-bridge-contract:update and commit the result.`
|
|
)
|
|
process.exit(1)
|
|
}
|
|
const { compatible, output } = checkCompatibility()
|
|
if (!compatible) {
|
|
console.error('Breaking desktop bridge change detected.\n')
|
|
console.error(output)
|
|
console.error(BREAKING_GUIDANCE)
|
|
process.exit(1)
|
|
}
|
|
console.log('Desktop bridge contract audit passed: bridge types are backward compatible.')
|
|
}
|
|
|
|
async function runUpdate(): Promise<void> {
|
|
const [minVersion, snapshot] = await Promise.all([readMinDesktopVersion(), readSnapshot()])
|
|
if (snapshot) {
|
|
const { compatible, output } = checkCompatibility()
|
|
if (!compatible && !isFloorRaised(minVersion, snapshot.floor)) {
|
|
console.error('Refusing to accept a breaking bridge change without a floor bump.\n')
|
|
console.error(output)
|
|
console.error(BREAKING_GUIDANCE)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
await writeFile(SNAPSHOT_PATH, await buildSnapshot(minVersion))
|
|
console.log(`Regenerated ${SNAPSHOT_PATH} (min-desktop-version: ${minVersion}).`)
|
|
}
|
|
|
|
const mode = process.argv.includes('--update') ? 'update' : 'check'
|
|
try {
|
|
if (mode === 'update') {
|
|
await runUpdate()
|
|
} else {
|
|
await runCheck()
|
|
}
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : error)
|
|
process.exit(1)
|
|
}
|