From 70bd0418605134a8fbe18bc35644f6b2e0ebef81 Mon Sep 17 00:00:00 2001 From: me2seeks Date: Tue, 25 Aug 2026 12:56:00 +0800 Subject: [PATCH] feat(runtime-host): add durable local deployment ownership Generated-by: Codex --- .../workflows/runtime-host-owner-platform.yml | 71 ++ .../fixtures/local-deployment-owner-claim.ts | 41 + .../__tests__/local-deployment-owner.test.ts | 697 +++++++++++++++ packages/runtime-host/src/operator/index.ts | 13 + .../src/operator/local-deployment-owner.ts | 843 ++++++++++++++++++ .../runtime-host/tsconfig.owner-platform.json | 12 + scripts/ci-test-plan.test.mjs | 1 + 7 files changed, 1678 insertions(+) create mode 100644 .github/workflows/runtime-host-owner-platform.yml create mode 100644 packages/runtime-host/src/__tests__/fixtures/local-deployment-owner-claim.ts create mode 100644 packages/runtime-host/src/__tests__/local-deployment-owner.test.ts create mode 100644 packages/runtime-host/src/operator/local-deployment-owner.ts create mode 100644 packages/runtime-host/tsconfig.owner-platform.json diff --git a/.github/workflows/runtime-host-owner-platform.yml b/.github/workflows/runtime-host-owner-platform.yml new file mode 100644 index 0000000000..2a4457e945 --- /dev/null +++ b/.github/workflows/runtime-host-owner-platform.yml @@ -0,0 +1,71 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +name: Runtime Host owner platforms + +on: + pull_request: + branches: [main] + paths: + - '.github/workflows/runtime-host-owner-platform.yml' + - 'package.json' + - 'package-lock.json' + - 'packages/storage/src/process-lifetime-file-update-lock.ts' + - 'packages/runtime-host/src/operator/local-deployment-owner.ts' + - 'packages/runtime-host/src/operator/update-package-evidence.ts' + - 'packages/runtime-host/src/__tests__/local-deployment-owner.test.ts' + - 'packages/runtime-host/src/__tests__/fixtures/local-deployment-owner-claim.ts' + - 'packages/runtime-host/tsconfig.owner-platform.json' + workflow_dispatch: + +concurrency: + group: runtime-host-owner-platform-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +jobs: + owner: + name: owner (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: '24' + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Build Runtime Host test dependencies + run: | + npm --workspace @maka/core run build + npm --workspace @maka/storage run build + npm exec -- tsc --project packages/runtime-host/tsconfig.owner-platform.json + + - name: Test local deployment owner + run: node --test packages/runtime-host/.owner-platform-dist/__tests__/local-deployment-owner.test.js diff --git a/packages/runtime-host/src/__tests__/fixtures/local-deployment-owner-claim.ts b/packages/runtime-host/src/__tests__/fixtures/local-deployment-owner-claim.ts new file mode 100644 index 0000000000..020106c9f0 --- /dev/null +++ b/packages/runtime-host/src/__tests__/fixtures/local-deployment-owner-claim.ts @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { applyLocalHostDeploymentTransition } from '../../operator/local-deployment-owner.js'; + +const [mode, location, rootId, installationId, integrity] = process.argv.slice(2); +if (!mode || !location || !rootId || !installationId || !integrity) { + throw new Error( + 'usage: local-deployment-owner-claim <--authority-root|--account-home> ', + ); +} +if (mode !== '--authority-root' && mode !== '--account-home') { + throw new Error('invalid local deployment owner claim mode'); +} + +const result = await applyLocalHostDeploymentTransition( + rootId, + { + kind: 'claim', + owner: { kind: 'desktop', installationId }, + selected: { kind: 'npm_registry', version: '1.0.0', integrity }, + }, + mode === '--authority-root' ? { authorityRoot: location } : { homeDir: location }, +); +process.stdout.write(JSON.stringify({ kind: result.kind })); diff --git a/packages/runtime-host/src/__tests__/local-deployment-owner.test.ts b/packages/runtime-host/src/__tests__/local-deployment-owner.test.ts new file mode 100644 index 0000000000..6ca0224a80 --- /dev/null +++ b/packages/runtime-host/src/__tests__/local-deployment-owner.test.ts @@ -0,0 +1,697 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { execFileSync, spawn } from 'node:child_process'; +import { constants as fsConstants } from 'node:fs'; +import { chmod, mkdir, mkdtemp, open, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import { + applyLocalHostDeploymentTransition, + LocalHostDeploymentAuthorityError, + readLocalHostDeploymentRecord, + resolveLocalHostDeploymentAuthorityRoot, + type LocalHostDeploymentAuthorityOptions, + type RuntimeHostInstallationOwner, +} from '../operator/local-deployment-owner.js'; +import type { RuntimeHostDeploymentIdentity } from '../operator/update-package-evidence.js'; + +const ROOT_ID = 'a'.repeat(64); +const DESKTOP: RuntimeHostInstallationOwner = { + kind: 'desktop', + installationId: 'desktop:stable', +}; +const CLI: RuntimeHostInstallationOwner = { + kind: 'cli', + installationId: 'cli:global', +}; +const DESKTOP_DEPLOYMENT: RuntimeHostDeploymentIdentity = { + kind: 'npm_registry', + version: '1.0.0', + integrity: `sha512-${Buffer.alloc(64, 1).toString('base64')}`, +}; +const CLI_DEPLOYMENT: RuntimeHostDeploymentIdentity = { + kind: 'npm_registry', + version: '2.0.0', + integrity: `sha512-${Buffer.alloc(64, 2).toString('base64')}`, +}; +const CLAIM_FIXTURE = fileURLToPath( + new URL('./fixtures/local-deployment-owner-claim.js', import.meta.url), +); + +async function authority(t: test.TestContext): Promise { + const authorityRoot = await mkdtemp(join(tmpdir(), 'maka-local-owner-')); + t.after(() => rm(authorityRoot, { recursive: true, force: true })); + return { authorityRoot }; +} + +test('resolves one durable account-local namespace outside cache and State Root paths', () => { + assert.equal( + resolveLocalHostDeploymentAuthorityRoot({ + platform: 'linux', + homeDir: '/home/ada', + }), + '/home/ada/.local/share/Maka/runtime-host-ownership', + ); + assert.equal( + resolveLocalHostDeploymentAuthorityRoot({ + platform: 'darwin', + homeDir: '/Users/ada', + }), + '/Users/ada/Library/Application Support/Maka/runtime-host-ownership', + ); + assert.equal( + resolveLocalHostDeploymentAuthorityRoot({ + platform: 'win32', + homeDir: 'C:\\Users\\Ada', + }), + 'C:\\Users\\Ada\\AppData\\Local\\Maka\\runtime-host-ownership', + ); +}); + +test('different process data environments still compete for one account authority', async (t) => { + const parent = await mkdtemp(join(tmpdir(), 'maka-local-owner-env-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const homeDir = join(parent, 'home'); + const results = await Promise.all([ + claimFromAccountProcess(homeDir, 'desktop-no-xdg', DESKTOP_DEPLOYMENT.integrity, undefined), + claimFromAccountProcess(homeDir, 'cli-with-xdg', CLI_DEPLOYMENT.integrity, join(parent, 'xdg')), + ]); + + assert.equal(results.filter((result) => result.kind === 'applied').length, 1); + assert.equal(results.filter((result) => result.kind === 'rejected').length, 1); +}); + +test('serializes competing initial claims and never uses last-launch-wins', async (t) => { + const options = await authority(t); + const results = await Promise.all([ + claimFromIndependentProcess(options.authorityRoot!, 'desktop-a', DESKTOP_DEPLOYMENT.integrity), + claimFromIndependentProcess(options.authorityRoot!, 'desktop-b', CLI_DEPLOYMENT.integrity), + ]); + + assert.equal(results.filter((result) => result.kind === 'applied').length, 1); + assert.equal(results.filter((result) => result.kind === 'rejected').length, 1); + const stored = await readLocalHostDeploymentRecord(ROOT_ID, options); + assert.equal(stored?.state.kind, 'owned'); + assert.ok( + stored?.state.kind === 'owned' && + (stored.state.owner.installationId === 'desktop-a' || + stored.state.owner.installationId === 'desktop-b'), + ); +}); + +test('creates a missing authority hierarchy before durably publishing the first record', async (t) => { + const parent = await mkdtemp(join(tmpdir(), 'maka-local-owner-parent-')); + t.after(() => rm(parent, { recursive: true, force: true })); + const options = { authorityRoot: join(parent, 'account', 'Maka', 'runtime-host-ownership') }; + + const claimed = await applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner: DESKTOP, selected: DESKTOP_DEPLOYMENT }, + options, + ); + + assert.equal(claimed.kind, 'applied'); + assert.deepEqual(await readLocalHostDeploymentRecord(ROOT_ID, options), claimed.record); +}); + +test('does not sync above the OS-managed account-home durability boundary', async (t) => { + if (process.platform === 'win32') return; + const parent = await mkdtemp(join(tmpdir(), 'maka-local-owner-home-boundary-')); + const homeDir = join(parent, 'home'); + await mkdir(homeDir, { mode: 0o700 }); + await chmod(parent, 0o111); + t.after(async () => { + await chmod(parent, 0o700); + await rm(parent, { recursive: true, force: true }); + }); + await assert.rejects( + open(parent, 'r'), + (error: unknown) => (error as NodeJS.ErrnoException).code === 'EACCES', + ); + + const claimed = await applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner: DESKTOP, selected: DESKTOP_DEPLOYMENT }, + { homeDir }, + ); + + assert.equal(claimed.kind, 'applied'); + assert.deepEqual(await readLocalHostDeploymentRecord(ROOT_ID, { homeDir }), claimed.record); +}); + +test('keeps an exact initial-owner claim retry idempotent without changing revision', async (t) => { + const options = await authority(t); + const first = await applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner: DESKTOP, selected: DESKTOP_DEPLOYMENT }, + options, + ); + const retried = await applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner: DESKTOP, selected: DESKTOP_DEPLOYMENT }, + options, + ); + + assert.equal(first.kind, 'applied'); + assert.equal(retried.kind, 'unchanged'); + assert.equal(retried.record?.revision, first.record?.revision); +}); + +test('retries a directory-entry durability barrier after mkdir', async (t) => { + if (process.platform === 'win32') return; + const parent = await mkdtemp(join(tmpdir(), 'maka-local-owner-mkdir-sync-')); + t.after(() => rm(parent, { recursive: true, force: true })); + let remainingFailures = 2; + const options: LocalHostDeploymentAuthorityOptions = { + authorityRoot: join(parent, 'account', 'Maka', 'runtime-host-ownership'), + beforeDirectorySync: (path, purpose) => { + if (purpose === 'directory_entry' && path === parent && remainingFailures > 0) { + remainingFailures -= 1; + throw new Error('injected directory-entry sync failure'); + } + }, + }; + const transition = { kind: 'claim', owner: DESKTOP, selected: DESKTOP_DEPLOYMENT } as const; + + await assertCommitUnknown(applyLocalHostDeploymentTransition(ROOT_ID, transition, options)); + await assertCommitUnknown(applyLocalHostDeploymentTransition(ROOT_ID, transition, options)); + const retried = await applyLocalHostDeploymentTransition(ROOT_ID, transition, options); + + assert.equal(retried.kind, 'applied'); +}); + +test('an exact claim retry re-establishes rename durability before succeeding', async (t) => { + if (process.platform === 'win32') return; + const base = await authority(t); + let publishFailure = true; + let confirmationFailure = true; + const options: LocalHostDeploymentAuthorityOptions = { + ...base, + beforeDirectorySync: (_path, purpose) => { + if (purpose === 'record_publish' && publishFailure) { + publishFailure = false; + throw new Error('injected record publish sync failure'); + } + if (purpose === 'unchanged_confirmation' && confirmationFailure) { + confirmationFailure = false; + throw new Error('injected unchanged confirmation failure'); + } + }, + }; + const transition = { kind: 'claim', owner: DESKTOP, selected: DESKTOP_DEPLOYMENT } as const; + + await assertCommitUnknown(applyLocalHostDeploymentTransition(ROOT_ID, transition, options)); + await assertCommitUnknown(applyLocalHostDeploymentTransition(ROOT_ID, transition, options)); + const retried = await applyLocalHostDeploymentTransition(ROOT_ID, transition, options); + + assert.equal(retried.kind, 'unchanged'); +}); + +test('an exact release retry re-establishes unlink durability before succeeding', async (t) => { + if (process.platform === 'win32') return; + const base = await authority(t); + const claimed = await applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner: DESKTOP, selected: DESKTOP_DEPLOYMENT }, + base, + ); + let removeFailure = true; + let confirmationFailure = true; + const options: LocalHostDeploymentAuthorityOptions = { + ...base, + beforeDirectorySync: (_path, purpose) => { + if (purpose === 'record_remove' && removeFailure) { + removeFailure = false; + throw new Error('injected record removal sync failure'); + } + if (purpose === 'unchanged_confirmation' && confirmationFailure) { + confirmationFailure = false; + throw new Error('injected unchanged confirmation failure'); + } + }, + }; + const transition = { + kind: 'release', + expectedRevision: claimed.record!.revision, + owner: DESKTOP, + } as const; + + await assertCommitUnknown(applyLocalHostDeploymentTransition(ROOT_ID, transition, options)); + await assertCommitUnknown(applyLocalHostDeploymentTransition(ROOT_ID, transition, options)); + const retried = await applyLocalHostDeploymentTransition(ROOT_ID, transition, options); + + assert.deepEqual(retried, { kind: 'unchanged', record: undefined }); +}); + +test('snapshots caller-owned transition values before the first await', async (t) => { + const options = await authority(t); + const owner: { kind: 'desktop'; installationId: string } = { + kind: 'desktop', + installationId: 'desktop:invocation-time', + }; + const selected: { kind: 'npm_registry'; version: string; integrity: string } = { + ...DESKTOP_DEPLOYMENT, + }; + + const pending = applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner, selected }, + options, + ); + owner.installationId = `mutated-${'x'.repeat(600)}`; + selected.version = 'not-a-release-version'; + const result = await pending; + + assert.equal(result.kind, 'applied'); + assert.equal(result.record?.state.kind, 'owned'); + assert.deepEqual(result.record?.state, { + kind: 'owned', + owner: { kind: 'desktop', installationId: 'desktop:invocation-time' }, + selected: DESKTOP_DEPLOYMENT, + }); + assert.deepEqual(await readLocalHostDeploymentRecord(ROOT_ID, options), result.record); +}); + +test('removes abandoned record workspaces before applying the next transition', async (t) => { + const options = await authority(t); + const abandoned = `${ROOT_ID}.json.00000000-0000-4000-8000-000000000000.tmp`; + const unrelated = `${ROOT_ID}.json.keep.tmp`; + await writeFile(join(options.authorityRoot!, abandoned), 'partial', 'utf8'); + await writeFile(join(options.authorityRoot!, unrelated), 'keep', 'utf8'); + + const claimed = await applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner: DESKTOP, selected: DESKTOP_DEPLOYMENT }, + options, + ); + + assert.equal(claimed.kind, 'applied'); + const entries = await readdir(options.authorityRoot!); + assert.equal(entries.includes(abandoned), false); + assert.equal(entries.includes(unrelated), true); +}); + +test('persists transfer intent before cutover and commits the exact target', async (t) => { + const options = await authority(t); + const claimed = await applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner: DESKTOP, selected: DESKTOP_DEPLOYMENT }, + options, + ); + assert.equal(claimed.kind, 'applied'); + + const begun = await applyLocalHostDeploymentTransition( + ROOT_ID, + { + kind: 'begin_transfer', + expectedRevision: claimed.record!.revision, + transactionId: 'desktop-to-cli', + from: DESKTOP, + to: CLI, + target: CLI_DEPLOYMENT, + }, + options, + ); + assert.equal(begun.kind, 'applied'); + assert.deepEqual(await readLocalHostDeploymentRecord(ROOT_ID, options), begun.record); + assert.equal(begun.record?.state.kind, 'transferring'); + + const committed = await applyLocalHostDeploymentTransition( + ROOT_ID, + { + kind: 'commit_transfer', + expectedRevision: begun.record!.revision, + transactionId: 'desktop-to-cli', + to: CLI, + target: CLI_DEPLOYMENT, + }, + options, + ); + assert.equal(committed.kind, 'applied'); + assert.deepEqual(committed.record?.state, { + kind: 'owned', + owner: CLI, + selected: CLI_DEPLOYMENT, + previous: DESKTOP_DEPLOYMENT, + }); + + const retried = await applyLocalHostDeploymentTransition( + ROOT_ID, + { + kind: 'commit_transfer', + expectedRevision: begun.record!.revision, + transactionId: 'desktop-to-cli', + to: CLI, + target: CLI_DEPLOYMENT, + }, + options, + ); + assert.equal(retried.kind, 'unchanged'); +}); + +test('rejects stale confirmation after the owner revision changes', async (t) => { + const options = await authority(t); + const claimed = await applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner: DESKTOP, selected: DESKTOP_DEPLOYMENT }, + options, + ); + assert.equal(claimed.kind, 'applied'); + const selected = await applyLocalHostDeploymentTransition( + ROOT_ID, + { + kind: 'select', + expectedRevision: claimed.record!.revision, + owner: DESKTOP, + selected: { + ...DESKTOP_DEPLOYMENT, + version: '1.1.0', + integrity: CLI_DEPLOYMENT.integrity, + }, + }, + options, + ); + assert.equal(selected.kind, 'applied'); + + const stale = await applyLocalHostDeploymentTransition( + ROOT_ID, + { + kind: 'begin_transfer', + expectedRevision: claimed.record!.revision, + transactionId: 'stale-prompt', + from: DESKTOP, + to: CLI, + target: CLI_DEPLOYMENT, + }, + options, + ); + assert.deepEqual( + { + kind: stale.kind, + reason: stale.kind === 'rejected' ? stale.reason : null, + }, + { + kind: 'rejected', + reason: 'revision_changed', + }, + ); +}); + +test('rolls an interrupted transfer back to the exact previous owner state', async (t) => { + const options = await authority(t); + const claimed = await applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner: DESKTOP, selected: DESKTOP_DEPLOYMENT }, + options, + ); + const begun = await applyLocalHostDeploymentTransition( + ROOT_ID, + { + kind: 'begin_transfer', + expectedRevision: claimed.record!.revision, + transactionId: 'recover-me', + from: DESKTOP, + to: CLI, + target: CLI_DEPLOYMENT, + }, + options, + ); + const rolledBack = await applyLocalHostDeploymentTransition( + ROOT_ID, + { + kind: 'rollback_transfer', + expectedRevision: begun.record!.revision, + transactionId: 'recover-me', + from: DESKTOP, + selected: DESKTOP_DEPLOYMENT, + }, + options, + ); + + assert.equal(rolledBack.kind, 'applied'); + assert.deepEqual(rolledBack.record?.state, { + kind: 'owned', + owner: DESKTOP, + selected: DESKTOP_DEPLOYMENT, + }); + + const changedSelection = await applyLocalHostDeploymentTransition( + ROOT_ID, + { + kind: 'select', + expectedRevision: rolledBack.record!.revision, + owner: DESKTOP, + selected: { + ...DESKTOP_DEPLOYMENT, + version: '1.1.0', + integrity: CLI_DEPLOYMENT.integrity, + }, + }, + options, + ); + const staleRetry = await applyLocalHostDeploymentTransition( + ROOT_ID, + { + kind: 'rollback_transfer', + expectedRevision: begun.record!.revision, + transactionId: 'recover-me', + from: DESKTOP, + selected: DESKTOP_DEPLOYMENT, + }, + options, + ); + assert.equal(changedSelection.kind, 'applied'); + assert.equal(staleRetry.kind, 'rejected'); + assert.equal(staleRetry.kind === 'rejected' ? staleRetry.reason : undefined, 'transfer_changed'); +}); + +test('requires exact owner and revision before releasing durable authority', async (t) => { + const options = await authority(t); + const claimed = await applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner: DESKTOP, selected: DESKTOP_DEPLOYMENT }, + options, + ); + const wrongOwner = await applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'release', expectedRevision: claimed.record!.revision, owner: CLI }, + options, + ); + assert.equal(wrongOwner.kind, 'rejected'); + assert.equal(wrongOwner.kind === 'rejected' ? wrongOwner.reason : undefined, 'owner_changed'); + + const released = await applyLocalHostDeploymentTransition( + ROOT_ID, + { + kind: 'release', + expectedRevision: claimed.record!.revision, + owner: DESKTOP, + }, + options, + ); + assert.deepEqual(released, { kind: 'applied', record: undefined }); + assert.equal(await readLocalHostDeploymentRecord(ROOT_ID, options), undefined); +}); + +test('fails closed on a malformed durable record instead of silently claiming it', async (t) => { + const options = await authority(t); + await applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner: DESKTOP, selected: DESKTOP_DEPLOYMENT }, + options, + ); + const path = join(options.authorityRoot!, `${ROOT_ID}.json`); + await writeFile(path, '{"schemaVersion":999}\n', 'utf8'); + + await assert.rejects( + applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner: CLI, selected: CLI_DEPLOYMENT }, + options, + ), + (error: unknown) => + error instanceof LocalHostDeploymentAuthorityError && error.code === 'invalid_record', + ); + assert.equal(await readFile(path, 'utf8'), '{"schemaVersion":999}\n'); +}); + +test('reports record acquisition failures as authority I/O rather than corruption', async (t) => { + if (process.platform === 'win32') return; + const options = await authority(t); + await applyLocalHostDeploymentTransition( + ROOT_ID, + { kind: 'claim', owner: DESKTOP, selected: DESKTOP_DEPLOYMENT }, + options, + ); + const path = join(options.authorityRoot!, `${ROOT_ID}.json`); + await chmod(path, 0o000); + try { + await assert.rejects(readLocalHostDeploymentRecord(ROOT_ID, options), (error: unknown) => { + assert.ok(error instanceof LocalHostDeploymentAuthorityError); + assert.equal(error.code, 'authority_io_failed'); + assert.equal((error.cause as NodeJS.ErrnoException | undefined)?.code, 'EACCES'); + return true; + }); + } finally { + await chmod(path, 0o600); + } +}); + +test('rejects a FIFO owner record without blocking before file-type validation', async (t) => { + if (process.platform === 'win32') return; + const options = await authority(t); + const path = join(options.authorityRoot!, `${ROOT_ID}.json`); + execFileSync('mkfifo', [path]); + const startedAt = Date.now(); + const unblock = setTimeout(() => { + void open(path, fsConstants.O_WRONLY | fsConstants.O_NONBLOCK) + .then((handle) => handle.close()) + .catch(() => undefined); + }, 500); + try { + await assert.rejects( + readLocalHostDeploymentRecord(ROOT_ID, options), + (error: unknown) => + error instanceof LocalHostDeploymentAuthorityError && error.code === 'invalid_record', + ); + } finally { + clearTimeout(unblock); + } + assert.ok(Date.now() - startedAt < 250); +}); + +test('rejects non-UTF-8 owner record bytes instead of replacing them', async (t) => { + const options = await authority(t); + await applyLocalHostDeploymentTransition( + ROOT_ID, + { + kind: 'claim', + owner: { kind: 'desktop', installationId: 'ZMARKZ' }, + selected: DESKTOP_DEPLOYMENT, + }, + options, + ); + const path = join(options.authorityRoot!, `${ROOT_ID}.json`); + const document = await readFile(path); + const markerOffset = document.indexOf(Buffer.from('ZMARKZ')); + assert.notEqual(markerOffset, -1); + document[markerOffset + 2] = 0xff; + await writeFile(path, document); + + await assert.rejects( + readLocalHostDeploymentRecord(ROOT_ID, options), + (error: unknown) => + error instanceof LocalHostDeploymentAuthorityError && error.code === 'invalid_record', + ); +}); + +test('rejects transient npx and remote identities at the durable record seam', async (t) => { + const options = await authority(t); + await assert.rejects( + applyLocalHostDeploymentTransition( + ROOT_ID, + { + kind: 'claim', + owner: { kind: 'npx', installationId: 'temporary' } as never, + selected: CLI_DEPLOYMENT, + }, + options, + ), + (error: unknown) => + error instanceof LocalHostDeploymentAuthorityError && error.code === 'invalid_input', + ); + await assert.rejects( + applyLocalHostDeploymentTransition( + ROOT_ID, + { + kind: 'claim', + owner: { kind: 'remote', installationId: 'ssh-client' } as never, + selected: CLI_DEPLOYMENT, + }, + options, + ), + (error: unknown) => + error instanceof LocalHostDeploymentAuthorityError && error.code === 'invalid_input', + ); +}); + +function claimFromIndependentProcess( + authorityRoot: string, + installationId: string, + integrity: string, +): Promise<{ readonly kind: string }> { + return claimFromProcess( + ['--authority-root', authorityRoot, ROOT_ID, installationId, integrity], + process.env, + ); +} + +async function assertCommitUnknown(pending: Promise): Promise { + await assert.rejects( + pending, + (error: unknown) => + error instanceof LocalHostDeploymentAuthorityError && error.code === 'commit_unknown', + ); +} + +function claimFromAccountProcess( + homeDir: string, + installationId: string, + integrity: string, + xdgDataHome: string | undefined, +): Promise<{ readonly kind: string }> { + const env = { ...process.env }; + if (xdgDataHome === undefined) delete env.XDG_DATA_HOME; + else env.XDG_DATA_HOME = xdgDataHome; + return claimFromProcess(['--account-home', homeDir, ROOT_ID, installationId, integrity], env); +} + +function claimFromProcess( + args: readonly string[], + env: NodeJS.ProcessEnv, +): Promise<{ readonly kind: string }> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [CLAIM_FIXTURE, ...args], { + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr.on('data', (chunk: string) => { + stderr += chunk; + }); + child.once('error', reject); + child.once('close', (code) => { + if (code !== 0) { + reject(new Error(`Owner claim fixture exited ${String(code)}: ${stderr}`)); + return; + } + resolve(JSON.parse(stdout) as { readonly kind: string }); + }); + }); +} diff --git a/packages/runtime-host/src/operator/index.ts b/packages/runtime-host/src/operator/index.ts index 5bb2699a2b..4c8ec1ad8c 100644 --- a/packages/runtime-host/src/operator/index.ts +++ b/packages/runtime-host/src/operator/index.ts @@ -64,3 +64,16 @@ export { type RuntimeHostDeploymentIdentity, type RuntimeHostNpmDeploymentIdentity, } from './update-package-evidence.js'; +export { + applyLocalHostDeploymentTransition, + LocalHostDeploymentAuthorityError, + readLocalHostDeploymentRecord, + resolveLocalHostDeploymentAuthorityRoot, + type LocalHostDeploymentAuthorityOptions, + type LocalHostDeploymentRecord, + type LocalHostDeploymentState, + type LocalHostDeploymentTransition, + type LocalHostDeploymentTransitionRejection, + type LocalHostDeploymentTransitionResult, + type RuntimeHostInstallationOwner, +} from './local-deployment-owner.js'; diff --git a/packages/runtime-host/src/operator/local-deployment-owner.ts b/packages/runtime-host/src/operator/local-deployment-owner.ts new file mode 100644 index 0000000000..4c7c222cda --- /dev/null +++ b/packages/runtime-host/src/operator/local-deployment-owner.ts @@ -0,0 +1,843 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { constants as fsConstants } from 'node:fs'; +import { randomUUID } from 'node:crypto'; +import { chmod, lstat, mkdir, open, readdir, rename, rm, unlink } from 'node:fs/promises'; +import { userInfo } from 'node:os'; +import { dirname, isAbsolute, join, posix, resolve, win32 } from 'node:path'; +import { withProcessLifetimeFileUpdateLock } from '@maka/storage/process-lifetime-file-update-lock'; +import { z } from 'zod'; +import { + isProductReleaseVersion, + isSha512PackageIntegrity, + type RuntimeHostDeploymentIdentity, +} from './update-package-evidence.js'; + +const RECORD_SCHEMA_VERSION = 1 as const; +const RECORD_MAX_BYTES = 64 * 1024; +const AUTHORITY_LOCK_TIMEOUT_MS = 60_000; +const ROOT_ID = /^[a-f0-9]{64}$/u; +const REVISION = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; + +const boundedText = (maxBytes: number) => + z + .string() + .min(1) + .refine( + (value) => + Buffer.byteLength(value, 'utf8') <= maxBytes && !/[\u0000-\u001f\u007f]/u.test(value), + ); + +const OWNER_SCHEMA = z + .object({ + kind: z.enum(['desktop', 'cli', 'managed_service', 'development']), + installationId: boundedText(512), + }) + .strict(); + +const DEPLOYMENT_IDENTITY_SCHEMA = z + .object({ + kind: z.literal('npm_registry'), + version: z.string().refine(isProductReleaseVersion), + integrity: z.string().refine(isSha512PackageIntegrity), + }) + .strict(); + +const OWNED_STATE_SCHEMA = z + .object({ + kind: z.literal('owned'), + owner: OWNER_SCHEMA, + selected: DEPLOYMENT_IDENTITY_SCHEMA, + previous: DEPLOYMENT_IDENTITY_SCHEMA.optional(), + }) + .strict(); + +const TRANSFERRING_STATE_SCHEMA = z + .object({ + kind: z.literal('transferring'), + transactionId: boundedText(512), + from: OWNER_SCHEMA, + to: OWNER_SCHEMA, + selected: DEPLOYMENT_IDENTITY_SCHEMA, + previous: DEPLOYMENT_IDENTITY_SCHEMA.optional(), + target: DEPLOYMENT_IDENTITY_SCHEMA, + }) + .strict(); + +const RECORD_SCHEMA = z + .object({ + schemaVersion: z.literal(RECORD_SCHEMA_VERSION), + rootId: z.string().regex(ROOT_ID), + revision: z.string().regex(REVISION), + state: z.discriminatedUnion('kind', [OWNED_STATE_SCHEMA, TRANSFERRING_STATE_SCHEMA]), + }) + .strict(); + +const TRANSITION_SCHEMA = z.discriminatedUnion('kind', [ + z + .object({ + kind: z.literal('claim'), + owner: OWNER_SCHEMA, + selected: DEPLOYMENT_IDENTITY_SCHEMA, + }) + .strict(), + z + .object({ + kind: z.literal('select'), + expectedRevision: z.string().regex(REVISION), + owner: OWNER_SCHEMA, + selected: DEPLOYMENT_IDENTITY_SCHEMA, + }) + .strict(), + z + .object({ + kind: z.literal('begin_transfer'), + expectedRevision: z.string().regex(REVISION), + transactionId: boundedText(512), + from: OWNER_SCHEMA, + to: OWNER_SCHEMA, + target: DEPLOYMENT_IDENTITY_SCHEMA, + }) + .strict(), + z + .object({ + kind: z.literal('commit_transfer'), + expectedRevision: z.string().regex(REVISION), + transactionId: boundedText(512), + to: OWNER_SCHEMA, + target: DEPLOYMENT_IDENTITY_SCHEMA, + }) + .strict(), + z + .object({ + kind: z.literal('rollback_transfer'), + expectedRevision: z.string().regex(REVISION), + transactionId: boundedText(512), + from: OWNER_SCHEMA, + selected: DEPLOYMENT_IDENTITY_SCHEMA, + }) + .strict(), + z + .object({ + kind: z.literal('release'), + expectedRevision: z.string().regex(REVISION), + owner: OWNER_SCHEMA, + }) + .strict(), +]); + +export interface RuntimeHostInstallationOwner { + readonly kind: 'desktop' | 'cli' | 'managed_service' | 'development'; + readonly installationId: string; +} + +export type LocalHostDeploymentState = + | { + readonly kind: 'owned'; + readonly owner: RuntimeHostInstallationOwner; + readonly selected: RuntimeHostDeploymentIdentity; + readonly previous?: RuntimeHostDeploymentIdentity; + } + | { + readonly kind: 'transferring'; + readonly transactionId: string; + readonly from: RuntimeHostInstallationOwner; + readonly to: RuntimeHostInstallationOwner; + readonly selected: RuntimeHostDeploymentIdentity; + readonly previous?: RuntimeHostDeploymentIdentity; + readonly target: RuntimeHostDeploymentIdentity; + }; + +export interface LocalHostDeploymentRecord { + readonly schemaVersion: typeof RECORD_SCHEMA_VERSION; + readonly rootId: string; + /** Opaque compare-and-swap token. It is intentionally not a HostEpoch or PID. */ + readonly revision: string; + readonly state: LocalHostDeploymentState; +} + +export type LocalHostDeploymentTransition = + | { + readonly kind: 'claim'; + readonly owner: RuntimeHostInstallationOwner; + readonly selected: RuntimeHostDeploymentIdentity; + } + | { + readonly kind: 'select'; + readonly expectedRevision: string; + readonly owner: RuntimeHostInstallationOwner; + readonly selected: RuntimeHostDeploymentIdentity; + } + | { + readonly kind: 'begin_transfer'; + readonly expectedRevision: string; + readonly transactionId: string; + readonly from: RuntimeHostInstallationOwner; + readonly to: RuntimeHostInstallationOwner; + readonly target: RuntimeHostDeploymentIdentity; + } + | { + readonly kind: 'commit_transfer'; + readonly expectedRevision: string; + readonly transactionId: string; + readonly to: RuntimeHostInstallationOwner; + readonly target: RuntimeHostDeploymentIdentity; + } + | { + readonly kind: 'rollback_transfer'; + readonly expectedRevision: string; + readonly transactionId: string; + readonly from: RuntimeHostInstallationOwner; + readonly selected: RuntimeHostDeploymentIdentity; + } + | { + readonly kind: 'release'; + readonly expectedRevision: string; + readonly owner: RuntimeHostInstallationOwner; + }; + +export type LocalHostDeploymentTransitionRejection = + | 'owner_exists' + | 'not_owned' + | 'owner_changed' + | 'revision_changed' + | 'transfer_in_progress' + | 'transfer_changed'; + +export type LocalHostDeploymentTransitionResult = + | { + readonly kind: 'applied' | 'unchanged'; + readonly record: LocalHostDeploymentRecord | undefined; + } + | { + readonly kind: 'rejected'; + readonly reason: LocalHostDeploymentTransitionRejection; + readonly record: LocalHostDeploymentRecord | undefined; + }; + +export interface LocalHostDeploymentAuthorityOptions { + /** Test-only or embedding override. Production callers should use the account-local default. */ + readonly authorityRoot?: string; + readonly homeDir?: string; + readonly platform?: NodeJS.Platform; + /** Test-only fault injection. */ + readonly beforeDirectorySync?: ( + path: string, + purpose: LocalHostDeploymentDirectorySyncPurpose, + ) => void | Promise; +} + +export type LocalHostDeploymentDirectorySyncPurpose = + | 'directory_entry' + | 'record_publish' + | 'record_remove' + | 'unchanged_confirmation' + | 'workspace_cleanup'; + +export class LocalHostDeploymentAuthorityError extends Error { + constructor( + readonly code: 'invalid_input' | 'invalid_record' | 'authority_io_failed' | 'commit_unknown', + message: string, + options?: ErrorOptions, + ) { + super(message, options); + this.name = 'LocalHostDeploymentAuthorityError'; + } +} + +export function resolveLocalHostDeploymentAuthorityRoot( + options: LocalHostDeploymentAuthorityOptions = {}, +): string { + return resolveLocalHostDeploymentAuthorityLocation(options).authorityRoot; +} + +function resolveLocalHostDeploymentAuthorityLocation( + options: LocalHostDeploymentAuthorityOptions, +): { + readonly authorityRoot: string; + readonly durabilityBoundary: string | undefined; +} { + if (options.authorityRoot !== undefined) { + if (!isAbsolute(options.authorityRoot)) { + throw new LocalHostDeploymentAuthorityError( + 'invalid_input', + 'The local Runtime Host deployment authority root must be absolute', + ); + } + return { + authorityRoot: resolve(options.authorityRoot), + durabilityBoundary: undefined, + }; + } + const accountHome = options.homeDir ?? userInfo().homedir; + const platform = options.platform ?? process.platform; + const accountPath = platform === 'win32' ? win32 : posix; + if (!accountPath.isAbsolute(accountHome)) { + throw new LocalHostDeploymentAuthorityError( + 'invalid_input', + 'The OS account home must be absolute', + ); + } + const durabilityBoundary = accountPath.normalize(accountHome); + const pathSegments = + platform === 'darwin' + ? ['Library', 'Application Support', 'Maka', 'runtime-host-ownership'] + : platform === 'win32' + ? ['AppData', 'Local', 'Maka', 'runtime-host-ownership'] + : ['.local', 'share', 'Maka', 'runtime-host-ownership']; + return { + authorityRoot: accountPath.join(durabilityBoundary, ...pathSegments), + durabilityBoundary, + }; +} + +export async function readLocalHostDeploymentRecord( + rootId: string, + options: LocalHostDeploymentAuthorityOptions = {}, +): Promise { + assertRootId(rootId); + const path = recordPath(rootId, options); + return readRecord(path, rootId); +} + +export async function applyLocalHostDeploymentTransition( + rootId: string, + transition: LocalHostDeploymentTransition, + options: LocalHostDeploymentAuthorityOptions = {}, +): Promise { + assertRootId(rootId); + const canonicalTransition = parseTransition(transition); + const { authorityRoot, durabilityBoundary } = + resolveLocalHostDeploymentAuthorityLocation(options); + await preparePrivateDirectory(authorityRoot, durabilityBoundary, options); + const path = join(authorityRoot, `${rootId}.json`); + try { + return await withProcessLifetimeFileUpdateLock( + path, + async () => { + await removeAbandonedRecordWorkspaces(authorityRoot, rootId, options); + const current = await readRecord(path, rootId); + const result = reduceTransition(rootId, current, canonicalTransition); + if (result.kind === 'unchanged') { + await confirmUnchangedDurability(authorityRoot, options); + return result; + } + if (result.kind === 'rejected') return result; + if (result.record) await writeRecord(path, result.record, options); + else await removeRecord(path, options); + return result; + }, + AUTHORITY_LOCK_TIMEOUT_MS, + ); + } catch (error) { + if (error instanceof LocalHostDeploymentAuthorityError) throw error; + throw new LocalHostDeploymentAuthorityError( + 'authority_io_failed', + 'Unable to update local Runtime Host deployment ownership', + { cause: error }, + ); + } +} + +function reduceTransition( + rootId: string, + current: LocalHostDeploymentRecord | undefined, + transition: LocalHostDeploymentTransition, +): LocalHostDeploymentTransitionResult { + switch (transition.kind) { + case 'claim': { + if (!current) return applied(record(rootId, owned(transition.owner, transition.selected))); + if ( + current.state.kind === 'owned' && + sameOwner(current.state.owner, transition.owner) && + sameDeployment(current.state.selected, transition.selected) + ) { + return unchanged(current); + } + return rejected( + current.state.kind === 'transferring' ? 'transfer_in_progress' : 'owner_exists', + current, + ); + } + case 'select': { + if (!current) return rejected('not_owned', current); + if (current.state.kind === 'transferring') return rejected('transfer_in_progress', current); + if (!sameOwner(current.state.owner, transition.owner)) + return rejected('owner_changed', current); + if (sameDeployment(current.state.selected, transition.selected)) return unchanged(current); + if (current.revision !== transition.expectedRevision) + return rejected('revision_changed', current); + return applied( + record(rootId, owned(transition.owner, transition.selected, current.state.selected)), + ); + } + case 'begin_transfer': { + if (!current) return rejected('not_owned', current); + if (current.state.kind === 'transferring') { + return sameTransfer(current.state, transition) + ? unchanged(current) + : rejected('transfer_changed', current); + } + if (!sameOwner(current.state.owner, transition.from)) + return rejected('owner_changed', current); + if (current.revision !== transition.expectedRevision) + return rejected('revision_changed', current); + return applied( + record(rootId, { + kind: 'transferring', + transactionId: transition.transactionId, + from: transition.from, + to: transition.to, + selected: current.state.selected, + ...(current.state.previous ? { previous: current.state.previous } : {}), + target: transition.target, + }), + ); + } + case 'commit_transfer': { + if (!current) return rejected('not_owned', current); + if (current.state.kind === 'owned') { + return sameOwner(current.state.owner, transition.to) && + sameDeployment(current.state.selected, transition.target) + ? unchanged(current) + : rejected('transfer_changed', current); + } + if (!matchesTransferTarget(current.state, transition)) + return rejected('transfer_changed', current); + if (current.revision !== transition.expectedRevision) + return rejected('revision_changed', current); + return applied( + record(rootId, owned(transition.to, transition.target, current.state.selected)), + ); + } + case 'rollback_transfer': { + if (!current) return rejected('not_owned', current); + if (current.state.kind === 'owned') { + return sameOwner(current.state.owner, transition.from) && + sameDeployment(current.state.selected, transition.selected) + ? unchanged(current) + : rejected('transfer_changed', current); + } + if ( + current.state.transactionId !== transition.transactionId || + !sameOwner(current.state.from, transition.from) || + !sameDeployment(current.state.selected, transition.selected) + ) { + return rejected('transfer_changed', current); + } + if (current.revision !== transition.expectedRevision) + return rejected('revision_changed', current); + return applied( + record(rootId, owned(current.state.from, current.state.selected, current.state.previous)), + ); + } + case 'release': { + if (!current) return unchanged(undefined); + if (current.state.kind === 'transferring') return rejected('transfer_in_progress', current); + if (!sameOwner(current.state.owner, transition.owner)) + return rejected('owner_changed', current); + if (current.revision !== transition.expectedRevision) + return rejected('revision_changed', current); + return applied(undefined); + } + } +} + +function record(rootId: string, state: LocalHostDeploymentState): LocalHostDeploymentRecord { + return { + schemaVersion: RECORD_SCHEMA_VERSION, + rootId, + revision: randomUUID(), + state, + }; +} + +function owned( + owner: RuntimeHostInstallationOwner, + selected: RuntimeHostDeploymentIdentity, + previous?: RuntimeHostDeploymentIdentity, +): Extract { + return { + kind: 'owned', + owner, + selected, + ...(previous ? { previous } : {}), + }; +} + +function sameTransfer( + current: Extract, + transition: Extract, +): boolean { + return ( + current.transactionId === transition.transactionId && + sameOwner(current.from, transition.from) && + sameOwner(current.to, transition.to) && + sameDeployment(current.target, transition.target) + ); +} + +function matchesTransferTarget( + current: Extract, + transition: Extract, +): boolean { + return ( + current.transactionId === transition.transactionId && + sameOwner(current.to, transition.to) && + sameDeployment(current.target, transition.target) + ); +} + +function sameOwner( + left: RuntimeHostInstallationOwner, + right: RuntimeHostInstallationOwner, +): boolean { + return left.kind === right.kind && left.installationId === right.installationId; +} + +function sameDeployment( + left: RuntimeHostDeploymentIdentity, + right: RuntimeHostDeploymentIdentity, +): boolean { + return ( + left.kind === right.kind && left.version === right.version && left.integrity === right.integrity + ); +} + +function applied( + record: LocalHostDeploymentRecord | undefined, +): LocalHostDeploymentTransitionResult { + return { kind: 'applied', record }; +} + +function unchanged( + record: LocalHostDeploymentRecord | undefined, +): LocalHostDeploymentTransitionResult { + return { kind: 'unchanged', record }; +} + +function rejected( + reason: LocalHostDeploymentTransitionRejection, + record: LocalHostDeploymentRecord | undefined, +): LocalHostDeploymentTransitionResult { + return { kind: 'rejected', reason, record }; +} + +function assertRootId(rootId: string): void { + if (!ROOT_ID.test(rootId)) { + throw new LocalHostDeploymentAuthorityError( + 'invalid_input', + 'The local Runtime Host deployment rootId is invalid', + ); + } +} + +function parseTransition(transition: LocalHostDeploymentTransition): LocalHostDeploymentTransition { + try { + const parsed = TRANSITION_SCHEMA.parse(transition) as LocalHostDeploymentTransition; + if (parsed.kind === 'begin_transfer' && sameOwner(parsed.from, parsed.to)) { + throw new Error('Transfer owners must differ'); + } + return parsed; + } catch (error) { + if (error instanceof LocalHostDeploymentAuthorityError) throw error; + throw new LocalHostDeploymentAuthorityError( + 'invalid_input', + 'The local Runtime Host deployment transition is invalid', + { cause: error }, + ); + } +} + +function recordPath(rootId: string, options: LocalHostDeploymentAuthorityOptions): string { + return join(resolveLocalHostDeploymentAuthorityRoot(options), `${rootId}.json`); +} + +async function readRecord( + path: string, + expectedRootId: string, +): Promise { + let pathMetadata: Awaited>; + try { + pathMetadata = await lstat(path); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return undefined; + throw authorityIo('Unable to inspect local Runtime Host deployment ownership', error); + } + if (!pathMetadata.isFile() || pathMetadata.isSymbolicLink()) { + throw new LocalHostDeploymentAuthorityError( + 'invalid_record', + 'The local Runtime Host deployment owner record is invalid', + ); + } + let handle: Awaited>; + try { + handle = await open( + path, + fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW | fsConstants.O_NONBLOCK, + ); + } catch (error) { + if (isNodeError(error, 'ENOENT')) return undefined; + throw authorityIo('Unable to open local Runtime Host deployment ownership', error); + } + let document: Uint8Array; + try { + const metadata = await handle.stat(); + if (!metadata.isFile() || metadata.size > RECORD_MAX_BYTES) { + throw new LocalHostDeploymentAuthorityError( + 'invalid_record', + 'The local Runtime Host deployment owner record is invalid', + ); + } + document = await readBoundedRecord(handle); + } catch (error) { + if (error instanceof LocalHostDeploymentAuthorityError) throw error; + throw authorityIo('Unable to read local Runtime Host deployment ownership', error); + } finally { + await handle.close().catch(() => undefined); + } + try { + const decoded = new TextDecoder('utf-8', { fatal: true }).decode(document); + const parsed = RECORD_SCHEMA.parse(JSON.parse(decoded)); + if (parsed.rootId !== expectedRootId) { + throw new LocalHostDeploymentAuthorityError( + 'invalid_record', + 'The local Runtime Host deployment owner record belongs to a different State Root', + ); + } + return parsed as LocalHostDeploymentRecord; + } catch (error) { + if (error instanceof LocalHostDeploymentAuthorityError) throw error; + throw new LocalHostDeploymentAuthorityError( + 'invalid_record', + 'The local Runtime Host deployment owner record is invalid', + { cause: error }, + ); + } +} + +async function readBoundedRecord(handle: Awaited>): Promise { + const bytes = Buffer.allocUnsafe(RECORD_MAX_BYTES + 1); + let offset = 0; + while (offset < bytes.length) { + const result = await handle.read(bytes, offset, bytes.length - offset, offset); + if (result.bytesRead === 0) break; + offset += result.bytesRead; + } + if (offset > RECORD_MAX_BYTES) { + throw new LocalHostDeploymentAuthorityError( + 'invalid_record', + 'The local Runtime Host deployment owner record is invalid', + ); + } + return bytes.subarray(0, offset); +} + +async function writeRecord( + path: string, + value: LocalHostDeploymentRecord, + options: LocalHostDeploymentAuthorityOptions, +): Promise { + const temporaryPath = `${path}.${randomUUID()}.tmp`; + const document = `${JSON.stringify(value)}\n`; + if (Buffer.byteLength(document, 'utf8') > RECORD_MAX_BYTES) { + throw new LocalHostDeploymentAuthorityError( + 'invalid_input', + 'The local Runtime Host deployment owner record is too large', + ); + } + let handle: Awaited> | undefined; + let published = false; + try { + handle = await open( + temporaryPath, + fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW, + 0o600, + ); + await handle.writeFile(document, 'utf8'); + await handle.sync(); + await handle.close(); + handle = undefined; + await rename(temporaryPath, path); + published = true; + await syncDirectory(dirname(path), 'record_publish', options); + } catch (error) { + await handle?.close().catch(() => undefined); + await rm(temporaryPath, { force: true }).catch(() => undefined); + if (published) { + throw new LocalHostDeploymentAuthorityError( + 'commit_unknown', + 'Local Runtime Host deployment ownership may have been persisted; re-read it before retrying', + { cause: error }, + ); + } + throw authorityIo('Unable to persist local Runtime Host deployment ownership', error); + } +} + +async function removeRecord( + path: string, + options: LocalHostDeploymentAuthorityOptions, +): Promise { + let removed = false; + try { + await unlink(path); + removed = true; + await syncDirectory(dirname(path), 'record_remove', options); + } catch (error) { + if (removed) { + throw new LocalHostDeploymentAuthorityError( + 'commit_unknown', + 'Local Runtime Host deployment ownership may have been released; re-read it before retrying', + { cause: error }, + ); + } + if (isNodeError(error, 'ENOENT')) return; + throw authorityIo('Unable to release local Runtime Host deployment ownership', error); + } +} + +async function preparePrivateDirectory( + path: string, + durabilityBoundary: string | undefined, + options: LocalHostDeploymentAuthorityOptions, +): Promise { + try { + const missing: string[] = []; + let candidate = path; + while (!(await pathExists(candidate))) { + missing.push(candidate); + const parent = dirname(candidate); + if (parent === candidate) break; + candidate = parent; + } + const boundaryMetadata = await lstat(candidate); + if (!boundaryMetadata.isDirectory() || boundaryMetadata.isSymbolicLink()) { + throw new Error('Authority path contains a non-directory entry'); + } + if (candidate !== durabilityBoundary) { + await confirmDirectoryEntry(dirname(candidate), options); + } + for (const directory of missing.reverse()) { + try { + await mkdir(directory, { mode: 0o700 }); + } catch (error) { + if (!isNodeError(error, 'EEXIST')) throw error; + } + const metadata = await lstat(directory); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error('Authority path contains a non-directory entry'); + } + await confirmDirectoryEntry(dirname(directory), options); + } + const metadata = await lstat(path); + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + throw new Error('Authority path is not a private directory'); + } + if (process.platform !== 'win32') await chmod(path, 0o700); + } catch (error) { + throw authorityIo('Unable to prepare local Runtime Host deployment authority', error); + } +} + +async function removeAbandonedRecordWorkspaces( + authorityRoot: string, + rootId: string, + options: LocalHostDeploymentAuthorityOptions, +): Promise { + const temporaryRevision = new RegExp( + `^${rootId}\\.json\\.[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\\.tmp$`, + 'u', + ); + let removed = false; + for (const entry of await readdir(authorityRoot, { withFileTypes: true })) { + if (!temporaryRevision.test(entry.name)) continue; + await unlink(join(authorityRoot, entry.name)); + removed = true; + } + if (removed) await syncDirectory(authorityRoot, 'workspace_cleanup', options); +} + +async function pathExists(path: string): Promise { + try { + await lstat(path); + return true; + } catch (error) { + if (isNodeError(error, 'ENOENT')) return false; + throw error; + } +} + +async function confirmDirectoryEntry( + path: string, + options: LocalHostDeploymentAuthorityOptions, +): Promise { + try { + await syncDirectory(path, 'directory_entry', options); + } catch (error) { + throw new LocalHostDeploymentAuthorityError( + 'commit_unknown', + 'The local Runtime Host deployment authority directory may not be durable; retry the operation', + { cause: error }, + ); + } +} + +async function confirmUnchangedDurability( + authorityRoot: string, + options: LocalHostDeploymentAuthorityOptions, +): Promise { + try { + await syncDirectory(authorityRoot, 'unchanged_confirmation', options); + } catch (error) { + throw new LocalHostDeploymentAuthorityError( + 'commit_unknown', + 'Local Runtime Host deployment ownership is visible but its durability is not confirmed; retry the exact transition', + { cause: error }, + ); + } +} + +async function syncDirectory( + path: string, + purpose: LocalHostDeploymentDirectorySyncPurpose, + options: LocalHostDeploymentAuthorityOptions, +): Promise { + if (process.platform === 'win32') return; + await options.beforeDirectorySync?.(path, purpose); + const directory = await open(path, 'r'); + try { + await directory.sync(); + } finally { + await directory.close(); + } +} + +function authorityIo(message: string, cause: unknown): LocalHostDeploymentAuthorityError { + return cause instanceof LocalHostDeploymentAuthorityError + ? cause + : new LocalHostDeploymentAuthorityError('authority_io_failed', message, { + cause, + }); +} + +function isNodeError(error: unknown, code: string): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error && error.code === code; +} diff --git a/packages/runtime-host/tsconfig.owner-platform.json b/packages/runtime-host/tsconfig.owner-platform.json new file mode 100644 index 0000000000..e80d8cd1bb --- /dev/null +++ b/packages/runtime-host/tsconfig.owner-platform.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": ".owner-platform-dist", + "incremental": false + }, + "files": [ + "src/__tests__/local-deployment-owner.test.ts", + "src/__tests__/fixtures/local-deployment-owner-claim.ts" + ] +} diff --git a/scripts/ci-test-plan.test.mjs b/scripts/ci-test-plan.test.mjs index 5324bebe6d..438cacb866 100644 --- a/scripts/ci-test-plan.test.mjs +++ b/scripts/ci-test-plan.test.mjs @@ -342,6 +342,7 @@ test('pull request triggers stay on an explicit allowlist', () => { 'copilot-auto-review.yml', 'dependency-audit.yml', 'release-windows-check.yml', + 'runtime-host-owner-platform.yml', 'windows-sandbox-w0.yml', ]); });