From cb0e6568953f7cc888e64c9c9dc3a98af85b544b Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:31:38 +0000 Subject: [PATCH 01/25] build: package pinned native installer distributions --- .github/workflows/native-installer.yml | 64 ++++++++++ scripts/build-native-installer-ci.mjs | 63 ++++++++++ scripts/build-native-installer.mjs | 151 ++++++++++++++++++++++++ scripts/build-native-installer.test.mjs | 61 ++++++++++ 4 files changed, 339 insertions(+) create mode 100644 .github/workflows/native-installer.yml create mode 100644 scripts/build-native-installer-ci.mjs create mode 100644 scripts/build-native-installer.mjs create mode 100644 scripts/build-native-installer.test.mjs diff --git a/.github/workflows/native-installer.yml b/.github/workflows/native-installer.yml new file mode 100644 index 000000000..45743a68e --- /dev/null +++ b/.github/workflows/native-installer.yml @@ -0,0 +1,64 @@ +name: native-installer + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + bundle: + strategy: + fail-fast: false + matrix: + os: [ubuntu-22.04, macos-14, windows-2022] + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-go@v7 + with: + go-version-file: go.mod + - uses: actions/setup-node@v6 + with: + node-version: '22.22.0' + - name: Build the native daemon and test bundle boundaries + run: | + go build -o "$RUNNER_TEMP/oac-daemon${{ runner.os == 'Windows' && '.exe' || '' }}" ./apps/parsar-daemon/cmd/parsar-daemon + node --test scripts/build-native-installer.test.mjs + - name: Build pinned native components + run: | + npm install --global pnpm@10.30.3 + pnpm install --frozen-lockfile --filter @parsar/claude-sdk-adapter... + pnpm --filter @parsar/claude-sdk-adapter build --outDir "$RUNNER_TEMP/claude-compiled" + pnpm --config.inject-workspace-packages=true --config.extend-node-path=false --filter @parsar/claude-sdk-adapter deploy --prod "$RUNNER_TEMP/claude-runtime" + rm -rf "$RUNNER_TEMP/claude-runtime/dist" + mv "$RUNNER_TEMP/claude-compiled" "$RUNNER_TEMP/claude-runtime/dist" + # Reify the dedicated frozen export lock without pnpm's symlink layout. + # Flattening isolated package links changes Node dependency resolution. + rm -rf "$RUNNER_TEMP/claude-runtime/node_modules" + pnpm --dir "$RUNNER_TEMP/claude-runtime" install --prod --frozen-lockfile --config.node-linker=hoisted --ignore-scripts + npm install --prefix "$RUNNER_TEMP/native-tools" --no-save @openai/codex@0.153.4 + - name: Package and exercise CLI-only installation, additions and reuse + run: | + if [[ "$RUNNER_OS" == Windows ]]; then + export CLAUDE_CODE_GIT_BASH_PATH='C:\Program Files\Git\bin\bash.exe' + fi + node scripts/build-native-installer-ci.mjs + - name: Verify native Harness protocols without model requests + run: | + if [[ "$RUNNER_OS" == Windows ]]; then + export CLAUDE_CODE_GIT_BASH_PATH='C:\Program Files\Git\bin\bash.exe' + fi + node scripts/native-harness-smoke.mjs --codex-binary "$RUNNER_TEMP/native-installer/components/codex/bin/codex${{ runner.os == 'Windows' && '.exe' || '' }}" --claude-runtime "$RUNNER_TEMP/native-installer/components/claude" + - name: Archive the native distribution + run: tar -C "$RUNNER_TEMP/native-installer" -czf "$RUNNER_TEMP/oac-native-installer-${{ runner.os }}-${{ runner.arch }}.tar.gz" . + - uses: actions/upload-artifact@v6 + with: + name: oac-native-installer-${{ runner.os }}-${{ runner.arch }} + path: ${{ runner.temp }}/oac-native-installer-*.tar.gz + if-no-files-found: error diff --git a/scripts/build-native-installer-ci.mjs b/scripts/build-native-installer-ci.mjs new file mode 100644 index 000000000..fba7b973e --- /dev/null +++ b/scripts/build-native-installer-ci.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// Native CI assembly and installation smoke; no provider credentials or model calls. +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { access, mkdir, readdir, realpath, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve } from 'node:path'; +import { buildBundle } from './build-native-installer.mjs'; + +const root = resolve(process.env.RUNNER_TEMP ?? ''); +if (!process.env.RUNNER_TEMP) throw new Error('RUNNER_TEMP is required'); +const windows = process.platform === 'win32'; +const daemon = join(root, windows ? 'oac-daemon.exe' : 'oac-daemon'); +// setup-node extracts the complete official Node distribution, including npm. +const node = windows ? dirname(process.execPath) : dirname(dirname(process.execPath)); +async function findCodex(directory, depth = 0) { + if (depth > 6) return []; + try { + await access(join(directory, 'bin', windows ? 'codex.exe' : 'codex')); + await access(join(directory, 'codex-resources')); + return [directory]; + } catch { /* Search the installed platform package rather than hard-code npm aliases. */ } + const results = []; + for (const entry of await readdir(directory, { withFileTypes: true })) { + if (entry.isDirectory()) results.push(...await findCodex(join(directory, entry.name), depth + 1)); + } + return results; +} +const candidates = await findCodex(join(root, 'native-tools', 'node_modules', '@openai')); +assert.equal(candidates.length, 1, 'Expected exactly one native Codex artifact'); +const bundle = join(root, 'native-installer'); +const receipt = await buildBundle({ daemon, node: await realpath(node), codex: candidates[0], claude: join(root, 'claude-runtime'), output: bundle }); +console.log(JSON.stringify({ stage: 'bundle', os: receipt.os, arch: receipt.arch, components: Object.keys(receipt.components), model_requests: 0 })); +const workspace = join(root, 'native-install-workspace'); await mkdir(workspace); +const credential = join(root, 'native-install-credential.json'); +const environment = randomUUID(); +await writeFile(credential, JSON.stringify({ key_id: randomUUID(), executor_token: 'fixture-only-not-a-real-credential', environment_id: environment }), { mode: 0o600 }); +const installDir = join(root, 'native-install-smoke'); +const args = ['install', '--non-interactive', '--bundle-dir', bundle, + '--install-dir', installDir, '--remote', 'ws://localhost:1/api/v1/agent-daemon/ws', + '--environment-id', environment, '--workspace', workspace, '--credential-file', credential]; +const command = join(bundle, windows ? 'oac-daemon.exe' : 'oac-daemon'); +const env = { ...process.env, OAC_RUNTIME_HOME: join(root, 'native-install-state') }; +function install(extra, expected) { + const result = spawnSync(command, [...args, ...extra], { env, encoding: 'utf8', timeout: 120000, maxBuffer: 1024 * 1024 }); + // Keep native output out of CI logs, even for fixture-only credentials. + assert.equal(Boolean(result.error), false, 'Installation command did not settle'); + assert.equal(result.status === 0, expected, 'Unexpected installation result'); + assert.ok(!`${result.stdout}${result.stderr}`.includes('fixture-only-not-a-real-credential'), 'Installer echoed a credential'); +} +install(['--harness', 'codex'], true); +install(['--harness', 'codex,claude'], true); +install(['--harness', 'codex,claude'], true); +if (windows) install(['--harness', 'minimax'], false); +const installedCommand = join(installDir, 'bin', windows ? 'oac-daemon.exe' : 'oac-daemon'); +const installedEnv = { ...process.env }; delete installedEnv.OAC_RUNTIME_HOME; +const installedVersion = spawnSync(installedCommand, ['version'], { env: installedEnv, encoding: 'utf8', timeout: 10000 }); +assert.equal(installedVersion.status, 0, 'Installed daemon could not start'); +assert.equal(installedVersion.stdout.trim(), receipt.daemon_version, 'Installed daemon identity differs'); +const incomplete = spawnSync(command, ['install', '--non-interactive'], { env, input: '', encoding: 'utf8', timeout: 10000 }); +assert.equal(Boolean(incomplete.error), false, 'Missing arguments waited for interaction'); +assert.notEqual(incomplete.status, 0, 'Missing required arguments succeeded'); +console.log(JSON.stringify({ stage: 'install', install: 'passed', additive: 'passed', reuse: 'passed', missing_arguments: 'rejected', connection: 'not_attempted', model: 'not_configured', model_requests: 0 })); diff --git a/scripts/build-native-installer.mjs b/scripts/build-native-installer.mjs new file mode 100644 index 000000000..c83f60541 --- /dev/null +++ b/scripts/build-native-installer.mjs @@ -0,0 +1,151 @@ +#!/usr/bin/env node +// Package trusted, already-built native artifacts. This does not install on a host. +import { createHash } from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { chmod, copyFile, lstat, mkdir, mkdtemp, readFile, readdir, realpath, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export const pins = { node: '22.22.0', codex: '0.153.4', claude: '0.3.269', minimax: '0.4.12' }; +const platforms = { linux: 'linux', darwin: 'darwin', win32: 'windows' }; +const architectures = { x64: 'amd64', arm64: 'arm64' }; +const inside = (root, path) => { + const rel = relative(root, path); + return rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel); +}; + +// Flatten contained links, including directory links used by pnpm exports. Every +// traversal checks its resolved target; active ancestors detect directory cycles. +export async function copyComponent(source, destination) { + const root = await realpath(source); + if (!(await stat(root)).isDirectory()) throw new Error('Component source must be a directory'); + const files = {}; + async function visit(path, target, ancestors) { + const resolved = await realpath(path); + if (!inside(root, resolved)) throw new Error('Component link escapes its source'); + const info = await stat(resolved); + if (info.isDirectory()) { + if (ancestors.has(resolved)) throw new Error('Component contains a directory link cycle'); + const next = new Set([...ancestors, resolved]); + await mkdir(target); + for (const name of (await readdir(resolved)).sort()) { + // Manifest paths are portable slash paths, never Windows aliases/streams. + if (/[\\:\x00-\x1f]/.test(name) || /[. ]$/.test(name) || /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name)) { + throw new Error('Component contains a non-portable file name'); + } + await visit(join(resolved, name), join(target, name), next); + } + return; + } + if (!info.isFile()) throw new Error('Component contains a non-regular file'); + await copyFile(resolved, target); + const executable = Boolean(info.mode & 0o111) || /\.(exe|cmd|bat)$/i.test(target); + await chmod(target, executable ? 0o755 : 0o644); + files[relative(destination, target).split(sep).join('/')] = { + sha256: createHash('sha256').update(await readFile(target)).digest('hex'), executable, + }; + } + await visit(root, destination, new Set()); + if (!Object.keys(files).length) throw new Error('Component is empty'); + return files; +} + +function probe(command, args, cwd, env) { + const result = spawnSync(command, args, { cwd, env, input: '', encoding: 'utf8', timeout: 30000, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024 }); + if (result.error || result.status !== 0) throw new Error('Native component compatibility probe failed'); + return result.stdout.trim(); +} +const json = async path => JSON.parse(await readFile(path, 'utf8')); +const requireFile = async path => { if (!(await stat(path)).isFile()) throw new Error('Required component file is missing'); }; + +export async function validateComponents(root, names, daemonVersion) { + const windows = process.platform === 'win32'; + const nodeRoot = join(root, 'components', 'node'); + const node = join(nodeRoot, windows ? 'node.exe' : 'bin/node'); + const npm = join(nodeRoot, windows ? 'node_modules/npm/bin/npm-cli.js' : 'lib/node_modules/npm/bin/npm-cli.js'); + const env = { ...process.env, PATH: [dirname(node), join(root, 'components', 'minimax', 'bin'), process.env.PATH ?? ''].join(sep === '\\' ? ';' : ':') }; + if (probe(node, ['--version'], root, env) !== `v${pins.node}`) throw new Error('Node version does not match the pin'); + const identity = JSON.parse(probe(node, ['-p', 'JSON.stringify([process.platform,process.arch])'], root, env)); + if (identity[0] !== process.platform || identity[1] !== process.arch) throw new Error('Node platform does not match the build host'); + await requireFile(npm); + probe(node, [npm, '--version'], root, env); + const daemon = join(root, windows ? 'oac-daemon.exe' : 'oac-daemon'); + if (probe(daemon, ['version'], root, env) !== daemonVersion) throw new Error('Daemon identity changed during packaging'); + for (const name of names) { + const component = join(root, 'components', name); + if (name === 'codex') { + if (!(await stat(join(component, 'codex-resources'))).isDirectory()) throw new Error('Codex resources are missing'); + const binary = join(component, 'bin', windows ? 'codex.exe' : 'codex'); + if (probe(binary, ['--version'], component, env) !== `codex-cli ${pins.codex}`) throw new Error('Codex version does not match the pin'); + } else if (name === 'claude') { + const manifest = await json(join(component, 'package.json')); + if (manifest.dependencies?.['@anthropic-ai/claude-agent-sdk']?.split('(')[0] !== pins.claude) throw new Error('Claude SDK pin mismatch'); + await requireFile(join(component, 'pnpm-lock.yaml')); + await requireFile(join(component, 'dist/main.js')); + const info = JSON.parse(probe(node, [join(component, 'dist/runtime_check.js')], component, env)); + if (info.type !== 'runtime_ready' || info.protocol !== 3 || info.sdk !== pins.claude || info.native !== '2.1.269 (Claude Code)') throw new Error('Claude runtime compatibility failed'); + } else if (name === 'minimax') { + if (windows) throw new Error('MiniMax is not supported on Windows'); + const upstream = await json(join(component, 'source.json')); + const expected = await json(new URL('../packages/mcode-harness/source.json', import.meta.url)); + if (upstream.version !== pins.minimax || upstream.revision !== expected.revision) throw new Error('MiniMax source pin mismatch'); + for (const entry of ['launch.mjs', 'dist/worker.mjs', 'provenance.json', 'native-patch.json']) await requireFile(join(component, entry)); + if (probe(node, [join(component, 'native/cli.js'), '--version'], component, env) !== pins.minimax) throw new Error('MiniMax native pin mismatch'); + const info = JSON.parse(probe(node, [join(component, 'check.mjs')], component, env)); + if (info.protocol !== 1 || info.native !== pins.minimax || info.source !== expected.revision) throw new Error('MiniMax companion compatibility failed'); + } + } +} + +export async function buildBundle(options) { + if (!platforms[process.platform] || !architectures[process.arch]) throw new Error('Unsupported native bundle platform'); + for (const required of ['daemon', 'node', 'output']) if (!options[required]) throw new Error(`Missing --${required}`); + for (const path of Object.values(options)) if (!isAbsolute(path)) throw new Error('Bundle paths must be absolute'); + const names = ['codex', 'claude', 'minimax'].filter(name => options[name]); + if (!names.length) throw new Error('At least one Harness source is required'); + if (process.platform === 'win32' && options.minimax) throw new Error('MiniMax is not supported on Windows'); + try { await lstat(options.output); throw new Error('Bundle output already exists'); } catch (error) { if (error.code !== 'ENOENT') throw error; } + // Refuse staging inside a source: recursive copying must not ingest its own output. + for (const name of ['node', ...names]) if (inside(await realpath(options[name]), resolve(options.output))) throw new Error('Output must be outside component sources'); + const daemonVersion = probe(options.daemon, ['version'], dirname(options.daemon), process.env); + if (!/^[0-9A-Za-z][0-9A-Za-z.+_-]{0,127}$/.test(daemonVersion)) throw new Error('Invalid daemon version'); + await mkdir(dirname(options.output), { recursive: true }); + const staging = await mkdtemp(join(dirname(options.output), '.native-bundle-')); + try { + const daemon = join(staging, process.platform === 'win32' ? 'oac-daemon.exe' : 'oac-daemon'); + await copyFile(options.daemon, daemon); await chmod(daemon, 0o755); + await mkdir(join(staging, 'components')); + const components = {}; + for (const name of ['node', ...names]) components[name] = { version: pins[name], files: await copyComponent(options[name], join(staging, 'components', name)) }; + if (components.minimax) { + const component = join(staging, 'components', 'minimax'); + const rg = join(component, 'native', 'node_modules', '@vscode', `ripgrep-${process.platform}-${process.arch}`, 'bin', 'rg'); + await requireFile(rg); + await mkdir(join(component, 'bin'), { recursive: true }); + await copyFile(rg, join(component, 'bin', 'rg')); await chmod(join(component, 'bin', 'rg'), 0o755); + components.minimax.files['bin/rg'] = { sha256: createHash('sha256').update(await readFile(rg)).digest('hex'), executable: true }; + } + await validateComponents(staging, names, daemonVersion); + const manifest = { schema: 1, daemon_version: daemonVersion, os: platforms[process.platform], arch: architectures[process.arch], components }; + await writeFile(join(staging, 'bundle.json'), JSON.stringify(manifest, null, 2) + '\n'); + await rename(staging, options.output); + return manifest; + } finally { await rm(staging, { recursive: true, force: true }); } +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { + const options = {}; + for (let i = 2; i < process.argv.length; i += 2) { + const key = process.argv[i].replace(/^--/, ''); + if (!process.argv[i].startsWith('--') || !['daemon', 'node', 'codex', 'claude', 'minimax', 'output'].includes(key) || options[key] || !process.argv[i + 1]) throw new Error('Expected --daemon, --node, --codex, --claude, --minimax or --output with an absolute path'); + options[key] = process.argv[i + 1]; + } + const manifest = await buildBundle(options); + console.log(JSON.stringify({ status: 'built', os: manifest.os, arch: manifest.arch, components: Object.keys(manifest.components), model_requests: 0 })); + } catch (error) { + // Native output and environment values are never printed by the packager. + console.error(error.code ? 'Native bundle source is unavailable' : error instanceof SyntaxError ? 'Native component metadata is invalid' : error.message); + process.exitCode = 1; + } +} diff --git a/scripts/build-native-installer.test.mjs b/scripts/build-native-installer.test.mjs new file mode 100644 index 000000000..83816a828 --- /dev/null +++ b/scripts/build-native-installer.test.mjs @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { chmod, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { buildBundle, copyComponent } from './build-native-installer.mjs'; + +async function fixture(t) { + const root = await mkdtemp(join(tmpdir(), 'oac-native-bundle-test-')); + t.after(() => rm(root, { recursive: true, force: true })); + const source = join(root, 'source'); await mkdir(source); + return { root, source, output: join(root, 'output') }; +} + +test('component preserves bytes and executable metadata with contained links flattened', async t => { + const { source, output } = await fixture(t); + await mkdir(join(source, 'lib')); + await writeFile(join(source, 'lib', 'native'), 'native bytes'); + await chmod(join(source, 'lib', 'native'), 0o755); + await symlink(join(source, 'lib'), join(source, 'linked'), process.platform === 'win32' ? 'junction' : 'dir'); + const files = await copyComponent(source, output); + assert.equal(await readFile(join(output, 'linked/native'), 'utf8'), 'native bytes'); + assert.equal((await lstat(join(output, 'linked'))).isSymbolicLink(), false); + assert.equal(files['linked/native'].sha256, createHash('sha256').update('native bytes').digest('hex')); + if (process.platform !== 'win32') assert.equal(files['linked/native'].executable, true); + assert.deepEqual(Object.keys(files).sort(), ['lib/native', 'linked/native']); +}); + +test('component rejects escaping directory links', async t => { + const { root, source, output } = await fixture(t); + await mkdir(join(root, 'outside')); await writeFile(join(root, 'outside/secret'), 'not bundled'); + await symlink(join(root, 'outside'), join(source, 'external'), process.platform === 'win32' ? 'junction' : 'dir'); + await assert.rejects(copyComponent(source, output), /escapes/); +}); + +test('component rejects cyclic links without recursing forever', async t => { + const { source, output } = await fixture(t); + await symlink(source, join(source, 'cycle'), process.platform === 'win32' ? 'junction' : 'dir'); + await assert.rejects(copyComponent(source, output), /cycle/); +}); + +test('bundle does not replace an existing output or accept relative paths', async t => { + const { source, output } = await fixture(t); + await mkdir(output); await writeFile(join(output, 'keep'), 'existing install'); + await assert.rejects(buildBundle({ daemon: join(source, 'daemon'), node: source, codex: source, output }), /already exists/); + assert.equal(await readFile(join(output, 'keep'), 'utf8'), 'existing install'); + await assert.rejects(buildBundle({ daemon: 'relative', node: source, codex: source, output }), /absolute/); +}); + +test('bundle requires a selected harness and rejects output inside sources', async t => { + const { source, output } = await fixture(t); + await assert.rejects(buildBundle({ daemon: join(source, 'daemon'), node: source, output }), /Harness source/); + await assert.rejects(buildBundle({ daemon: join(source, 'daemon'), node: source, codex: source, output: join(source, 'out') }), /outside/); +}); + +test('component rejects nonportable paths', { skip: process.platform === 'win32' }, async t => { + const { source, output } = await fixture(t); + await writeFile(join(source, 'bad:name'), 'bad'); + await assert.rejects(copyComponent(source, output), /non-portable/); +}); From 67c8c031b1232a5f5fefb72fd9acb641a032f902 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:34:51 +0000 Subject: [PATCH 02/25] refactor(install): retire user-managed Docker installer --- deploy/install/install.py | 7 +- deploy/install/installer_fakes.py | 3 +- deploy/install/self_hosted_install.py | 466 ------------------ .../test_generation_review_regressions.py | 2 +- deploy/install/test_install.py | 2 +- deploy/install/test_self_hosted_connection.py | 136 ----- deploy/install/test_self_hosted_install.py | 397 --------------- scripts/build-core-distribution.sh | 3 +- scripts/core-distribution-manifest.py | 22 +- scripts/core-distribution-manifest.test.py | 10 +- scripts/name-allowlist.json | 10 - services/agents-api/cmd/runtime/main.go | 154 ------ services/agents-api/cmd/runtime/main_test.go | 43 -- .../internal/sandbox/docker/self_hosted.go | 185 ------- .../sandbox/docker/self_hosted_test.go | 221 --------- services/core-console/distribution_test.go | 9 +- services/core-console/node_installation.go | 18 +- .../core-console/node_installation_test.go | 5 +- services/core-console/server.go | 18 +- 19 files changed, 42 insertions(+), 1669 deletions(-) delete mode 100644 deploy/install/self_hosted_install.py delete mode 100644 deploy/install/test_self_hosted_connection.py delete mode 100644 deploy/install/test_self_hosted_install.py delete mode 100644 services/agents-api/cmd/runtime/main.go delete mode 100644 services/agents-api/cmd/runtime/main_test.go delete mode 100644 services/agents-api/internal/sandbox/docker/self_hosted.go delete mode 100644 services/agents-api/internal/sandbox/docker/self_hosted_test.go diff --git a/deploy/install/install.py b/deploy/install/install.py index b22ac7522..66d8514e5 100644 --- a/deploy/install/install.py +++ b/deploy/install/install.py @@ -75,7 +75,7 @@ def verify_bundle(bundle): required = {"manifest.json", "install.sh", "install.py", "configuration.py", "config_model.py", "config.schema.json", "oac_cli.py", "convert.py", "rename.py", "oac.pyz", "native_service.py", "sandbox_setup.py", "standard-sizes.json", "node_spec.py", "node-install.pyz", - "self-hosted-install.pyz", "distribution.py", "runtime/seccomp.json"} + "distribution.py", "runtime/seccomp.json"} required.update(f"images/{name}.tar" for name in ("core", "web", "database")) required.update("native/bin/" + name for name in ("oac-core", "oac-core-migrate")) required.add("native/e2b/oac-e2b-provider") @@ -331,7 +331,8 @@ def publish(source): revision = manifest.get("source_commit", "") if not re.fullmatch(r"[0-9a-f]{40}", revision): raise InstallError("Invalid node payload release identity") - names = ["node-install.pyz", "self-hosted-install.pyz", "manifest.json", "SHA256SUMS", "runtime/seccomp.json"] + metadata_names = ("node-install.pyz", "manifest.json", "SHA256SUMS", "runtime/seccomp.json") + names = list(metadata_names) for logical in manifest.get("artifacts", {}): entry = artifact(manifest, logical) name = "artifacts/" + entry["filename"] @@ -349,7 +350,7 @@ def publish(source): if target.exists(): # Validate the complete published metadata and every existing declared # artifact before filling any absence. Existing bytes are immutable. - for name in names[:5]: + for name in metadata_names: previous = target / name if (previous.parent.is_symlink() or previous.is_symlink() or not previous.is_file() or digest(previous) != digest(source / name)): diff --git a/deploy/install/installer_fakes.py b/deploy/install/installer_fakes.py index 8e33a501a..527466f5e 100644 --- a/deploy/install/installer_fakes.py +++ b/deploy/install/installer_fakes.py @@ -370,8 +370,7 @@ def make_bundle(directory, manifest, commit=None): for name in MODULES: (bundle / name).write_bytes(Path(__file__).with_name(name).read_bytes()) (bundle / "standard-sizes.json").write_bytes(STANDARD_SIZES.read_bytes()) - for name in ("node-install.pyz", "self-hosted-install.pyz"): - (bundle / name).write_bytes(b"synthetic verified Python bootstrap") + (bundle / "node-install.pyz").write_bytes(b"synthetic verified Python bootstrap") (bundle / "oac.pyz").write_bytes(b"synthetic oac command " + manifest["source_commit"].encode()) manifest["artifacts"] = {} for name in ("images/runtime.tar.gz", "native/bin/oac-node", diff --git a/deploy/install/self_hosted_install.py b/deploy/install/self_hosted_install.py deleted file mode 100644 index 1f9538e0c..000000000 --- a/deploy/install/self_hosted_install.py +++ /dev/null @@ -1,466 +0,0 @@ -#!/usr/bin/env python3 -"""Start one user-owned V1 Runtime from the connected Core's distribution.""" -import argparse -import fcntl -import http.client -import json -import os -from pathlib import Path -import platform -import re -import signal -import stat -import subprocess -import sys -import tempfile -import termios -import time -import urllib.error -import urllib.request -from urllib.parse import urlencode, urlsplit -import uuid - -from distribution import DistributionError, load_manifest, obtain_artifact, runtime_archive, image_identities, ensure_docker_image - - -class InstallError(Exception): - pass - - -def canonical_uuid(value): - try: - return isinstance(value, str) and str(uuid.UUID(value)) == value and uuid.UUID(value).int != 0 - except ValueError: - return False - - -def identity(environment, remote): - try: - address = urlsplit(remote) - address.port - if (not canonical_uuid(environment) or address.scheme != 'wss' or not address.hostname - or address.username is not None or address.password is not None - or address.path != '/api/v1/agent-daemon/ws' or any(c in remote for c in '?#\\') - or any(c.isspace() for c in remote)): - raise ValueError() - except (ValueError, TypeError): - raise InstallError('Use the returned Environment UUID and unchanged reachable wss remote_url') from None - return {'environment_id': environment, 'remote_url': remote} - - -def private_read(path): - path = Path(path) - if not path.is_absolute() or path.resolve() != path: - raise InstallError('Credential and state files must have absolute paths without symlinks') - try: - fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK) - with os.fdopen(fd, 'rb') as stream: - info = os.fstat(stream.fileno()) - if (not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o600 - or info.st_uid != os.getuid() or info.st_nlink != 1 or info.st_size > 16384): - raise InstallError('Credential and state files must be private mode-0600 owned regular files') - return stream.read(16385) - except OSError: - raise InstallError('Cannot read private credential or state file') from None - - -def credential(raw, environment): - try: - if len(raw) > 16384: - raise ValueError() - value = json.loads(raw) - if (not isinstance(value, dict) or set(value) != {'key_id', 'environment_id', 'executor_token'} - or not canonical_uuid(value['key_id']) or value['environment_id'] != environment - or not isinstance(value['executor_token'], str) or not value['executor_token'] - or any(c.isspace() or c == '\x00' for c in value['executor_token'])): - raise ValueError() - except (ValueError, TypeError, KeyError): - raise InstallError('Invalid restricted executor credential JSON') from None - return value - - -def prompt_credential(environment): - """Read the credential JSON from the terminal with echo off. - - Input is read until one complete JSON object parses, so both the compact - form and Web's pretty-printed file work. The secret never enters argv, the - environment, shell history or the screen; leftover typed-ahead input is - discarded so no fragment reaches the shell afterwards. - """ - try: - tty = os.open('/dev/tty', os.O_RDWR | os.O_NOCTTY) - except OSError: - raise InstallError('Use --credential-file in noninteractive sessions; credentials are never echoed') from None - try: - previous = termios.tcgetattr(tty) - hidden = termios.tcgetattr(tty) - hidden[3] &= ~(termios.ECHO | termios.ECHONL) - raw = b'' - try: - # Echo is off before the prompt appears, so nothing pasted is shown. - termios.tcsetattr(tty, termios.TCSAFLUSH, hidden) - os.write(tty, b'Paste the restricted executor credential JSON (input hidden), then press Enter: ') - while True: - chunk = os.read(tty, 4096) - if not chunk: - raise InstallError('No executor credential was entered') - raw += chunk - text = raw.decode('utf-8', 'replace').strip() - if len(raw) > 16384 or text[:1] not in ('', '{'): - raise InstallError('Invalid restricted executor credential JSON') - if not text: - continue - try: - json.loads(text) - break - except ValueError: - # The credential is one flat object: a closing brace ends it. - if text.endswith('}'): - raise InstallError('Invalid restricted executor credential JSON') from None - finally: - termios.tcsetattr(tty, termios.TCSAFLUSH, previous) - os.write(tty, b'\n') - except termios.error: - raise InstallError('Cannot read the credential from this terminal; use --credential-file') from None - finally: - os.close(tty) - return credential(raw, environment) - - -def write_private(path, value): - data = json.dumps(value).encode() - try: - fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600) - with os.fdopen(fd, 'wb') as stream: - stream.write(data) - stream.flush() - os.fsync(stream.fileno()) - except OSError: - raise InstallError('Cannot write private Runtime installation state; inspect retained files') from None - - -def checked(command, message, timeout=30): - try: - result = subprocess.run(command, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, - stderr=subprocess.PIPE, timeout=timeout, check=False) - if result.returncode: - raise InstallError(message) - return result.stdout.decode() - except (OSError, subprocess.SubprocessError, UnicodeError): - raise InstallError(message) from None - - -def preflight(): - if (sys.version_info < (3, 9) or platform.system() != 'Linux' - or platform.machine() not in ('x86_64', 'amd64') or os.getuid() == 0): - raise InstallError('Run Python 3.9+ as a non-root Linux amd64 user with Docker access') - checked(['docker', '--host', 'unix:///var/run/docker.sock', 'info', '--format', '{{.ServerVersion}}'], - 'Docker access through /var/run/docker.sock is required') - - -class NoRedirect(urllib.request.HTTPRedirectHandler): - def redirect_request(self, request, fp, code, message, headers, url): - raise InstallError('Core connection redirects are not supported; check the returned remote_url') - - -def open_connection(request, timeout): - return urllib.request.build_opener(NoRedirect()).open(request, timeout=timeout) - - -class _ConnectionDeadline(Exception): - pass - - -def connection_request(remote, environment, key): - # Only the validated daemon origin receives the restricted credential. - identity(environment, remote) - address = urlsplit(remote) - endpoint = 'https://' + address.netloc + '/api/v1/agent-daemon/connection?' - return urllib.request.Request(endpoint + urlencode({'environment_id': environment}), - headers={'Authorization': 'Bearer ' + key['executor_token']}) - - -def credential_verdict(remote, environment, key): - """One connection read: 'accepted', 401 or 409 when Core rejects the key, or None when Core cannot answer.""" - try: - with open_connection(connection_request(remote, environment, key), 10) as response: - response.read(4097) - return 'accepted' - except urllib.error.HTTPError as error: - return error.code if error.code in (401, 409) else None - except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.HTTPException): - return None - - -def wait_connected(remote, environment, key, container, timeout=60): - request = connection_request(remote, environment, key) - guidance = (' Inspect with: docker --host unix:///var/run/docker.sock logs --tail 100 ' + container - + '. Check the Runtime network, TLS and executor credential, then rerun the same installation command.' - + ' Keep the existing container, volumes and installation state; do not replace history.') - started_at = time.monotonic() - deadline = started_at + timeout - detail = 'Core has not confirmed this Environment connection' - - def deadline_expired(_signal, _frame): - raise _ConnectionDeadline() - - # Socket timeouts only limit inactivity. The Linux CLI needs a process timer - # as well so a slow response cannot keep the overall deadline alive. - previous_handler = signal.signal(signal.SIGALRM, deadline_expired) - previous_timer = signal.setitimer(signal.ITIMER_REAL, max(0.001, timeout)) - try: - while time.monotonic() < deadline: - try: - with open_connection(request, min(10, max(0.1, deadline - time.monotonic()))) as response: - raw = response.read(4097) - if len(raw) > 4096: - raise ValueError() - result = json.loads(raw) - if (not isinstance(result, dict) or set(result) != {'environment_id', 'status'} - or result['environment_id'] != environment - or result['status'] not in ('connected', 'disconnected')): - raise ValueError() - if time.monotonic() >= deadline: - raise _ConnectionDeadline() - if result['status'] == 'connected': - print('Runtime connected to Environment ' + environment + ': ' + container) - return - detail = 'Core reports this Environment disconnected' - except urllib.error.HTTPError as error: - if error.code == 404: - raise InstallError('Core connection check was not found (HTTP 404); route /api/v1 on the Core origin' - ' directly to Core, not to Web.' + guidance) from None - if error.code not in (408, 429, 500, 502, 503, 504): - raise InstallError('Core connection check rejected (HTTP ' + str(error.code) - + '); verify the exact Environment and active executor key.' + guidance) from None - detail = 'Core connection check unavailable (HTTP ' + str(error.code) + ')' - except (urllib.error.URLError, TimeoutError, ConnectionError, http.client.IncompleteRead): - detail = 'Cannot reach the Core connection endpoint; check DNS, TLS and network access' - except (ValueError, TypeError, UnicodeError): - raise InstallError('Core returned an invalid connection response.' + guidance) from None - except InstallError as error: - raise InstallError(str(error) + guidance) from None - remaining = deadline - time.monotonic() - if remaining > 0: - time.sleep(min(2, remaining)) - except _ConnectionDeadline: - pass - finally: - signal.setitimer(signal.ITIMER_REAL, 0) - signal.signal(signal.SIGALRM, previous_handler) - if previous_timer[0] > 0: - remaining_timer = max(0.001, previous_timer[0] - (time.monotonic() - started_at)) - signal.setitimer(signal.ITIMER_REAL, remaining_timer, previous_timer[1]) - raise InstallError('Runtime connection timed out: ' + detail + '.' + guidance) - - -def inspect_prior_launch(root, state, replacing=False): - docker = 'docker --host unix:///var/run/docker.sock' - filters = (' --filter label=io.oac.installation=' + state['installation_id'] - + ' --filter label=io.oac.environment=' + state['environment_id']) - guidance = (' Inspect retained resources with: ' + docker + ' ps -a' + filters - + '; ' + docker + ' volume ls' + filters - + '. Do not delete the receipt or create replacement history.') - receipt = root / 'started.json' - if not receipt.exists(): - raise InstallError('A Runtime launch was already attempted without confirmed startup.' + guidance) - prior = json.loads(private_read(receipt)) - name = prior.get('container', '') if isinstance(prior, dict) else '' - if not re.fullmatch(r'oac-selfhost-[0-9a-f]{32}', name) or prior.get('status') != 'started': - raise InstallError('Invalid retained Runtime startup receipt.' + guidance) - try: - raw = checked(['docker', '--host', 'unix:///var/run/docker.sock', 'container', 'inspect', name, - '--format', '{{json .Config.Labels}} {{.State.Status}} {{.Image}}'], 'Cannot inspect retained Runtime') - labels, status, image = raw.strip().rsplit(' ', 2) - labels = json.loads(labels) - except (InstallError, ValueError): - raise InstallError('The prior Runtime cannot be confirmed.' + guidance) from None - expected = {'io.oac.installation': state['installation_id'], - 'io.oac.environment': state['environment_id'], - 'io.oac.user-owned': 'true'} - if not isinstance(labels, dict) or any(labels.get(key) != value for key, value in expected.items()): - raise InstallError('The retained container does not match this installation and Environment.' + guidance) - if image not in (state['runtime_image'], state['runtime_manifest']): - raise InstallError('The retained container image does not match this distribution.' + guidance) - if replacing and status in ('running', 'exited', 'restarting', 'created'): - return name - if status == 'running': - print('Runtime already running: ' + name) - return name - if status == 'exited': - raise InstallError('The existing Runtime is stopped. Preserve its history and resume that same container with: ' - + docker + ' start ' + name + '. Then rerun the same installation command to confirm connection.') - raise InstallError('The retained Runtime requires inspection before continuing.' + guidance) - - -def replace_credential(args, manifest, root, state, stored, rejection, supplied): - """Give the same container a rotated credential after Core rejected the stored one. - - rejection is Core's answer for the stored credential: 401 when it was revoked - or rotated, so only that same key rotated can replace it; 409 when the - Environment is bound to a different credential. The container, its volumes and - native history stay. The stored copy changes last, so every interruption - leaves it rejected and rerunning the same command replaces it again. - """ - environment = args.environment_id - name = inspect_prior_launch(root, state, replacing=True) - if rejection == 401: - advice = ('Rotate this same credential in Web (Session > Executor credentials > Rotate); ' - 'a newly issued credential cannot replace it.') - print('Executor credential ' + stored['key_id'] + ' is no longer accepted by Core. ' + advice - + ('' if supplied else ' Then paste the rotated credential.')) - else: - advice = ('Rotate the credential first used for this Environment in Web (Session > Executor credentials > Rotate) ' - 'instead of issuing a new one.') - print('Environment ' + environment + ' is bound to a different executor credential than ' + stored['key_id'] + '. ' - + advice + ('' if supplied else ' Then paste the rotated credential.')) - replacement = supplied or prompt_credential(environment) - if rejection == 401 and replacement['key_id'] != stored['key_id']: - raise InstallError('The pasted credential is ' + replacement['key_id'] + ', not ' + stored['key_id'] + '. ' - + advice + ' Then rerun this command. Nothing was changed.') - verdict = credential_verdict(args.remote, environment, replacement) - if verdict == 409: - raise InstallError('Environment ' + environment + ' is bound to a different executor credential. Rotate the credential ' - 'first used for this Environment instead of issuing a new one, then rerun this command. Nothing was changed.') - if verdict != 'accepted': - raise InstallError('Core did not accept the replacement credential (' - + ('HTTP 401' if verdict == 401 else 'Core unavailable') - + '). Nothing was changed; rerun this command with a currently valid credential.') - launcher = obtain_artifact(manifest, 'native/bin/oac-selfhost', root / 'native/bin/oac-selfhost', args.offline_root) - docker = ['docker', '--host', 'unix:///var/run/docker.sock'] - fd, staged = tempfile.mkstemp(prefix='.executor-key-', dir=root) - try: - with os.fdopen(fd, 'wb') as stream: - stream.write(json.dumps(replacement).encode()) - stream.flush() - os.fsync(stream.fileno()) - checked(docker + ['stop', name], 'Cannot stop the Runtime to replace its credential; rerun this command', timeout=60) - checked([str(launcher), 'replace-credential', '--container', name, '--credential-file', staged], - 'Runtime credential replacement failed; rerun this command', timeout=90) - checked(docker + ['start', name], 'Cannot start the Runtime ' + name + '; rerun this command') - os.replace(staged, root / 'executor-key.json') - finally: - if os.path.exists(staged): - os.unlink(staged) - print('Executor credential replaced; restarted the same Runtime: ' + name) - wait_connected(args.remote, environment, replacement, name) - - -def install(args, root): - target = identity(args.environment_id, args.remote) - refuse_legacy_executor(args.environment_id) - manifest = load_manifest(source_url=args.source_url, offline_root=args.offline_root) - revision = manifest.get('source_commit', '') - runtime_image, runtime_manifest = image_identities(manifest, 'runtime') - if (manifest.get('platform') != 'linux/amd64' or not re.fullmatch(r'[0-9a-f]{40}', revision) - or not re.fullmatch(r'sha256:[0-9a-f]{64}', runtime_image)): - raise InstallError('The distribution does not contain a matched Linux amd64 Runtime') - target.update(source_commit=revision, runtime_image=runtime_image, runtime_manifest=runtime_manifest) - state_file = root / 'installation.json' - if state_file.exists(): - state = json.loads(private_read(state_file)) - if (not isinstance(state, dict) or set(state) != set(target) | {'installation_id'} - or any(state.get(key) != value for key, value in target.items()) - or not canonical_uuid(state['installation_id'])): - raise InstallError('This installation belongs to another Environment or distribution') - else: - state = dict(target, installation_id=str(uuid.uuid4())) - write_private(state_file, state) - if (root / 'launch.json').exists() or (root / 'launch.json').is_symlink(): - key = credential(private_read(root / 'executor-key.json'), args.environment_id) - supplied = credential(private_read(Path(args.credential_file)), args.environment_id) if args.credential_file else None - rejection = credential_verdict(args.remote, args.environment_id, key) - if rejection in (401, 409): - replace_credential(args, manifest, root, state, key, rejection, supplied) - return - name = inspect_prior_launch(root, state) - if supplied and supplied != key: - raise InstallError('Stored executor credential differs; inspect the existing installation') - wait_connected(args.remote, args.environment_id, key, name) - return - key_file = root / 'executor-key.json' - if key_file.exists(): - key = credential(private_read(key_file), args.environment_id) - if args.credential_file and credential(private_read(Path(args.credential_file)), args.environment_id) != key: - raise InstallError('Stored executor credential differs; inspect the existing installation') - else: - if args.credential_file: - key = credential(private_read(Path(args.credential_file)), args.environment_id) - else: - key = prompt_credential(args.environment_id) - write_private(key_file, key) - launcher = obtain_artifact(manifest, 'native/bin/oac-selfhost', root / 'native/bin/oac-selfhost', args.offline_root) - seccomp = obtain_artifact(manifest, 'runtime/seccomp.json', root / 'runtime/seccomp.json', args.offline_root) - runtime_image = ensure_docker_image( - manifest, 'runtime', lambda: runtime_archive(manifest, root, args.offline_root), - ('docker', '--host', 'unix:///var/run/docker.sock')) - command = [str(launcher), '--installation-id', state['installation_id'], - '--environment-id', args.environment_id, '--remote', args.remote, - '--image', runtime_image, '--seccomp-file', str(seccomp), '--credential-file', str(key_file)] - # Persist the attempt before Docker mutation. Uncertain outcomes require - # inspection, never another bootstrap or replacement native history. - write_private(root / 'launch.json', {'installation_id': state['installation_id'], 'environment_id': args.environment_id}) - result = json.loads(checked(command, 'Runtime launch failed or is uncertain. Inspect retained installation state and Docker containers', timeout=150)) - if (not isinstance(result, dict) or result.get('status') != 'started' - or not re.fullmatch(r'oac-selfhost-[0-9a-f]{32}', result.get('container', ''))): - raise InstallError('Runtime launcher returned an invalid result; inspect the retained container') - write_private(root / 'started.json', result) - print('Runtime started: ' + result['container']) - wait_connected(args.remote, args.environment_id, key, result['container']) - print('Stop this user-owned Runtime with: docker --host unix:///var/run/docker.sock stop ' + result['container']) - - -def refuse_legacy_executor(environment): - """Old executors retain their own image and history; this installer never adopts them.""" - path = Path.home() / '.parsar/self-hosted' / environment - try: - path.lstat() - except FileNotFoundError: - return - except OSError: - raise InstallError('Cannot inspect the previous executor directory ' + str(path) - + '; check its ownership before installing. Nothing was changed.') from None - raise InstallError('An executor for this Environment was installed before the OpenAgentCore rename at ' - + str(path) + ' (container parsar-selfhost-…). It keeps working with this Core. ' - 'To replace it, stop and remove that container, then rerun this command; its volumes are not reused.') - - -def main(): - parser = argparse.ArgumentParser(description=__doc__) - source = parser.add_mutually_exclusive_group(required=True) - source.add_argument('--source-url', help='Core console HTTPS origin') - source.add_argument('--offline-root', type=Path, help='matching extracted offline distribution') - parser.add_argument('--environment-id', required=True) - parser.add_argument('--remote', required=True, help='unchanged Session environment remote_url') - parser.add_argument('--credential-file', help='absolute private restricted executor credential JSON') - parser.add_argument('--install-dir', type=Path) - args = parser.parse_args() - preflight() - identity(args.environment_id, args.remote) - refuse_legacy_executor(args.environment_id) - root = args.install_dir or Path.home() / '.oac/self-hosted' / args.environment_id - if not root.is_absolute() or root.resolve() != root: - raise InstallError('Installation directory must be absolute and have no symlinks') - root.mkdir(mode=0o700, parents=True, exist_ok=True) - info = root.stat() - if not root.is_dir() or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o700: - raise InstallError('Installation directory must be owned by this user and mode 0700') - lock = os.open(root / '.lock', os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) - with os.fdopen(lock, 'rb') as stream: - info = os.fstat(stream.fileno()) - if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1: - raise InstallError('Installation lock must be private and owned') - try: - fcntl.flock(stream.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - except BlockingIOError: - raise InstallError('Another installation is already running') from None - install(args, root) - - -if __name__ == '__main__': - try: - main() - except (InstallError, DistributionError) as error: - raise SystemExit(str(error)) from None - except (OSError, ValueError, KeyError, TypeError): - raise SystemExit('Runtime installation failed; inspect the retained installation state') from None diff --git a/deploy/install/test_generation_review_regressions.py b/deploy/install/test_generation_review_regressions.py index 648258901..1f9961e44 100644 --- a/deploy/install/test_generation_review_regressions.py +++ b/deploy/install/test_generation_review_regressions.py @@ -132,7 +132,7 @@ def payload_fixture(self, directory): for name in ('node', 'helper'): raw = (name + ' verified artifact').encode() entries[name] = {'filename': name + '-' + source, 'size': len(raw), 'sha256': hashlib.sha256(raw).hexdigest()} - files = {'node-install.pyz': b'node', 'self-hosted-install.pyz': b'self', 'SHA256SUMS': b'fixture', 'runtime/seccomp.json': b'{}', 'manifest.json': json.dumps({'source_commit': source, 'artifacts': entries}).encode()} + files = {'node-install.pyz': b'node', 'SHA256SUMS': b'fixture', 'runtime/seccomp.json': b'{}', 'manifest.json': json.dumps({'source_commit': source, 'artifacts': entries}).encode()} for name, raw in files.items(): path = bundle / name; path.parent.mkdir(parents=True, exist_ok=True); path.write_bytes(raw) install.prepare_node_payload(root, {'mode': 'all'}, bundle) diff --git a/deploy/install/test_install.py b/deploy/install/test_install.py index dfc533548..542f0e10e 100644 --- a/deploy/install/test_install.py +++ b/deploy/install/test_install.py @@ -416,7 +416,7 @@ def test_node_payload_exports_only_matched_distribution_files(self): self.install() payload = self.root / "node-payload/releases" / ("a" * 40) exported = {str(path.relative_to(payload)) for path in payload.rglob("*") if path.is_file()} - self.assertEqual(exported, {"node-install.pyz", "self-hosted-install.pyz", "manifest.json", "SHA256SUMS", + self.assertEqual(exported, {"node-install.pyz", "manifest.json", "SHA256SUMS", "runtime/seccomp.json"}) (payload / "node-install.pyz").write_text("changed") with self.assertRaisesRegex(install.InstallError, "node payload differs"): diff --git a/deploy/install/test_self_hosted_connection.py b/deploy/install/test_self_hosted_connection.py deleted file mode 100644 index 7de3743e8..000000000 --- a/deploy/install/test_self_hosted_connection.py +++ /dev/null @@ -1,136 +0,0 @@ -"""Core confirmation is independent of Docker launch and never recreates history.""" -import contextlib -import io -import http.server -import json -import signal -import threading -import time -import unittest -from unittest.mock import patch -import urllib.error -import urllib.request - -import self_hosted_install as installer - - -class ConnectionTests(unittest.TestCase): - environment = '55b5311c-4df9-43ce-b875-faf901e6d10f' - remote = 'wss://core.example:8443/api/v1/agent-daemon/ws' - key = {'executor_token': 'synthetic-private-token'} - container = 'oac-selfhost-' + 'c' * 32 - - def response(self, status='connected', environment=None): - return io.BytesIO(json.dumps({'status': status, 'environment_id': environment or self.environment}).encode()) - - def wait(self): - installer.wait_connected(self.remote, self.environment, self.key, self.container) - - def test_waits_for_core_and_recovers_transient_failure(self): - failure = urllib.error.HTTPError('url', 503, 'private body', {}, None) - with patch.object(installer, 'open_connection', side_effect=[failure, self.response('disconnected'), self.response()]) as request, \ - patch.object(installer.time, 'sleep') as sleep, contextlib.redirect_stdout(io.StringIO()) as output: - self.wait() - self.assertEqual(request.call_count, 3) - self.assertEqual(sleep.call_count, 2) - sent = request.call_args.args[0] - self.assertEqual(sent.full_url, 'https://core.example:8443/api/v1/agent-daemon/connection?environment_id=' + self.environment) - self.assertEqual(sent.get_header('Authorization'), 'Bearer ' + self.key['executor_token']) - self.assertIn('Runtime connected to Environment', output.getvalue()) - self.assertNotIn(self.key['executor_token'], output.getvalue()) - - def test_permanent_denial_does_not_retry_or_echo_server_body(self): - for status in (401, 403, 404, 409): - with patch.object(installer, 'open_connection', side_effect=urllib.error.HTTPError('url', status, 'private body', {}, None)) as request: - with self.assertRaisesRegex(installer.InstallError, 'HTTP ' + str(status)) as failure: - self.wait() - self.assertEqual(request.call_count, 1) - self.assertIn('logs --tail 100 ' + self.container, str(failure.exception)) - self.assertNotIn('private body', str(failure.exception)) - self.assertEqual('route /api/v1' in str(failure.exception), status == 404) - - def test_deadline_retains_container_and_has_retry_guidance(self): - with patch.object(installer, 'open_connection', return_value=self.response('disconnected')), \ - patch.object(installer.time, 'monotonic', side_effect=[0, 0, 0, 0, 60, 60]), \ - patch.object(installer, 'checked') as mutation: - with self.assertRaisesRegex(installer.InstallError, 'timed out.*disconnected') as failure: - self.wait() - mutation.assert_not_called() - self.assertIn('rerun the same installation command', str(failure.exception)) - self.assertIn('do not replace history', str(failure.exception)) - - def test_wrong_or_malformed_response_cannot_confirm_connection(self): - for response in (self.response(environment='other'), self.response(status='running'), io.BytesIO(b'{}'), io.BytesIO(b'x' * 4097)): - with patch.object(installer, 'open_connection', return_value=response): - with self.assertRaisesRegex(installer.InstallError, 'invalid connection response'): - self.wait() - - def test_slow_response_cannot_outlive_deadline_or_report_success(self): - body = self.response().getvalue() - class SlowResponse(http.server.BaseHTTPRequestHandler): - def do_GET(self): - self.send_response(200) - self.send_header('Content-Length', str(len(body))) - self.end_headers() - try: - for offset in range(0, len(body), 10): - self.wfile.write(body[offset:offset + 10]) - self.wfile.flush() - time.sleep(0.07) - except (BrokenPipeError, ConnectionResetError): - pass - - def log_message(self, *_args): - pass - - server = http.server.HTTPServer(('127.0.0.1', 0), SlowResponse) - worker = threading.Thread(target=server.serve_forever, daemon=True) - worker.start() - previous_handler = signal.getsignal(signal.SIGALRM) - try: - def open_local(request, timeout): - local = urllib.request.Request('http://127.0.0.1:' + str(server.server_port), - headers=dict(request.header_items())) - return urllib.request.urlopen(local, timeout=timeout) - with patch.object(installer, 'open_connection', side_effect=open_local), \ - contextlib.redirect_stdout(io.StringIO()) as output: - started = time.monotonic() - with self.assertRaisesRegex(installer.InstallError, 'connection timed out'): - installer.wait_connected(self.remote, self.environment, self.key, self.container, timeout=0.2) - elapsed = time.monotonic() - started - self.assertLess(elapsed, 0.5) - self.assertNotIn('Runtime connected', output.getvalue()) - self.assertEqual(signal.getitimer(signal.ITIMER_REAL), (0, 0)) - self.assertEqual(signal.getsignal(signal.SIGALRM), previous_handler) - finally: - server.shutdown() - server.server_close() - worker.join(timeout=2) - - def test_timer_and_handler_are_restored_on_success_and_rejection(self): - previous_handler = signal.getsignal(signal.SIGALRM) - try: - signal.setitimer(signal.ITIMER_REAL, 30) - for response in (self.response(), io.BytesIO(b'{}')): - with patch.object(installer, 'open_connection', return_value=response), \ - contextlib.redirect_stdout(io.StringIO()): - try: - self.wait() - except installer.InstallError: - pass - remaining, interval = signal.getitimer(signal.ITIMER_REAL) - self.assertGreater(remaining, 20) - self.assertLessEqual(remaining, 30) - self.assertEqual(interval, 0) - self.assertEqual(signal.getsignal(signal.SIGALRM), previous_handler) - finally: - signal.setitimer(signal.ITIMER_REAL, 0) - - def test_redirect_never_forwards_bearer(self): - for target in ('https://other.example/connection', 'https://core.example/new'): - with self.assertRaisesRegex(installer.InstallError, 'redirects'): - installer.NoRedirect().redirect_request(None, None, 302, '', {}, target) - - -if __name__ == '__main__': - unittest.main() diff --git a/deploy/install/test_self_hosted_install.py b/deploy/install/test_self_hosted_install.py deleted file mode 100644 index fbe4bf9fa..000000000 --- a/deploy/install/test_self_hosted_install.py +++ /dev/null @@ -1,397 +0,0 @@ -import argparse -import contextlib -import io -import json -import os -from pathlib import Path -import pty -import tempfile -import termios -from types import SimpleNamespace - -import distribution -import unittest -from unittest.mock import patch -import uuid - -import self_hosted_install as installer - - -class SelfHostedInstallTests(unittest.TestCase): - def setUp(self): - base = Path.home() / '.oac/tests/selfhost-install' - base.mkdir(parents=True, exist_ok=True) - self.temporary = tempfile.TemporaryDirectory(dir=base) - self.addCleanup(self.temporary.cleanup) - self.root = Path(self.temporary.name).resolve() - self.environment = str(uuid.uuid4()) - self.remote = 'wss://core.example/api/v1/agent-daemon/ws' - self.key = {'key_id': str(uuid.uuid4()), 'environment_id': self.environment, - 'executor_token': 'synthetic-private-token'} - self.source_key = self.root / 'source-key.json' - installer.write_private(self.source_key, self.key) - self.args = argparse.Namespace(source_url='https://core.example', offline_root=None, - environment_id=self.environment, remote=self.remote, credential_file=str(self.source_key)) - self.wait = patch.object(installer, 'wait_connected').start() - self.verdict = patch.object(installer, 'credential_verdict', return_value='accepted').start() - self.addCleanup(patch.stopall) - self.manifest = {'source_commit': 'a' * 40, 'platform': 'linux/amd64', - 'images': {'runtime': 'sha256:' + 'b' * 64}, - 'image_manifest_digests': {'runtime': 'sha256:' + 'd' * 64}} - patch.object(distribution, 'docker_command', side_effect=self.docker_command).start() - - def docker_command(self, arguments, timeout=30): - try: - return SimpleNamespace(returncode=0, stdout=installer.checked(arguments, 'image missing', timeout=timeout)) - except installer.InstallError: - return SimpleNamespace(returncode=1, stdout='') - - def test_exact_restricted_credential_and_tls_identity(self): - self.assertEqual(installer.credential(json.dumps(self.key), self.environment), self.key) - for change in ({'environment_id': ''}, {'executor_token': ''}, {'key_id': 'bad'}, {'extra': True}): - with self.assertRaises(installer.InstallError): - installer.credential(json.dumps(dict(self.key, **change)), self.environment) - for remote in ('ws://localhost/api/v1/agent-daemon/ws', self.remote+'?token=secret', - 'wss://user:secret@core.example/api/v1/agent-daemon/ws', self.remote+'#fragment'): - with self.assertRaises(installer.InstallError): - installer.identity(self.environment, remote) - - def test_private_file_rejects_symlink_permissions_and_hardlinks(self): - self.assertEqual(json.loads(installer.private_read(self.source_key)), self.key) - self.source_key.chmod(0o644) - with self.assertRaises(installer.InstallError): installer.private_read(self.source_key) - self.source_key.chmod(0o600) - alias = self.root / 'alias' - alias.symlink_to(self.source_key) - with self.assertRaises(installer.InstallError): installer.private_read(alias) - alias.unlink() - os.link(self.source_key, alias) - with self.assertRaises(installer.InstallError): installer.private_read(self.source_key) - - def test_one_command_keeps_secrets_out_of_arguments_and_retains_attempt(self): - commands = [] - def checked(command, message, timeout=30): - commands.append(command) - self.assertNotIn(self.key['executor_token'], json.dumps(command)) - if 'inspect' in command: return self.manifest['images']['runtime'] + ' linux/amd64' - if command[0].endswith('oac-selfhost'): - self.assertTrue((self.root / 'launch.json').exists()) - return json.dumps({'container': 'oac-selfhost-'+'c'*32, 'status': 'started'}) - return '' - with patch.object(installer, 'load_manifest', return_value=self.manifest), \ - patch.object(installer, 'obtain_artifact', side_effect=lambda m, n, dest, o: dest), \ - patch.object(installer, 'runtime_archive', return_value=self.root/'runtime.tar'), \ - patch.object(installer, 'checked', side_effect=checked), contextlib.redirect_stdout(io.StringIO()): - installer.install(self.args, self.root) - self.assertEqual(len(commands), 2) - self.assertEqual(json.loads(installer.private_read(self.root/'executor-key.json')), self.key) - with self.assertRaises(installer.InstallError): installer.install(self.args, self.root) - self.assertEqual(len(commands), 3) - - def test_connection_failure_retry_preserves_same_container_and_credential(self): - commands = [] - name = 'oac-selfhost-'+'c'*32 - def checked(command, message, timeout=30): - commands.append(command) - if 'container' in command: - state = json.loads(installer.private_read(self.root/'installation.json')) - labels = {'io.oac.installation': state['installation_id'], - 'io.oac.environment': self.environment, - 'io.oac.user-owned': 'true'} - return json.dumps(labels)+' running '+state['runtime_image'] - if 'inspect' in command: return self.manifest['images']['runtime'] + ' linux/amd64' - return json.dumps({'container': name, 'status': 'started'}) - self.wait.side_effect = [installer.InstallError('connection timed out'), None] - with patch.object(installer, 'load_manifest', return_value=self.manifest), \ - patch.object(installer, 'obtain_artifact', side_effect=lambda m, n, dest, o: dest), \ - patch.object(installer, 'checked', side_effect=checked), contextlib.redirect_stdout(io.StringIO()): - with self.assertRaisesRegex(installer.InstallError, 'timed out'): - installer.install(self.args, self.root) - original = {name: (self.root/name).read_bytes() for name in ('installation.json', 'launch.json', 'started.json', 'executor-key.json')} - installer.install(self.args, self.root) - self.assertEqual(len([c for c in commands if c[0].endswith('oac-selfhost')]), 1) - self.assertEqual(self.wait.call_count, 2) - self.wait.assert_called_with(self.remote, self.environment, self.key, name) - self.assertEqual(original, {name: (self.root/name).read_bytes() for name in original}) - - def test_hidden_prompt_reads_pretty_or_compact_credential_without_echo(self): - for text in (json.dumps(self.key, indent=2) + '\n', json.dumps(self.key) + '\n'): - pid, terminal = pty.fork() - if pid == 0: - try: - accepted = installer.prompt_credential(self.environment) == self.key - os._exit(0 if accepted and termios.tcgetattr(0)[3] & termios.ECHO else 3) - except BaseException: - os._exit(4) - output = b'' - while b'input hidden' not in output: - output += os.read(terminal, 1024) - os.write(terminal, text.encode()) - while True: - try: - chunk = os.read(terminal, 1024) - except OSError: - break - if not chunk: - break - output += chunk - os.close(terminal) - self.assertEqual(os.waitstatus_to_exitcode(os.waitpid(pid, 0)[1]), 0) - self.assertNotIn(self.key['executor_token'].encode(), output) - - def launched_runtime(self): - """Launch with self.key, then rerun without --credential-file; docker commands in `commands`.""" - name = 'oac-selfhost-' + 'c' * 32 - commands, interrupt = [], [] - def checked(command, message, timeout=30): - commands.append(command) - self.assertNotIn('private-token', json.dumps(command)) - if interrupt and interrupt[0] in command: - interrupt.pop(0) - raise installer.InstallError(message) - if 'container' in command: - state = json.loads(installer.private_read(self.root/'installation.json')) - labels = {'io.oac.installation': state['installation_id'], - 'io.oac.environment': self.environment, - 'io.oac.user-owned': 'true'} - return json.dumps(labels)+' running '+state['runtime_image'] - if 'inspect' in command: return self.manifest['images']['runtime'] + ' linux/amd64' - if command[1:2] == ['replace-credential']: - self.assertEqual(command[2:4], ['--container', name]) - return json.dumps({'container': name, 'status': 'started'}) - patch.object(installer, 'load_manifest', return_value=self.manifest).start() - patch.object(installer, 'obtain_artifact', side_effect=lambda m, n, dest, o: dest).start() - patch.object(installer, 'runtime_archive', return_value=self.root/'runtime.tar').start() - patch.object(installer, 'checked', side_effect=checked).start() - with contextlib.redirect_stdout(io.StringIO()): - installer.install(self.args, self.root) - self.args.credential_file = None - return name, commands, interrupt - - def stored(self): - return json.loads(installer.private_read(self.root/'executor-key.json')) - - def rerun(self, pasted): - with patch.object(installer, 'prompt_credential', return_value=pasted), contextlib.redirect_stdout(io.StringIO()) as output: - installer.install(self.args, self.root) - return output.getvalue() - - def test_revoked_credential_is_replaced_only_by_the_same_key_in_the_same_container(self): - name, commands, _ = self.launched_runtime() - rotated = dict(self.key, executor_token='rotated-private-token') - issued = dict(self.key, key_id=str(uuid.uuid4()), executor_token='issued-private-token') - # Core's connection check accepts a new key while the revoked bound key has no authority. - verdicts = {self.key['executor_token']: 401, rotated['executor_token']: 'accepted', issued['executor_token']: 'accepted'} - self.verdict.side_effect = lambda remote, environment, key: verdicts[key['executor_token']] - retained = {n: (self.root/n).read_bytes() for n in ('installation.json', 'launch.json', 'started.json')} - launched = len(commands) - with self.assertRaisesRegex(installer.InstallError, 'not ' + self.key['key_id'] + '.*same credential'): - self.rerun(issued) - self.assertFalse([c for c in commands[launched:] if 'stop' in c or c[0].endswith('oac-selfhost')]) - self.assertEqual(self.stored(), self.key) - output = self.rerun(rotated) - replaced = [c for c in commands[launched:] if 'inspect' not in c] - self.assertEqual([(c[-2], c[-1]) if c[0] == 'docker' else c[1] for c in replaced], - [('stop', name), 'replace-credential', ('start', name)]) - self.assertEqual(len([c for c in commands if c[0].endswith('oac-selfhost') and c[1] != 'replace-credential']), 1) - self.assertEqual(self.stored(), rotated) - self.assertEqual(retained, {n: (self.root/n).read_bytes() for n in retained}) - self.assertFalse(list(self.root.glob('.executor-key-*'))) - self.wait.assert_called_with(self.remote, self.environment, rotated, name) - self.assertIn('no longer accepted', output) - self.assertNotIn('private-token', output) - - def test_binding_conflict_asks_for_the_credential_first_used(self): - self.launched_runtime() - self.verdict.side_effect = lambda remote, environment, key: 409 - other = dict(self.key, key_id=str(uuid.uuid4()), executor_token='other-private-token') - with patch.object(installer, 'prompt_credential', return_value=other), contextlib.redirect_stdout(io.StringIO()) as output, \ - self.assertRaisesRegex(installer.InstallError, 'first used for this Environment') as failure: - installer.install(self.args, self.root) - self.assertIn('first used for this Environment', output.getvalue()) - self.assertNotIn('same credential', output.getvalue() + str(failure.exception)) - self.assertEqual(self.stored(), self.key) - - def test_replacement_interrupted_before_start_or_store_is_finished_by_rerunning(self): - rotated = dict(self.key, executor_token='rotated-private-token') - name, commands, interrupt = self.launched_runtime() - verdicts = {self.key['executor_token']: 401, rotated['executor_token']: 'accepted'} - self.verdict.side_effect = lambda remote, environment, key: verdicts[key['executor_token']] - replace = os.replace - def store_interrupted(source, target): - if str(target).endswith('executor-key.json'): - raise OSError('interrupted') - return replace(source, target) - for point in ('start', 'store'): - interrupt[:] = ['start'] if point == 'start' else [] - with self.subTest(point=point), self.assertRaises((installer.InstallError, OSError)), \ - patch.object(installer.os, 'replace', side_effect=store_interrupted if point == 'store' else replace): - self.rerun(rotated) - self.assertEqual(commands[-1][-2:], ['start', name]) - self.assertEqual(self.stored(), self.key) - self.assertFalse(list(self.root.glob('.executor-key-*'))) - launched = len(commands) - self.rerun(rotated) - self.assertEqual([c[3] if c[0] == 'docker' else c[1] for c in commands[launched:] if 'inspect' not in c], - ['stop', 'replace-credential', 'start']) - self.assertEqual(self.stored(), rotated) - self.wait.assert_called_with(self.remote, self.environment, rotated, name) - - def test_cached_runtime_avoids_archive_download_and_load(self): - def checked(command, message, timeout=30): - self.assertNotIn('load', command) - if 'inspect' in command: return self.manifest['images']['runtime'] + ' linux/amd64' - return json.dumps({'container': 'oac-selfhost-'+'c'*32, 'status': 'started'}) - with patch.object(installer, 'load_manifest', return_value=self.manifest), \ - patch.object(installer, 'obtain_artifact', side_effect=lambda m, n, dest, o: dest), \ - patch.object(installer, 'runtime_archive') as archive, \ - patch.object(installer, 'checked', side_effect=checked), contextlib.redirect_stdout(io.StringIO()): - installer.install(self.args, self.root) - archive.assert_not_called() - - def test_missing_runtime_loads_and_verifies_before_launch(self): - commands = [] - def checked(command, message, timeout=30): - commands.append(command) - if 'inspect' in command and not any('load' in item for item in commands): - raise installer.InstallError('image missing') - if 'inspect' in command: return self.manifest['images']['runtime'] + ' linux/amd64' - if command[0].endswith('oac-selfhost'): - return json.dumps({'container': 'oac-selfhost-'+'c'*32, 'status': 'started'}) - return '' - with patch.object(installer, 'load_manifest', return_value=self.manifest), \ - patch.object(installer, 'obtain_artifact', side_effect=lambda m, n, dest, o: dest), \ - patch.object(installer, 'runtime_archive', return_value=self.root/'runtime.tar') as archive, \ - patch.object(installer, 'checked', side_effect=checked), contextlib.redirect_stdout(io.StringIO()): - installer.install(self.args, self.root) - archive.assert_called_once() - self.assertIn('load', commands[2]) - self.assertIn('inspect', commands[3]) - self.assertTrue(commands[4][0].endswith('oac-selfhost')) - - def test_containerd_cache_passes_actual_id_to_launcher_without_changing_manifest(self): - expected = self.manifest['image_manifest_digests']['runtime'] - published = json.dumps(self.manifest, sort_keys=True) - def checked(command, message, timeout=30): - if 'inspect' in command: - if command[command.index('inspect') + 1] == self.manifest['images']['runtime']: - raise installer.InstallError('image missing') - return expected + ' linux/amd64' - self.assertEqual(command[command.index('--image') + 1], expected) - return json.dumps({'container': 'oac-selfhost-'+'c'*32, 'status': 'started'}) - with patch.object(installer, 'load_manifest', return_value=self.manifest), \ - patch.object(installer, 'obtain_artifact', side_effect=lambda m, n, dest, o: dest), \ - patch.object(installer, 'runtime_archive', side_effect=AssertionError('warm Runtime download')), \ - patch.object(installer, 'checked', side_effect=checked), contextlib.redirect_stdout(io.StringIO()): - installer.install(self.args, self.root) - self.assertEqual(json.dumps(self.manifest, sort_keys=True), published) - state = json.loads(installer.private_read(self.root/'installation.json')) - self.assertEqual(state['runtime_image'], self.manifest['images']['runtime']) - self.assertEqual(state['runtime_manifest'], expected) - - def test_wrong_loaded_id_or_platform_prevents_launch_receipt(self): - for observed in ('sha256:' + 'f' * 64 + ' linux/amd64', self.manifest['images']['runtime'] + ' linux/arm64'): - loaded = False - def checked(command, message, timeout=30): - nonlocal loaded - if 'load' in command: - loaded = True - return '' - if 'inspect' in command: - if not loaded: - raise installer.InstallError('image missing') - return observed - self.fail('Runtime launcher must not run') - with self.subTest(observed=observed), patch.object(installer, 'load_manifest', return_value=self.manifest), \ - patch.object(installer, 'obtain_artifact', side_effect=lambda m, n, dest, o: dest), \ - patch.object(installer, 'runtime_archive', return_value=self.root/'runtime.tar'), \ - patch.object(installer, 'checked', side_effect=checked), \ - self.assertRaisesRegex(distribution.DistributionError, 'identity or platform'): - installer.install(self.args, self.root) - self.assertFalse((self.root/'launch.json').exists()) - - def test_existing_launch_verifies_labels_and_reports_state(self): - name = 'oac-selfhost-'+'c'*32 - state = {'installation_id': str(uuid.uuid4()), 'environment_id': self.environment, - 'runtime_image': self.manifest['images']['runtime'], - 'runtime_manifest': self.manifest['image_manifest_digests']['runtime']} - labels = {'io.oac.installation': state['installation_id'], - 'io.oac.environment': self.environment, - 'io.oac.user-owned': 'true'} - installer.write_private(self.root/'started.json', {'container': name, 'status': 'started'}) - with patch.object(installer, 'checked', return_value=json.dumps(labels)+' running '+state['runtime_image']), \ - contextlib.redirect_stdout(io.StringIO()) as output: - installer.inspect_prior_launch(self.root, state) - self.assertIn('already running', output.getvalue()) - self.assertNotIn('connected to Environment', output.getvalue()) - with patch.object(installer, 'checked', return_value=json.dumps(labels)+' running '+state['runtime_manifest']), \ - contextlib.redirect_stdout(io.StringIO()): - self.assertEqual(installer.inspect_prior_launch(self.root, state), name) - with patch.object(installer, 'checked', return_value=json.dumps(labels)+' exited '+state['runtime_image']): - with self.assertRaisesRegex(installer.InstallError, 'start '+name): - installer.inspect_prior_launch(self.root, state) - for status in ('exited', 'restarting', 'created'): - with patch.object(installer, 'checked', return_value=json.dumps(labels)+' '+status+' '+state['runtime_image']): - self.assertEqual(installer.inspect_prior_launch(self.root, state, replacing=True), name) - with patch.object(installer, 'checked', return_value=json.dumps(labels)+' running sha256:'+'f'*64): - with self.assertRaisesRegex(installer.InstallError, 'container image does not match'): - installer.inspect_prior_launch(self.root, state) - labels['io.oac.environment'] = str(uuid.uuid4()) - with patch.object(installer, 'checked', return_value=json.dumps(labels)+' running '+state['runtime_image']): - with self.assertRaisesRegex(installer.InstallError, 'does not match'): - installer.inspect_prior_launch(self.root, state) - - def test_uncertain_launch_has_specific_safe_inspection_commands(self): - state = {'installation_id': str(uuid.uuid4()), 'environment_id': self.environment, - 'runtime_image': self.manifest['images']['runtime'], - 'runtime_manifest': self.manifest['image_manifest_digests']['runtime']} - with self.assertRaises(installer.InstallError) as failure: - installer.inspect_prior_launch(self.root, state) - message = str(failure.exception) - self.assertIn('ps -a --filter label=io.oac.installation='+state['installation_id'], message) - self.assertIn('volume ls --filter', message) - self.assertIn('Do not delete the receipt', message) - - def test_failed_launch_is_not_replayed(self): - def checked(command, message, timeout=30): - if 'inspect' in command: return self.manifest['images']['runtime'] + ' linux/amd64' - if command[0].endswith('oac-selfhost'): raise installer.InstallError('uncertain launch') - return '' - with patch.object(installer, 'load_manifest', return_value=self.manifest), \ - patch.object(installer, 'obtain_artifact', side_effect=lambda m, n, dest, o: dest), \ - patch.object(installer, 'runtime_archive', return_value=self.root/'runtime.tar'), \ - patch.object(installer, 'checked', side_effect=checked): - with self.assertRaises(installer.InstallError): installer.install(self.args, self.root) - self.assertTrue((self.root/'launch.json').exists()) - with self.assertRaisesRegex(installer.InstallError, 'already attempted'): installer.install(self.args, self.root) - - - - -class LegacySelfHostedTests(unittest.TestCase): - def test_same_environment_refuses_even_with_custom_install_dir(self): - base = Path.home() / '.oac/tests/selfhost-legacy' - base.mkdir(parents=True, exist_ok=True) - with tempfile.TemporaryDirectory(dir=base) as temporary: - home = Path(temporary) - environment = str(uuid.uuid4()) - legacy = home / '.parsar/self-hosted' / environment - legacy.mkdir(parents=True) - retained = legacy / 'executor-key.json' - retained.write_text('private-retained-key') - destination = home / 'custom-new-root' - argv = ['self-hosted-install.pyz', '--source-url', 'https://core.example', '--environment-id', environment, - '--remote', 'wss://core.example/api/v1/agent-daemon/ws', '--install-dir', str(destination)] - with patch.object(installer.Path, 'home', return_value=home), patch.object(installer, 'preflight'), \ - patch.object(installer.sys, 'argv', argv), patch.object(installer, 'install') as install: - with self.assertRaisesRegex(installer.InstallError, 'before the OpenAgentCore rename.*volumes are not reused') as error: - installer.main() - self.assertNotIn('private-retained-key', str(error.exception)) - install.assert_not_called() - self.assertFalse(destination.exists()) - self.assertEqual(retained.read_text(), 'private-retained-key') - installer.refuse_legacy_executor(str(uuid.uuid4())) - - -if __name__ == '__main__': - unittest.main() diff --git a/scripts/build-core-distribution.sh b/scripts/build-core-distribution.sh index 4308ae997..97f83ae1f 100755 --- a/scripts/build-core-distribution.sh +++ b/scripts/build-core-distribution.sh @@ -99,7 +99,7 @@ if [[ "$(go env GOVERSION)" != "$required_go" ]]; then exit 1 fi for file in install.sh install.py configuration.py config_model.py config.schema.json oac_cli.py convert.py rename.py \ - native_service.py node_install.py node_spec.py node_generations.py node_update.py sandbox_setup.py distribution.py self_hosted_install.py \ + native_service.py node_install.py node_spec.py node_generations.py node_update.py sandbox_setup.py distribution.py \ model_provider_sessions.py; do cp "deploy/install/$file" "$bundle/$file" done @@ -158,7 +158,6 @@ export AGENTS_EXECUTOR_BUILD_DIR="$stage/helpers" scripts/build-agents-executor.sh CGO_ENABLED=0 go build -mod=readonly -trimpath -o "$stage/oac-daemon" ./apps/parsar-daemon/cmd/parsar-daemon cp "$stage/oac-daemon" "$bundle/native/bin/oac-daemon" -CGO_ENABLED=0 go build -mod=readonly -trimpath -o "$bundle/native/bin/oac-selfhost" ./services/agents-api/cmd/runtime codex_image="${CORE_DISTRIBUTION_CODEX_IMAGE:-}" claude_image="${CORE_DISTRIBUTION_CLAUDE_IMAGE:-}" mcode_image="${CORE_DISTRIBUTION_MCODE_IMAGE:-}" diff --git a/scripts/core-distribution-manifest.py b/scripts/core-distribution-manifest.py index cd8aa7c29..b7deba672 100644 --- a/scripts/core-distribution-manifest.py +++ b/scripts/core-distribution-manifest.py @@ -23,7 +23,6 @@ "images/runtime.tar.gz": "runtime.tar.gz", "native/bin/oac-node": "sandbox-node", "native/bin/oac-daemon": "daemon", - "native/bin/oac-selfhost": "runtime-launcher", "native/bin/oac-microsandbox-provider": "microsandbox-provider", "native/microsandbox/msb": "msb", "native/microsandbox/libkrunfw.so.5.6.1": "libkrunfw.so.5.6.1", @@ -240,19 +239,14 @@ def bootstraps(bundle, epoch, revision): if not re.fullmatch(r"[0-9a-f]{40}", revision): raise ValueError("Invalid operator source revision") bundle = pathlib.Path(bundle) - for source, output in (("node_install.py", "node-install.pyz"), - ("self_hosted_install.py", "self-hosted-install.pyz")): - with tempfile.TemporaryDirectory(dir=bundle.parent) as directory: - for original, packaged in ((source, "__main__.py"), ("distribution.py", "distribution.py")): - target = pathlib.Path(directory) / packaged - shutil.copyfile(bundle / original, target) - os.utime(target, (int(epoch), int(epoch))) - if source == "node_install.py": - for name in ("node_spec.py", "node_generations.py", "node_update.py"): - target = pathlib.Path(directory) / name - shutil.copyfile(bundle / name, target) - os.utime(target, (int(epoch), int(epoch))) - zipapp.create_archive(directory, bundle / output, compressed=True) + with tempfile.TemporaryDirectory(dir=bundle.parent) as directory: + modules = (("node_install.py", "__main__.py"), ("distribution.py", "distribution.py"), + *((name, name) for name in ("node_spec.py", "node_generations.py", "node_update.py"))) + for original, packaged in modules: + target = pathlib.Path(directory) / packaged + shutil.copyfile(bundle / original, target) + os.utime(target, (int(epoch), int(epoch))) + zipapp.create_archive(directory, bundle / "node-install.pyz", compressed=True) with tempfile.TemporaryDirectory(dir=bundle.parent) as directory: for name in OAC_CLI_MODULES: shutil.copyfile(bundle / name, pathlib.Path(directory) / name) diff --git a/scripts/core-distribution-manifest.test.py b/scripts/core-distribution-manifest.test.py index 4051f64d6..df955049c 100644 --- a/scripts/core-distribution-manifest.test.py +++ b/scripts/core-distribution-manifest.test.py @@ -232,7 +232,7 @@ def test_release_location_and_explicit_offline_requirements(self): self.assertEqual(json.loads((self.bundle / "manifest.json").read_text())["artifact_base_url"], "") def test_bootstraps_include_shared_downloader_and_are_reproducible(self): - for name in ("node_install.py", "node_generations.py", "node_update.py", "self_hosted_install.py", *distribution.OAC_CLI_MODULES): + for name in ("node_install.py", "node_generations.py", "node_update.py", *distribution.OAC_CLI_MODULES): (self.bundle / name).write_text("# " + name + "\n") distribution.bootstraps(self.bundle, "1700000000", "a" * 40) first = [(self.bundle / name).read_bytes() for name in ("node-install.pyz", "oac.pyz")] @@ -242,11 +242,9 @@ def test_bootstraps_include_shared_downloader_and_are_reproducible(self): with zipfile.ZipFile(self.bundle / "oac.pyz") as contents: self.assertEqual(set(contents.namelist()), {"__main__.py", *distribution.OAC_CLI_MODULES}) self.assertIn(("oac_cli.SOURCE_COMMIT = " + repr("a" * 40)).encode(), contents.read("__main__.py")) - for script, filename in (("node_install.py", "node-install.pyz"), - ("self_hosted_install.py", "self-hosted-install.pyz")): - with zipfile.ZipFile(self.bundle / filename) as contents: - self.assertEqual(set(contents.namelist()), {"__main__.py", "distribution.py"} | ({"node_spec.py", "node_generations.py", "node_update.py"} if script == "node_install.py" else set())) - self.assertEqual(contents.read("__main__.py"), (self.bundle / script).read_bytes()) + with zipfile.ZipFile(self.bundle / "node-install.pyz") as contents: + self.assertEqual(set(contents.namelist()), {"__main__.py", "distribution.py", "node_spec.py", "node_generations.py", "node_update.py"}) + self.assertEqual(contents.read("__main__.py"), (self.bundle / "node_install.py").read_bytes()) class BundledDocsTests(unittest.TestCase): diff --git a/scripts/name-allowlist.json b/scripts/name-allowlist.json index 33861441c..6ca4e2a28 100644 --- a/scripts/name-allowlist.json +++ b/scripts/name-allowlist.json @@ -409,16 +409,6 @@ "regex": "parsar-node(?:-)?|\\.parsar(?:/nodes)?|PARSAR_NODE_ENROLLMENT_TOKEN", "reason": "Only the old node service/state paths and retired enrollment variable are recognized for scoped refusal and uninstall guidance." }, - { - "path": "deploy/install/self_hosted_install.py", - "regex": "\\.parsar(?:/self-hosted)?|parsar-selfhost-", - "reason": "The self-hosted installer refuses the old same-Environment installation and retains its existing resources." - }, - { - "path": "deploy/install/test_self_hosted_install.py", - "regex": "\\.parsar(?:/self-hosted)?|parsar-selfhost-", - "reason": "The self-hosted installer refuses the old same-Environment installation and retains its existing resources." - }, { "path": "deploy/install/install.py", "regex": "\\.parsar/core|\\bparsar\\b", diff --git a/services/agents-api/cmd/runtime/main.go b/services/agents-api/cmd/runtime/main.go deleted file mode 100644 index c8ad99068..000000000 --- a/services/agents-api/cmd/runtime/main.go +++ /dev/null @@ -1,154 +0,0 @@ -// Command runtime launches one user-owned packaged V1 Runtime, without a Core database. -package main - -import ( - "bytes" - "context" - "encoding/json" - "errors" - "flag" - "fmt" - "io" - "os" - "path/filepath" - "runtime" - "syscall" - "time" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox/docker" - "github.com/moby/moby/client" -) - -func main() { - if err := run(os.Args[1:]); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func run(args []string) error { - if len(args) > 0 && args[0] == "replace-credential" { - return replaceCredential(args[1:]) - } - var input docker.SelfHostedLaunch - var credential, seccomp string - flags := flag.NewFlagSet("oac-selfhost", flag.ContinueOnError) - flags.SetOutput(io.Discard) - flags.StringVar(&input.InstallationID, "installation-id", "", "persisted local installation UUID") - flags.StringVar(&input.EnvironmentID, "environment-id", "", "Session Environment UUID") - flags.StringVar(&input.RemoteURL, "remote", "", "unchanged Session remote_url") - flags.StringVar(&input.Image, "image", "", "matched Runtime image digest") - flags.StringVar(&credential, "credential-file", "", "private restricted executor credential JSON") - flags.StringVar(&seccomp, "seccomp-file", "", "matched Runtime seccomp file") - if flags.Parse(args) != nil || flags.NArg() != 0 { - return errors.New("invalid Runtime launch arguments") - } - if err := supportedHost(); err != nil { - return err - } - key, err := readCredential(credential) - if err != nil { - return err - } - input.Credential = key - raw, err := os.ReadFile(seccomp) - if err != nil || len(raw) > 1024*1024 { - return errors.New("cannot read Runtime seccomp profile") - } - input.Seccomp = string(raw) - if err = input.Validate(); err != nil { - return err - } - c, err := client.New(client.WithHost("unix:///var/run/docker.sock")) - if err != nil { - return errors.New("cannot open local Docker") - } - defer c.Close() - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) - defer cancel() - name, err := docker.LaunchSelfHosted(ctx, c, input) - if err != nil { - // A name contains only a digest, never credentials or request diagnostics. - if name != "" { - fmt.Fprintln(os.Stderr, "Retained Runtime name:", name) - } - return err - } - return json.NewEncoder(os.Stdout).Encode(map[string]string{"container": name, "status": "started"}) -} - -// replaceCredential writes a rotated executor credential into the stopped -// user-owned Runtime container; its volumes and native history stay. -func replaceCredential(args []string) error { - var name, credential string - flags := flag.NewFlagSet("oac-selfhost replace-credential", flag.ContinueOnError) - flags.SetOutput(io.Discard) - flags.StringVar(&name, "container", "", "user-owned Runtime container name") - flags.StringVar(&credential, "credential-file", "", "private restricted executor credential JSON") - if flags.Parse(args) != nil || flags.NArg() != 0 || name == "" { - return errors.New("invalid credential replacement arguments") - } - if err := supportedHost(); err != nil { - return err - } - key, err := readCredential(credential) - if err != nil { - return err - } - c, err := client.New(client.WithHost("unix:///var/run/docker.sock")) - if err != nil { - return errors.New("cannot open local Docker") - } - defer c.Close() - ctx, cancel := context.WithTimeout(context.Background(), time.Minute) - defer cancel() - if err = docker.ReplaceSelfHostedCredential(ctx, c, name, key); err != nil { - return err - } - return json.NewEncoder(os.Stdout).Encode(map[string]string{"container": name, "status": "credential_replaced"}) -} - -func supportedHost() error { - if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" || os.Getuid() == 0 { - return errors.New("run as a non-root user on Linux amd64 with Docker access") - } - return nil -} - -func readCredential(path string) (docker.ExecutorCredential, error) { - var key docker.ExecutorCredential - raw, err := readPrivateCredential(path) - if err != nil { - return key, err - } - decoder := json.NewDecoder(bytes.NewReader(raw)) - decoder.DisallowUnknownFields() - if decoder.Decode(&key) != nil || decoder.Decode(new(any)) != io.EOF { - return key, errors.New("invalid restricted executor credential file") - } - return key, nil -} - -func readPrivateCredential(path string) ([]byte, error) { - if !filepath.IsAbs(path) || filepath.Clean(path) != path { - return nil, errors.New("absolute private credential file required") - } - f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0) - if err != nil { - return nil, errors.New("cannot open private credential file") - } - defer f.Close() - info, err := f.Stat() - if err != nil { - return nil, errors.New("cannot inspect private credential file") - } - st, ok := info.Sys().(*syscall.Stat_t) - if !ok || !info.Mode().IsRegular() || info.Mode().Perm() != 0600 || st.Uid != uint32(os.Getuid()) || st.Nlink != 1 { - return nil, errors.New("credential must be a mode-0600 owned regular file") - } - raw, err := io.ReadAll(io.LimitReader(f, 16*1024+1)) - if err != nil || len(raw) > 16*1024 { - return nil, errors.New("cannot read bounded credential file") - } - return raw, nil -} diff --git a/services/agents-api/cmd/runtime/main_test.go b/services/agents-api/cmd/runtime/main_test.go deleted file mode 100644 index 079988cd6..000000000 --- a/services/agents-api/cmd/runtime/main_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package main - -import ( - "os" - "path/filepath" - "testing" -) - -func TestRuntimeCredentialRequiresPrivateOwnedRegularFile(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "credential.json") - if err := os.WriteFile(path, []byte(`{"executor_token":"synthetic"}`), 0600); err != nil { - t.Fatal(err) - } - if _, err := readPrivateCredential(path); err != nil { - t.Fatal(err) - } - if err := os.Chmod(path, 0644); err != nil { - t.Fatal(err) - } - if _, err := readPrivateCredential(path); err == nil { - t.Fatal("public credential accepted") - } - if err := os.Chmod(path, 0600); err != nil { - t.Fatal(err) - } - alias := filepath.Join(dir, "alias") - if err := os.Symlink(path, alias); err != nil { - t.Fatal(err) - } - if _, err := readPrivateCredential(alias); err == nil { - t.Fatal("symlink accepted") - } - if err := os.Remove(alias); err != nil { - t.Fatal(err) - } - if err := os.Link(path, alias); err != nil { - t.Fatal(err) - } - if _, err := readPrivateCredential(path); err == nil { - t.Fatal("hardlink accepted") - } -} diff --git a/services/agents-api/internal/sandbox/docker/self_hosted.go b/services/agents-api/internal/sandbox/docker/self_hosted.go deleted file mode 100644 index c12737a66..000000000 --- a/services/agents-api/internal/sandbox/docker/self_hosted.go +++ /dev/null @@ -1,185 +0,0 @@ -package docker - -import ( - "context" - "crypto/sha256" - "encoding/hex" - "encoding/json" - "errors" - "net/url" - "strings" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/containerd/errdefs" - "github.com/moby/moby/api/types/container" - "github.com/moby/moby/api/types/mount" - "github.com/moby/moby/client" -) - -// selfHostedCredentialPath is the daemon's credential file, relative to /home. -const selfHostedCredentialPath = "runtime/.oac/daemon/executor-key.json" - -// SelfHostedLaunch is local installation input, never a public execution request. -// The caller retains the container and volumes; Core acquires no compute authority. -type SelfHostedLaunch struct { - InstallationID, EnvironmentID, RemoteURL, Image, Seccomp string - Credential ExecutorCredential -} - -type ExecutorCredential struct { - KeyID string `json:"key_id"` - EnvironmentID string `json:"environment_id"` - Token string `json:"executor_token"` -} - -func (v SelfHostedLaunch) Validate() error { - u, err := url.Parse(v.RemoteURL) - if err != nil || u.Scheme != "wss" || u.Hostname() == "" || u.User != nil || u.Path != "/api/v1/agent-daemon/ws" || u.RawPath != "" || u.RawQuery != "" || u.ForceQuery || u.Fragment != "" || strings.TrimSpace(v.RemoteURL) != v.RemoteURL { - return errors.New("Docker Runtime requires the unchanged HTTPS-reachable wss Environment remote_url") - } - if !validID(v.InstallationID) || !validID(v.EnvironmentID) || !v.Credential.restrictedTo(v.EnvironmentID) { - return errors.New("invalid Environment identity or restricted executor credential") - } - if !strings.HasPrefix(v.Image, "sha256:") || len(v.Image) != 71 { - return errors.New("Runtime image must be the distribution image digest") - } - if raw, err := hex.DecodeString(strings.TrimPrefix(v.Image, "sha256:")); err != nil || len(raw) != 32 || !json.Valid([]byte(v.Seccomp)) { - return errors.New("invalid Runtime image digest or seccomp profile") - } - return nil -} - -func (c ExecutorCredential) restrictedTo(environment string) bool { - return validID(c.KeyID) && c.EnvironmentID == environment && c.Token != "" && len(c.Token) <= 16384 && !strings.ContainsAny(c.Token, " \t\r\n\x00") -} - -func (v SelfHostedLaunch) Name() string { - hash := sha256.Sum256([]byte(v.InstallationID + ":" + v.EnvironmentID)) - return "oac-selfhost-" + hex.EncodeToString(hash[:16]) -} - -// LaunchSelfHosted starts the packaged daemon's existing connect path using the -// same container isolation as managed V1. It never retries an uncertain launch, -// replaces compute, imports history or enrolls a sandbox node. -func LaunchSelfHosted(ctx context.Context, c *client.Client, v SelfHostedLaunch) (string, error) { - if err := v.Validate(); err != nil { - return "", err - } - if c == nil { - return "", sandbox.ErrInvalid - } - name := v.Name() - if _, err := c.ContainerInspect(ctx, name, client.ContainerInspectOptions{}); err == nil { - return name, sandbox.ErrExists - } else if !errdefs.IsNotFound(err) { - return name, errors.New("cannot inspect Docker Runtime") - } - labels := map[string]string{labelPrefix + "user-owned": "true", labelPrefix + "installation": v.InstallationID, labelPrefix + "environment": v.EnvironmentID} - for _, suffix := range []string{"-home", "-environment"} { - if _, err := c.VolumeInspect(ctx, name+suffix, client.VolumeInspectOptions{}); err == nil { - return name, sandbox.ErrExists - } else if !errdefs.IsNotFound(err) { - return name, errors.New("cannot inspect Runtime volumes") - } - } - for _, suffix := range []string{"-home", "-environment"} { - volume, err := c.VolumeCreate(ctx, client.VolumeCreateOptions{Name: name + suffix, Labels: labels}) - if err != nil { - return name, errors.New("cannot create Runtime volume; retain partial state") - } - for key, value := range labels { - if volume.Volume.Labels[key] != value { - return name, sandbox.ErrOwnership - } - } - } - options := runtimeContainerOptions(Config{Image: v.Image, Network: "bridge", Seccomp: v.Seccomp, NestedSandbox: true}, name, labels, nil) - // --self-hosted-install makes the daemon name this installer's rerun as the fix for a rejection. - options.Config.Cmd = []string{"connect", "--profile", "default", "--remote", v.RemoteURL, "--environment-id", v.EnvironmentID, "--credential-file", "/home/" + selfHostedCredentialPath, "--self-hosted-install"} - options.HostConfig.RestartPolicy = container.RestartPolicy{Name: container.RestartPolicyUnlessStopped} - created, err := c.ContainerCreate(ctx, options) - if err != nil { - return name, errors.New("cannot create Runtime container; retain partial state") - } - credential, _ := json.Marshal(v.Credential) - if err = copyRuntimeFiles(ctx, c, created.ID, "/home", []entry{ - {name: "runtime", directory: true}, {name: "runtime/.oac", directory: true}, - {name: "runtime/.oac/daemon", directory: true}, - {name: selfHostedCredentialPath, content: credential}, - }); err != nil { - return name, errors.New("cannot initialize private Runtime credential; retain partial state") - } - if err = copyRuntimeFiles(ctx, c, created.ID, "/environment", []entry{ - {name: "workspace", directory: true}, {name: "staging", directory: true}, - {name: "initialization", directory: true}, {name: "packages", directory: true}, - }); err != nil { - return name, errors.New("cannot initialize Runtime workspace; retain partial state") - } - if _, err = c.ContainerStart(ctx, created.ID, client.ContainerStartOptions{}); err != nil { - return name, errors.New("Runtime start outcome uncertain; inspect retained container") - } - return name, nil -} - -// ReplaceSelfHostedCredential writes a rotated executor credential into a -// stopped user-owned Runtime, keeping its container, volumes and native history. -// The container must carry this installation's labels and name for the -// credential's Environment; a running or unlabeled container is refused. -func ReplaceSelfHostedCredential(ctx context.Context, c *client.Client, name string, credential ExecutorCredential) error { - if c == nil || !validID(credential.EnvironmentID) || !credential.restrictedTo(credential.EnvironmentID) { - return sandbox.ErrInvalid - } - inspected, err := c.ContainerInspect(ctx, name, client.ContainerInspectOptions{}) - if err != nil { - return errors.New("cannot inspect Docker Runtime") - } - current := inspected.Container - if err = selfHostedOwned(current, name, credential.EnvironmentID); err != nil { - return err - } - if current.State.Status != container.StateExited && current.State.Status != container.StateCreated { - return errors.New("stop the Runtime before replacing its executor credential") - } - // A planted directory symlink would redirect the write out of the private home. - for _, path := range []string{"/home/runtime", "/home/runtime/.oac", "/home/runtime/.oac/daemon"} { - stat, err := c.ContainerStatPath(ctx, current.ID, client.ContainerStatPathOptions{Path: path}) - if err != nil || !stat.Stat.Mode.IsDir() || stat.Stat.LinkTarget != "" { - return errors.New("the Runtime's private credential directory is missing or redirected") - } - } - raw, _ := json.Marshal(credential) - if err = copyRuntimeFiles(ctx, c, current.ID, "/home", []entry{{name: selfHostedCredentialPath, content: raw}}); err != nil { - return errors.New("cannot replace the Runtime executor credential; inspect the retained container") - } - return nil -} - -// selfHostedOwned requires the labels, name and exactly the volume layout that -// LaunchSelfHosted creates, so a container copying the name and labels cannot -// direct the credential into another volume. -func selfHostedOwned(current container.InspectResponse, name, environment string) error { - if current.Config == nil || current.State == nil || current.HostConfig == nil { - return sandbox.ErrOwnership - } - labels := current.Config.Labels - owner := SelfHostedLaunch{InstallationID: labels[labelPrefix+"installation"], EnvironmentID: labels[labelPrefix+"environment"]} - if labels[labelPrefix+"user-owned"] != "true" || !validID(owner.InstallationID) || owner.EnvironmentID != environment || owner.Name() != name { - return sandbox.ErrOwnership - } - want := map[string]string{"/home": name + "-home", "/environment": name + "-environment", "/workspace": name + "-environment"} - if len(current.Mounts) != len(want) { - return sandbox.ErrOwnership - } - for _, m := range current.Mounts { - if m.Type != mount.TypeVolume || m.Name == "" || want[m.Destination] != m.Name { - return sandbox.ErrOwnership - } - delete(want, m.Destination) - } - for path := range current.HostConfig.Tmpfs { - if path != "/tmp" { - return sandbox.ErrOwnership - } - } - return nil -} diff --git a/services/agents-api/internal/sandbox/docker/self_hosted_test.go b/services/agents-api/internal/sandbox/docker/self_hosted_test.go deleted file mode 100644 index 5e6776cf9..000000000 --- a/services/agents-api/internal/sandbox/docker/self_hosted_test.go +++ /dev/null @@ -1,221 +0,0 @@ -package docker - -import ( - "archive/tar" - "encoding/base64" - "encoding/json" - "errors" - "io" - "net/http" - "net/http/httptest" - "os" - "reflect" - "strings" - "testing" - - "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" - "github.com/google/uuid" - "github.com/moby/moby/api/types/container" - "github.com/moby/moby/client" -) - -func selfHostedFixture() SelfHostedLaunch { - environment := uuid.NewString() - return SelfHostedLaunch{InstallationID: uuid.NewString(), EnvironmentID: environment, RemoteURL: "wss://core.example/api/v1/agent-daemon/ws", Image: "sha256:" + strings.Repeat("a", 64), Seccomp: `{}`, Credential: ExecutorCredential{KeyID: uuid.NewString(), EnvironmentID: environment, Token: "synthetic-private-token"}} -} - -func TestSelfHostedRejectsUnsafeLaunchBeforeDocker(t *testing.T) { - for _, change := range []func(*SelfHostedLaunch){ - func(v *SelfHostedLaunch) { v.RemoteURL = "ws://localhost/api/v1/agent-daemon/ws" }, - func(v *SelfHostedLaunch) { v.RemoteURL += "?secret=value" }, - func(v *SelfHostedLaunch) { v.RemoteURL = "wss://user:secret@core.example/api/v1/agent-daemon/ws" }, - func(v *SelfHostedLaunch) { v.Credential.EnvironmentID = "" }, - func(v *SelfHostedLaunch) { v.Credential.EnvironmentID = uuid.NewString() }, - func(v *SelfHostedLaunch) { v.Image = "runtime:latest" }, - } { - value := selfHostedFixture() - change(&value) - if _, err := LaunchSelfHosted(t.Context(), nil, value); err == nil { - t.Fatal("unsafe launch accepted") - } - } -} - -func TestSelfHostedLaunchUsesQualifiedIsolationAndPrivateBootstrap(t *testing.T) { - value := selfHostedFixture() - var configuration struct { - container.Config - HostConfig container.HostConfig - } - privateFiles := map[string]*tar.Header{} - contents := map[string]string{} - started, creates := false, 0 - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := strings.TrimPrefix(r.URL.Path, "/v1.52") - w.Header().Set("Content-Type", "application/json") - switch { - case r.Method == "GET" && (strings.HasPrefix(path, "/volumes/") || strings.HasSuffix(path, "/json")): - w.WriteHeader(404) - _, _ = io.WriteString(w, `{"message":"not found"}`) - case path == "/volumes/create": - var request map[string]any - _ = json.NewDecoder(r.Body).Decode(&request) - _ = json.NewEncoder(w).Encode(request) - case path == "/containers/create": - creates++ - if err := json.NewDecoder(r.Body).Decode(&configuration); err != nil { - t.Error(err) - } - w.WriteHeader(201) - _, _ = io.WriteString(w, `{"Id":"runtime-id","Warnings":[]}`) - case path == "/containers/runtime-id/archive": - reader := tar.NewReader(r.Body) - for { - header, err := reader.Next() - if err == io.EOF { - break - } - if err != nil { - t.Error(err) - break - } - name := r.URL.Query().Get("path") + "/" + header.Name - privateFiles[name] = header - content, _ := io.ReadAll(reader) - contents[name] = string(content) - } - w.WriteHeader(200) - case path == "/containers/runtime-id/start": - started = true - w.WriteHeader(204) - default: - t.Errorf("unexpected Docker request %s %s", r.Method, path) - w.WriteHeader(500) - } - })) - defer server.Close() - c, err := client.New(client.WithHost(server.URL), client.WithAPIVersion("1.52")) - if err != nil { - t.Fatal(err) - } - defer c.Close() - name, err := LaunchSelfHosted(t.Context(), c, value) - if err != nil || name != value.Name() || creates != 1 || !started { - t.Fatal("launch", err) - } - qualified := runtimeContainerOptions(Config{Image: value.Image, Seccomp: value.Seccomp, Network: "bridge", NestedSandbox: true}, name, configuration.Labels, nil) - qualified.HostConfig.RestartPolicy = configuration.HostConfig.RestartPolicy - if !reflect.DeepEqual(*qualified.HostConfig, configuration.HostConfig) { - t.Fatal("user Runtime changed qualified isolation") - } - if configuration.User != "1000:1000" || configuration.WorkingDir != "/environment/workspace" || len(configuration.Env) != 0 { - t.Fatal("unsafe process configuration") - } - command, _ := json.Marshal(configuration.Cmd) - if strings.Contains(string(command), value.Credential.Token) || !strings.Contains(string(command), value.RemoteURL) || configuration.Cmd[len(configuration.Cmd)-1] != "--self-hosted-install" { - t.Fatal("credential in argv or remote URL changed") - } - key := "/home/runtime/.oac/daemon/executor-key.json" - for name, header := range privateFiles { - if header.Uid != 1000 || header.Gid != 1000 || header.Mode != 0700 && header.Mode != 0600 { - t.Fatal("unsafe archive ownership", name) - } - } - if privateFiles[key] == nil || privateFiles[key].Mode != 0600 || !strings.Contains(contents[key], value.Credential.Token) || privateFiles["/environment/workspace"] == nil { - t.Fatal("missing protected credential or workspace") - } -} - -func TestSelfHostedRetainsExistingRuntimeWithoutWrites(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method != "GET" || !strings.HasSuffix(r.URL.Path, "/json") { - t.Fatal("unexpected mutation") - } - w.Header().Set("Content-Type", "application/json") - _, _ = io.WriteString(w, `{"Id":"existing"}`) - })) - defer server.Close() - c, err := client.New(client.WithHost(server.URL), client.WithAPIVersion("1.52")) - if err != nil { - t.Fatal(err) - } - defer c.Close() - if _, err := LaunchSelfHosted(t.Context(), c, selfHostedFixture()); !errors.Is(err, sandbox.ErrExists) { - t.Fatal("existing history adopted", err) - } -} - -func TestSelfHostedCredentialReplacementOnlyWritesStoppedOwnedRuntime(t *testing.T) { - value := selfHostedFixture() - name := value.Name() - labels := map[string]string{labelPrefix + "user-owned": "true", labelPrefix + "installation": value.InstallationID, labelPrefix + "environment": value.EnvironmentID} - mounts := func(home string) []map[string]string { - return []map[string]string{{"Type": "volume", "Name": home, "Destination": "/home"}, - {"Type": "volume", "Name": name + "-environment", "Destination": "/environment"}, - {"Type": "volume", "Name": name + "-environment", "Destination": "/workspace"}} - } - rotated := value.Credential - rotated.Token = "rotated-private-token" - for _, tc := range []struct { - name, status, container, home, symlink string - labels map[string]string - replaced bool - }{ - {"stopped", "exited", name, name + "-home", "", labels, true}, - {"running", "running", name, name + "-home", "", labels, false}, - {"unlabeled", "exited", name, name + "-home", "", map[string]string{}, false}, - {"other installation", "exited", "oac-selfhost-" + strings.Repeat("0", 32), name + "-home", "", labels, false}, - {"victim volume at /home", "exited", name, "victim-home", "", labels, false}, - {"redirected daemon directory", "exited", name, name + "-home", "/home/runtime/.oac/daemon", labels, false}, - } { - t.Run(tc.name, func(t *testing.T) { - files := map[string]*tar.Header{} - contents := map[string]string{} - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - path := strings.TrimPrefix(r.URL.Path, "/v1.52") - w.Header().Set("Content-Type", "application/json") - switch { - case r.Method == "GET" && path == "/containers/"+tc.container+"/json": - _ = json.NewEncoder(w).Encode(map[string]any{"Id": "runtime-id", "Config": map[string]any{"Labels": tc.labels}, - "State": map[string]any{"Status": tc.status}, "HostConfig": map[string]any{"Tmpfs": map[string]string{"/tmp": "rw"}}, "Mounts": mounts(tc.home)}) - case r.Method == "HEAD" && path == "/containers/runtime-id/archive": - stat := container.PathStat{Name: r.URL.Query().Get("path"), Mode: os.ModeDir | 0700} - if stat.Name == tc.symlink { - stat.Mode, stat.LinkTarget = os.ModeSymlink|0777, "/home/victim" - } - raw, _ := json.Marshal(stat) - w.Header().Set("X-Docker-Container-Path-Stat", base64.StdEncoding.EncodeToString(raw)) - case r.Method == "PUT" && path == "/containers/runtime-id/archive": - reader := tar.NewReader(r.Body) - for header, err := reader.Next(); err == nil; header, err = reader.Next() { - content, _ := io.ReadAll(reader) - files[r.URL.Query().Get("path")+"/"+header.Name] = header - contents[r.URL.Query().Get("path")+"/"+header.Name] = string(content) - } - w.WriteHeader(200) - default: - t.Errorf("unexpected Docker request %s %s", r.Method, path) - w.WriteHeader(500) - } - })) - defer server.Close() - c, err := client.New(client.WithHost(server.URL), client.WithAPIVersion("1.52")) - if err != nil { - t.Fatal(err) - } - defer c.Close() - err = ReplaceSelfHostedCredential(t.Context(), c, tc.container, rotated) - if !tc.replaced { - if err == nil || len(files) != 0 { - t.Fatal("credential written into a running, foreign or redirected Runtime", err) - } - return - } - key := "/home/runtime/.oac/daemon/executor-key.json" - if err != nil || len(files) != 1 || files[key] == nil || files[key].Uid != 1000 || files[key].Gid != 1000 || files[key].Mode != 0600 || - !strings.Contains(contents[key], rotated.Token) { - t.Fatal("credential not replaced privately", err) - } - }) - } -} diff --git a/services/core-console/distribution_test.go b/services/core-console/distribution_test.go index 0372022c5..b76fc158b 100644 --- a/services/core-console/distribution_test.go +++ b/services/core-console/distribution_test.go @@ -12,7 +12,7 @@ import ( func TestOfflineArtifactsAreManifestAllowlisted(t *testing.T) { dist, payload := t.TempDir(), t.TempDir() - for _, item := range []struct{ root, name, body string }{{dist, "index.html", "console"}, {payload, "node-install.pyz", "bootstrap"}, {payload, "self-hosted-install.pyz", "bootstrap"}} { + for _, item := range []struct{ root, name, body string }{{dist, "index.html", "console"}, {payload, "node-install.pyz", "bootstrap"}} { if err := os.WriteFile(filepath.Join(item.root, item.name), []byte(item.body), 0600); err != nil { t.Fatal(err) } @@ -20,12 +20,12 @@ func TestOfflineArtifactsAreManifestAllowlisted(t *testing.T) { if err := os.Mkdir(filepath.Join(payload, "artifacts"), 0700); err != nil { t.Fatal(err) } - manifest := map[string]any{"artifacts": map[string]any{"native/bin/oac-node": map[string]string{"filename": "matched-node"}, "private/key": map[string]string{"filename": "private-key"}}} + manifest := map[string]any{"artifacts": map[string]any{"native/bin/oac-node": map[string]string{"filename": "matched-node"}, "private/key": map[string]string{"filename": "private-key"}, "native/bin/oac-selfhost": map[string]string{"filename": "retired-launcher"}}} raw, _ := json.Marshal(manifest) if err := os.WriteFile(filepath.Join(payload, "manifest.json"), raw, 0600); err != nil { t.Fatal(err) } - for _, name := range []string{"matched-node", "private-key", "undeclared"} { + for _, name := range []string{"matched-node", "private-key", "undeclared", "retired-launcher"} { if err := os.WriteFile(filepath.Join(payload, "artifacts", name), []byte("payload"), 0600); err != nil { t.Fatal(err) } @@ -38,7 +38,7 @@ func TestOfflineArtifactsAreManifestAllowlisted(t *testing.T) { defer h.Close() server := httptest.NewServer(h) defer server.Close() - for _, name := range []string{"matched-node", "private-key", "undeclared"} { + for _, name := range []string{"matched-node", "private-key", "undeclared", "retired-launcher"} { req := consoleRequest(t, server, "GET", "/node-install/artifacts/"+name) response, body := responseBody(t, server, req) if name == "matched-node" { @@ -67,7 +67,6 @@ func TestConsoleReportsServableNodeProviders(t *testing.T) { } write(filepath.Join(dist, "index.html"), "console") write(filepath.Join(payload, "node-install.pyz"), "bootstrap") - write(filepath.Join(payload, "self-hosted-install.pyz"), "executor bootstrap") artifacts := map[string]any{} for logical := range map[string]bool{"native/bin/oac-node": true, "images/runtime.tar.gz": true, "native/microsandbox/msb": true} { name := strings.ReplaceAll(logical, "/", "-") diff --git a/services/core-console/node_installation.go b/services/core-console/node_installation.go index 464d7bbda..032615479 100644 --- a/services/core-console/node_installation.go +++ b/services/core-console/node_installation.go @@ -15,15 +15,15 @@ import ( // These are distribution artifacts, never installation configuration or secrets. // Serving the fixed list avoids a package registry or an arbitrary file endpoint. var nodePayloadFiles = map[string]bool{ - "node-install.pyz": true, "self-hosted-install.pyz": true, - "manifest.json": true, "SHA256SUMS": true, "runtime/seccomp.json": true, + "node-install.pyz": true, + "manifest.json": true, "SHA256SUMS": true, "runtime/seccomp.json": true, } // An offline distribution exposes only artifacts declared for these payloads. var optionalPayloadFiles = map[string]bool{ "native/bin/oac-node": true, "native/bin/oac-daemon": true, - "native/bin/oac-selfhost": true, "native/bin/oac-microsandbox-provider": true, - "native/microsandbox/msb": true, "native/microsandbox/libkrunfw.so.5.6.1": true, + "native/bin/oac-microsandbox-provider": true, + "native/microsandbox/msb": true, "native/microsandbox/libkrunfw.so.5.6.1": true, "images/runtime.tar.gz": true, "runtime/seccomp.json": true, } @@ -202,10 +202,8 @@ func (h *console) nodeArtifacts() []string { func (h *console) serveConsoleConfiguration(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(struct { - NodeInstaller bool `json:"node_installer"` - NodeInstallerSHA256 string `json:"node_installer_sha256"` - NodeArtifacts []string `json:"node_artifacts"` - SelfHostedInstaller bool `json:"self_hosted_installer"` - SelfHostedInstallerSHA256 string `json:"self_hosted_installer_sha256"` - }{h.nodePayload != nil, h.nodeInstallerDigest, h.nodeArtifacts(), h.nodePayload != nil, h.selfHostedInstallerDigest}) + NodeInstaller bool `json:"node_installer"` + NodeInstallerSHA256 string `json:"node_installer_sha256"` + NodeArtifacts []string `json:"node_artifacts"` + }{h.nodePayload != nil, h.nodeInstallerDigest, h.nodeArtifacts()}) } diff --git a/services/core-console/node_installation_test.go b/services/core-console/node_installation_test.go index f7983a2df..5c5c3edf1 100644 --- a/services/core-console/node_installation_test.go +++ b/services/core-console/node_installation_test.go @@ -62,6 +62,7 @@ func TestPairedConsoleProxiesOnlyAdministration(t *testing.T) { {"GET", "/console/config", "none", 401}, {"GET", "/node-install/node-install.pyz", "none", 200}, {"GET", "/node-install/caller.key", "none", 404}, + {"GET", "/node-install/self-hosted-install.pyz", "none", 404}, {"POST", "/node-install/node-install.pyz", "none", 405}, } { r := consoleRequest(t, server, tc.method, tc.path) @@ -82,9 +83,9 @@ func TestPairedConsoleProxiesOnlyAdministration(t *testing.T) { t.Fatal("credential leaked") } // Web verifies each downloaded installer against these digests before running it. - selfHostedDigest := sha256.Sum256([]byte("print('self-hosted')")) + nodeDigest := sha256.Sum256([]byte("print('installer')")) if tc.path == "/console/config" && tc.status == 200 && (!strings.Contains(body, `"node_installer":true`) || - !strings.Contains(body, `"self_hosted_installer":true,"self_hosted_installer_sha256":"`+hex.EncodeToString(selfHostedDigest[:])+`"`) || + !strings.Contains(body, `"node_installer_sha256":"`+hex.EncodeToString(nodeDigest[:])+`"`) || strings.Contains(body, "self_hosted_installer") || strings.Contains(body, "sandbox_admin") || strings.Contains(body, "api_keys")) { t.Fatalf("console configuration = %s", body) } diff --git a/services/core-console/server.go b/services/core-console/server.go index ed5e54ff4..faa24ee08 100644 --- a/services/core-console/server.go +++ b/services/core-console/server.go @@ -15,14 +15,13 @@ import ( type console struct { config - root *os.Root - nodePayload *os.Root - nodeInstallerDigest string - selfHostedInstallerDigest string - proxy *httputil.ReverseProxy - transport *http.Transport - host string - auth *consoleAuth + root *os.Root + nodePayload *os.Root + nodeInstallerDigest string + proxy *httputil.ReverseProxy + transport *http.Transport + host string + auth *consoleAuth } type consoleActorContextKey struct{} @@ -49,9 +48,6 @@ func newConsole(c config) (*console, error) { return nil, errors.New("cannot open node installation payload") } h.nodeInstallerDigest, err = installerDigest(h.nodePayload, "node-install.pyz") - if err == nil { - h.selfHostedInstallerDigest, err = installerDigest(h.nodePayload, "self-hosted-install.pyz") - } if err != nil { h.nodePayload.Close() root.Close() From 71b025cd793c22842ca4afbb861773a66c497055 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:35:28 +0000 Subject: [PATCH 03/25] feat(installer): install verified native Harnesses with additive recovery --- apps/parsar-daemon/internal/cli/connect.go | 5 +- .../internal/cli/connect_environment.go | 12 +- .../cli/connect_environment_park_test.go | 22 +- .../internal/cli/native_bundle.go | 204 ++++++++++++ .../internal/cli/native_harness.go | 139 ++++++++ .../internal/cli/native_install.go | 298 ++++++++++++++---- .../internal/cli/native_install_input.go | 114 +++++++ .../internal/cli/native_install_test.go | 214 +++++++++++-- apps/parsar-daemon/internal/cli/root.go | 6 +- 9 files changed, 903 insertions(+), 111 deletions(-) create mode 100644 apps/parsar-daemon/internal/cli/native_bundle.go create mode 100644 apps/parsar-daemon/internal/cli/native_harness.go create mode 100644 apps/parsar-daemon/internal/cli/native_install_input.go diff --git a/apps/parsar-daemon/internal/cli/connect.go b/apps/parsar-daemon/internal/cli/connect.go index 400d49fd6..8ae92131f 100644 --- a/apps/parsar-daemon/internal/cli/connect.go +++ b/apps/parsar-daemon/internal/cli/connect.go @@ -62,7 +62,6 @@ func runConnect(ctx *runContext, args []string) error { remote = fs.String("remote", "", "self-hosted Environment remote_url, unchanged") environment = fs.String("environment-id", "", "self-hosted Environment ID") credentialFile = fs.String("credential-file", "", "absolute path to protected executor credential JSON") - selfHosted = fs.Bool("self-hosted-install", false, "started by the self-hosted installer: a rejection names its rerun as the fix") ) if err := fs.Parse(args); err != nil { return fmt.Errorf("connect: parse flags: %w", err) @@ -78,11 +77,11 @@ func runConnect(ctx *runContext, args []string) error { if err := paths.ValidateProfile(*profile); err != nil { return fmt.Errorf("connect: %w", err) } - if *remote != "" || *environment != "" || *credentialFile != "" || *selfHosted { + if *remote != "" || *environment != "" || *credentialFile != "" { if *serverURL != "" || *token != "" || *deviceName != "" || fs.NArg() != 0 { return errors.New("connect: Environment enrollment cannot use pairing options or positional arguments") } - return runEnvironmentConnect(ctx, *profile, *background, *remote, *environment, *credentialFile, *selfHosted) + return runEnvironmentConnect(ctx, *profile, *background, *remote, *environment, *credentialFile) } inlinePair := strings.TrimSpace(*serverURL) != "" || strings.TrimSpace(*token) != "" diff --git a/apps/parsar-daemon/internal/cli/connect_environment.go b/apps/parsar-daemon/internal/cli/connect_environment.go index 72ea39eb7..90ef04fa7 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment.go +++ b/apps/parsar-daemon/internal/cli/connect_environment.go @@ -129,7 +129,7 @@ func environmentBootstrap(ctx context.Context, prof auth.Profile, remote string) return boot, nil } -func runEnvironmentConnect(rc *runContext, profile string, background bool, remote, environment, credentialFile string, selfHosted bool) error { +func runEnvironmentConnect(rc *runContext, profile string, background bool, remote, environment, credentialFile string) error { base, err := environmentBase(remote) if err != nil { return err @@ -148,7 +148,7 @@ func runEnvironmentConnect(rc *runContext, profile string, background bool, remo // the connection parks instead. parks := !background || daemonize.IsBackgroundChild() rejected := func(err error) error { - if message := environmentRejection(err, keyID, environment, selfHosted); parks && message != "" { + if message := environmentRejection(err, keyID, environment); parks && message != "" { return parkEnvironment(rc.stderr, message) } return err @@ -182,13 +182,9 @@ var ( // environmentRejection names the fix for a permanent Environment rejection: // enrollment 401 or 409, or a permanent WebSocket rejection or close. It returns // "" for anything else (transport failures, 5xx, 404), which keeps the ordinary -// failure exit so the Runtime's restart policy retries it. Only a Runtime the -// self-hosted installer started names that installer's rerun and container. -func environmentRejection(err error, keyID, environment string, selfHosted bool) string { +// failure exit so the Runtime's restart policy retries it. +func environmentRejection(err error, keyID, environment string) string { reconnect, remove := "install it for this Runtime and restart it", "stop this Runtime" - if selfHosted { - reconnect, remove = "rerun the self-hosted install command on this host and paste it", "stop this container" - } switch { case errors.Is(err, errEnvironmentBindingConflict): return fmt.Sprintf("executor credential %s cannot connect: Environment %s is bound to a different executor credential. This Runtime will not retry. Rotate the credential first used for this Environment instead of issuing a new one, then %s. To remove this Runtime instead, %s.", keyID, environment, reconnect, remove) diff --git a/apps/parsar-daemon/internal/cli/connect_environment_park_test.go b/apps/parsar-daemon/internal/cli/connect_environment_park_test.go index d1cd31cf4..9d15f20ef 100644 --- a/apps/parsar-daemon/internal/cli/connect_environment_park_test.go +++ b/apps/parsar-daemon/internal/cli/connect_environment_park_test.go @@ -76,7 +76,7 @@ func TestEnvironmentRejectionParksUntilTerminated(t *testing.T) { t.Fatal(err) } args, _ := json.Marshal([]string{"connect", "--remote", "ws" + strings.TrimPrefix(server.URL, "http") + "/api/v1/agent-daemon/ws", - "--environment-id", environment, "--credential-file", credential, "--self-hosted-install"}) + "--environment-id", environment, "--credential-file", credential}) cmd := exec.Command(os.Args[0], "-test.run=^TestEnvironmentRejectionParksUntilTerminated$") cmd.Env = append(os.Environ(), "OAC_TEST_ENVIRONMENT_CONNECT="+string(args), "OAC_RUNTIME_HOME="+home) var stderr lockedBuffer @@ -137,19 +137,17 @@ func TestEnvironmentRejectionParksUntilTerminated(t *testing.T) { func TestEnvironmentRejectionClassifiesConnectionErrors(t *testing.T) { for _, tc := range []struct { - err error - selfHosted bool - want string + err error + want string }{ - {fmt.Errorf("connect: runtime deleted: %w", transport.ErrPermanent), true, "rerun the self-hosted install command"}, - {fmt.Errorf("connect: runtime deleted: %w", transport.ErrPermanent), false, "install it for this Runtime and restart it"}, - {fmt.Errorf("connect: permanent error: %w: %w", transport.ErrPermanent, transport.ErrIncompatibleVersion), true, "daemon version"}, - {errors.New("connect: bootstrap: Environment bootstrap failed"), true, ""}, - {nil, true, ""}, + {fmt.Errorf("connect: runtime deleted: %w", transport.ErrPermanent), "install it for this Runtime and restart it"}, + {fmt.Errorf("connect: permanent error: %w: %w", transport.ErrPermanent, transport.ErrIncompatibleVersion), "daemon version"}, + {errors.New("connect: bootstrap: Environment bootstrap failed"), ""}, + {nil, ""}, } { - got := environmentRejection(tc.err, uuid.NewString(), uuid.NewString(), tc.selfHosted) - if tc.want == "" && got != "" || !strings.Contains(got, tc.want) || !tc.selfHosted && strings.Contains(got, "container") { - t.Errorf("environmentRejection(%v, %v) = %q", tc.err, tc.selfHosted, got) + got := environmentRejection(tc.err, uuid.NewString(), uuid.NewString()) + if tc.want == "" && got != "" || !strings.Contains(got, tc.want) || strings.Contains(got, "container") { + t.Errorf("environmentRejection(%v) = %q", tc.err, got) } } } diff --git a/apps/parsar-daemon/internal/cli/native_bundle.go b/apps/parsar-daemon/internal/cli/native_bundle.go new file mode 100644 index 000000000..e4baf6d44 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_bundle.go @@ -0,0 +1,204 @@ +package cli + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path" + "path/filepath" + "reflect" + "runtime" + "slices" + "strings" +) + +// This is release content, never an input file for installation options. +type nativeBundle struct { + Schema int `json:"schema"` + DaemonVersion string `json:"daemon_version"` + OS string `json:"os"` + Arch string `json:"arch"` + Components map[string]nativeComponent `json:"components"` +} +type nativeComponent struct { + Version string `json:"version"` + Files map[string]nativeFile `json:"files"` +} +type nativeFile struct { + SHA256 string `json:"sha256"` + Executable bool `json:"executable"` +} + +var nativePins = map[string]string{"node": "22.22.0", "codex": "0.153.4", "claude": "0.3.269", "minimax": "0.4.12"} + +func readNativeJSON(file string, value any) error { + f, err := os.Open(file) + if err != nil { + return err + } + defer f.Close() + raw, err := io.ReadAll(io.LimitReader(f, 16<<20+1)) + if err != nil || len(raw) > 16<<20 { + return errors.New("installation metadata unavailable") + } + return decodeEnvironmentJSON(raw, value) +} + +func readNativeBundle(directory string, selected []string) (nativeBundle, error) { + var b nativeBundle + if readNativeJSON(filepath.Join(directory, "bundle.json"), &b) != nil { + return b, errors.New("install: use the native installer distribution containing bundle.json") + } + if b.Schema != 1 || b.DaemonVersion != Version || b.OS != runtime.GOOS || b.Arch != runtime.GOARCH { + return b, errors.New("install: distribution version or platform mismatch; use a matching current release") + } + for _, name := range append([]string{"node"}, selected...) { + c, ok := b.Components[name] + if !ok || c.Version != nativePins[name] || len(c.Files) == 0 { + return b, fmt.Errorf("install: pinned %s component missing from this distribution", name) + } + for file, info := range c.Files { + if !validBundlePath(file) || len(info.SHA256) != 64 { + return b, errors.New("install: invalid component file manifest") + } + if _, err := hex.DecodeString(info.SHA256); err != nil { + return b, errors.New("install: invalid component digest") + } + } + } + return b, nil +} + +func validBundlePath(name string) bool { + return name != "." && path.Clean(name) == name && !strings.ContainsAny(name, "\\\x00\r\n:") && filepath.IsLocal(filepath.FromSlash(name)) && name != ".oac-install.json" +} + +func nativeComponentRoot(root, name string) string { return filepath.Join(root, "components", name) } + +func componentReceipt(root string) (nativeComponent, error) { + var c nativeComponent + err := readNativeJSON(filepath.Join(root, ".oac-install.json"), &c) + return c, err +} + +func checkComponentFiles(directory string, c nativeComponent) error { + root, err := os.OpenRoot(directory) + if err != nil { + return err + } + defer root.Close() + for name, expected := range c.Files { + if !validBundlePath(name) { + return errors.New("invalid installed path") + } + f, err := root.Open(filepath.FromSlash(name)) + if err != nil { + return err + } + info, e := f.Stat() + h := sha256.New() + _, copyErr := io.Copy(h, f) + f.Close() + if e != nil || !info.Mode().IsRegular() || copyErr != nil || hex.EncodeToString(h.Sum(nil)) != expected.SHA256 || (runtime.GOOS != "windows" && expected.Executable && info.Mode().Perm()&0100 == 0) { + return errors.New("installed files do not match the verified release") + } + } + return nil +} + +// A complete component is published once. An interruption cannot turn a partial +// copy into an installation; a subsequent run can reuse an already-published one. +func installNativeComponent(source, root, name string, expected nativeComponent) error { + dest := nativeComponentRoot(root, name) + if _, err := os.Lstat(dest); err == nil { + got, e := componentReceipt(dest) + if e != nil || !reflect.DeepEqual(got, expected) { + return fmt.Errorf("install: existing %s is incompatible; preserve it and use a separate installation directory", name) + } + if checkComponentFiles(dest, got) != nil { + return fmt.Errorf("install: existing %s is incomplete or modified; reinstall separately", name) + } + return nil + } else if !errors.Is(err, os.ErrNotExist) { + return err + } + parent := filepath.Dir(dest) + if err := os.MkdirAll(parent, 0700); err != nil { + return err + } + tmp, err := os.MkdirTemp(parent, ".install-"+name+"-") + if err != nil { + return err + } + defer os.RemoveAll(tmp) + src, err := os.OpenRoot(filepath.Join(source, "components", name)) + if err != nil { + return err + } + defer src.Close() + for name, expectedFile := range expected.Files { + if !validBundlePath(name) { + return errors.New("install: invalid component path") + } + in, err := src.Open(filepath.FromSlash(name)) + if err != nil { + return errors.New("install: component file unavailable") + } + info, err := in.Stat() + if err != nil || !info.Mode().IsRegular() { + in.Close() + return errors.New("install: component must contain regular files") + } + destination := filepath.Join(tmp, filepath.FromSlash(name)) + if err = os.MkdirAll(filepath.Dir(destination), 0700); err != nil { + in.Close() + return err + } + mode := os.FileMode(0600) + if expectedFile.Executable { + mode = 0700 + } + out, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode) + if err != nil { + in.Close() + return err + } + h := sha256.New() + _, err = io.Copy(io.MultiWriter(out, h), in) + in.Close() + if err == nil { + err = out.Sync() + } + closeErr := out.Close() + if err != nil || closeErr != nil || hex.EncodeToString(h.Sum(nil)) != expectedFile.SHA256 { + return errors.New("install: component checksum failed") + } + } + raw, _ := json.Marshal(expected) + if err = os.WriteFile(filepath.Join(tmp, ".oac-install.json"), raw, 0600); err != nil { + return err + } + return os.Rename(tmp, dest) +} + +func selectedNativeHarnesses(value string) ([]string, error) { + var out []string + for _, name := range strings.Split(value, ",") { + name = strings.TrimSpace(name) + if name != "codex" && name != "claude" && name != "minimax" { + return nil, errors.New("install: --harness requires codex,claude,minimax (comma-separated)") + } + if name == "minimax" && runtime.GOOS == "windows" { + return nil, errors.New("install: MiniMax Code adapter is unsupported on Windows; select Codex or Claude Code") + } + if !slices.Contains(out, name) { + out = append(out, name) + } + } + slices.Sort(out) + return out, nil +} diff --git a/apps/parsar-daemon/internal/cli/native_harness.go b/apps/parsar-daemon/internal/cli/native_harness.go new file mode 100644 index 000000000..23144e643 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_harness.go @@ -0,0 +1,139 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudesdk" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" +) + +func nativeExe(name string) string { + if runtime.GOOS == "windows" { + return name + ".exe" + } + return name +} +func nativeNode(root string) string { + base := nativeComponentRoot(root, "node") + if runtime.GOOS != "windows" { + base = filepath.Join(base, "bin") + } + return filepath.Join(base, nativeExe("node")) +} + +func nativeHarnessEnvironment(root string, selected []string) map[string]string { + node := nativeNode(root) + values := map[string]string{"PATH": filepath.Dir(node) + string(os.PathListSeparator) + os.Getenv("PATH")} + for _, name := range selected { + dir := nativeComponentRoot(root, name) + switch name { + case "codex": + values["OAC_RUNTIME_CODEX_BIN"] = filepath.Join(dir, "bin", nativeExe("codex")) + case "claude": + values[claudeSDKEntrypointEnv] = filepath.Join(dir, "dist", "main.js") + values[claudeSDKNodeEnv] = node + case "minimax": + values["OAC_RUNTIME_MCODE_BIN"] = filepath.Join(dir, "native", "cli.js") + values["OAC_RUNTIME_MCODE_NODE"] = node + values["OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE"] = filepath.Join(dir, "launch.mjs") + values["OAC_RUNTIME_MCODE_AGENTS_API"] = "1" + values["PATH"] = filepath.Join(dir, "bin") + string(os.PathListSeparator) + values["PATH"] + } + } + return values +} + +func withNativeEnv(values map[string]string) []string { + env := make([]string, 0, len(os.Environ())+len(values)) + for _, entry := range os.Environ() { + key, _, _ := strings.Cut(entry, "=") + // Windows environment names are case-insensitive. + replaced := false + for k := range values { + if strings.EqualFold(k, key) { + replaced = true + break + } + } + if !replaced { + env = append(env, entry) + } + } + for key, value := range values { + env = append(env, key+"="+value) + } + return env +} + +// Native diagnostics are deliberately discarded: dependencies may echo their +// environment. Readiness is separate from model credentials and a live Turn. +func nativeProbeCommand(binary string, args, env []string, dir string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + p, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: args, Env: env, Dir: dir, OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond}) + if err != nil { + return "", errors.New("native component failed to start") + } + defer p.Cancel() + done := make(chan struct{}) + go func() { _, _ = io.Copy(io.Discard, p.Stderr); close(done) }() + raw, err := io.ReadAll(io.LimitReader(p.Stdout, 64*1024+1)) + if err != nil || len(raw) > 64*1024 { + p.Cancel() + } + _, _ = io.Copy(io.Discard, p.Stdout) + <-done + waitErr := p.Wait() + if err != nil || waitErr != nil || ctx.Err() != nil || len(raw) > 64*1024 { + return "", errors.New("native component compatibility check failed") + } + return strings.TrimSpace(string(raw)), nil +} + +var probeNativeInstallation = checkNativeInstallation + +func checkNativeInstallation(root string, selected []string) error { + values := nativeHarnessEnvironment(root, selected) + env := withNativeEnv(values) + node := nativeNode(root) + version, err := nativeProbeCommand(node, []string{"--version"}, env, root) + if err != nil || version != "v"+nativePins["node"] { + return errors.New("install: bundled Node is unavailable or incompatible; use the distribution for this operating system and architecture") + } + for _, name := range selected { + dir := nativeComponentRoot(root, name) + switch name { + case "codex": + version, err = nativeProbeCommand(values["OAC_RUNTIME_CODEX_BIN"], []string{"--version"}, env, root) + if err == nil && version != "codex-cli "+nativePins[name] { + err = errors.New("version mismatch") + } + case "claude": + var info claudesdk.RuntimeInfo + info, err = claudesdk.CheckRuntime(context.Background(), claudesdk.Config{Node: node, Entrypoint: values[claudeSDKEntrypointEnv], Env: env}) + if err == nil && (info.SDK != nativePins[name] || !info.SupportsLocalRuntime()) { + err = errors.New("adapter contract mismatch") + } + case "minimax": + version, err = nativeProbeCommand(node, []string{filepath.Join(dir, "native", "cli.js"), "--version"}, env, root) + if err == nil && version != nativePins[name] { + err = errors.New("version mismatch") + } + if err == nil { + _, err = nativeProbeCommand(node, []string{filepath.Join(dir, "check.mjs")}, env, root) + } + } + if err != nil { + return fmt.Errorf("install: %s compatibility check failed; verify OS dependencies (Claude on Windows needs Git Bash; MiniMax needs Bash) and use a matching release; no files were replaced", name) + } + } + return nil +} diff --git a/apps/parsar-daemon/internal/cli/native_install.go b/apps/parsar-daemon/internal/cli/native_install.go index e1273a284..1b6022dd4 100644 --- a/apps/parsar-daemon/internal/cli/native_install.go +++ b/apps/parsar-daemon/internal/cli/native_install.go @@ -1,26 +1,32 @@ package cli import ( + "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" + "flag" "fmt" + "io" "os" "path/filepath" + "slices" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" "github.com/MiniMax-AI-Dev/parsar/internal/runtimefs" ) -// Native installation is a user-owned configuration, not an OS service or sandbox. -// The same binary can be run by an operator's preferred service manager. +// Installation is local state, not an options-file input or an OS service. type nativeInstallation struct { - Version string `json:"version"` - Remote string `json:"remote_url"` - Environment string `json:"environment_id"` - Workspace string `json:"workspace_directory"` - Credential string `json:"credential_file"` - CapabilityDirectory string `json:"capability_directory"` - ToolEnvironmentFile string `json:"tool_environment_file,omitempty"` + Version string `json:"version"` + Remote string `json:"remote_url"` + Environment string `json:"environment_id"` + Workspace string `json:"workspace_directory"` + Credential string `json:"credential_file"` + CapabilityDirectory string `json:"capability_directory"` + ToolEnvironmentFile string `json:"tool_environment_file,omitempty"` + Harnesses []string `json:"harnesses"` } func nativeInstallationPath() (string, error) { @@ -31,109 +37,277 @@ func nativeInstallationPath() (string, error) { return filepath.Join(root, "daemon", "installation.json"), nil } -func runInstall(rc *runContext, args []string) error { - flags := newFlagSet("install") - remote := flags.String("remote", "", "Environment remote_url from Core") - environment := flags.String("environment-id", "", "Environment ID from Core") - workspace := flags.String("workspace", "", "existing absolute workspace directory") - credential := flags.String("credential-file", "", "absolute executor credential JSON file") - capabilities := flags.String("capability-directory", "", "installed capability snapshot directory (defaults to Runtime state)") - toolEnv := flags.String("tool-env-file", "", "optional absolute JSON file with tool environment variables") - if err := flags.Parse(args); err != nil { - return err +func lockNativeInstallation(root string) (*os.Root, func(), error) { + dir := filepath.Join(root, "daemon") + if err := runtimefs.EnsurePrivateDir(dir); err != nil { + return nil, nil, err } - if flags.NArg() != 0 { - return errors.New("install: unexpected positional arguments") + held, err := os.OpenRoot(dir) + if err != nil { + return nil, nil, err } - if _, err := environmentBase(*remote); err != nil { - return err + unlock, err := runtimefs.LockDirectory(held) + if err != nil { + held.Close() + return nil, nil, errors.New("installation is busy; wait for the other operation to finish") } - if !environmentUUID(*environment) { - return errors.New("install: canonical Environment ID required") + return held, func() { unlock(); held.Close() }, nil +} + +func validateNativeInstallation(c nativeInstallation) error { + if c.Version != Version { + return errors.New("installation version is unsupported; preserve this installation and reinstall in a separate directory") } - root, err := paths.Root() - if err != nil { + if _, err := environmentBase(c.Remote); err != nil { return err } - if *capabilities == "" { - *capabilities = filepath.Join(root, "capabilities") + if !environmentUUID(c.Environment) { + return errors.New("install: --environment-id requires a canonical Environment ID") } - for _, path := range []string{*workspace, *credential, *capabilities} { - if runtimefs.ValidateLocalPath(path) != nil { - return errors.New("install: clean absolute paths required") + for _, p := range []string{c.Workspace, c.Credential, c.CapabilityDirectory} { + if runtimefs.ValidateLocalPath(p) != nil { + return errors.New("install: --workspace, --credential-file and capability destination must be clean absolute paths") } } - info, err := os.Stat(*workspace) + info, err := os.Stat(c.Workspace) if err != nil || !info.IsDir() { - return errors.New("install: existing workspace directory required") + return errors.New("install: --workspace requires an existing directory") + } + if _, _, err = executorCredential(c.Credential, c.Environment); err != nil { + return err } - if _, _, err = executorCredential(*credential, *environment); err != nil { + if c.ToolEnvironmentFile != "" { + var values map[string]string + if runtimefs.ValidateLocalPath(c.ToolEnvironmentFile) != nil || readNativeJSON(c.ToolEnvironmentFile, &values) != nil { + return errors.New("install: --tool-env-file requires an absolute JSON file containing string values") + } + } + return nil +} + +func runInstall(rc *runContext, args []string) error { + o, err := parseNativeInstall(rc, args) + if errors.Is(err, flag.ErrHelp) { + return nil + } + if err != nil { return err } - if *toolEnv != "" && runtimefs.ValidateLocalPath(*toolEnv) != nil { - return errors.New("install: absolute tool environment file required") + if runtimefs.ValidateLocalPath(o.Directory) != nil || runtimefs.ValidateLocalPath(o.Bundle) != nil { + return errors.New("install: --install-dir and --bundle-dir must be clean absolute directories") } - config := nativeInstallation{Version: Version, Remote: *remote, Environment: *environment, Workspace: *workspace, Credential: *credential, CapabilityDirectory: *capabilities, ToolEnvironmentFile: *toolEnv} - path, err := nativeInstallationPath() + selected, err := selectedNativeHarnesses(o.Harness) if err != nil { return err } - if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil { + o.Version = Version + if o.CapabilityDirectory == "" { + o.CapabilityDirectory = filepath.Join(o.Directory, "capabilities") + } + if err = validateNativeInstallation(o.nativeInstallation); err != nil { return err } - held, err := os.OpenRoot(filepath.Dir(path)) + bundle, err := readNativeBundle(o.Bundle, selected) if err != nil { return err } - defer held.Close() - unlock, err := runtimefs.LockDirectory(held) + held, unlock, err := lockNativeInstallation(o.Directory) if err != nil { return err } defer unlock() - if _, err = held.Stat(filepath.Base(path)); err == nil { - return errors.New("install: installation already exists; in-place upgrades are unsupported, use its current binary or reinstall explicitly") + var previous nativeInstallation + raw, err := runtimefs.ReadPrivate(held, "installation.json", 1<<20) + if err == nil { + if decodeEnvironmentJSON(raw, &previous) != nil || previous.Version != Version || len(previous.Harnesses) == 0 { + return errors.New("install: existing installation is unsupported; preserve it and reinstall separately") + } + wanted := o.nativeInstallation + wanted.Harnesses = nil + comparison := previous + comparison.Harnesses = nil + before, _ := json.Marshal(comparison) + after, _ := json.Marshal(wanted) + if !bytes.Equal(before, after) { + return errors.New("install: existing connection settings differ; additive installation cannot replace them") + } } else if !errors.Is(err, os.ErrNotExist) { + return errors.New("install: cannot read existing installation") + } + all := append([]string{}, previous.Harnesses...) + for _, name := range selected { + if !slices.Contains(all, name) { + all = append(all, name) + } + } + slices.Sort(all) + // Verify existing components before adding any new one; never repair or + // upgrade an installed dependency as a side effect of adding a Harness. + if len(previous.Harnesses) > 0 { + if err = verifyNativeComponents(o.Directory, previous.Harnesses); err != nil { + return err + } + } + if err = installNativeBinary(o.Directory); err != nil { + return err + } + for _, name := range append([]string{"node"}, selected...) { + if err = installNativeComponent(o.Bundle, o.Directory, name, bundle.Components[name]); err != nil { + return err + } + } + if err = probeNativeInstallation(o.Directory, all); err != nil { return err } - raw, _ := json.MarshalIndent(config, "", " ") - if err = runtimefs.WritePrivateAtomic(held, filepath.Base(path), raw); err != nil { + o.Harnesses = all + raw, _ = json.MarshalIndent(o.nativeInstallation, "", " ") + if err = runtimefs.WritePrivateAtomic(held, "installation.json", raw); err != nil { return err } - fmt.Fprintln(rc.stdout, "Installed daemon configuration:", path) - fmt.Fprintln(rc.stdout, "Run oac-daemon start; use stop, status and logs with the same OAC_RUNTIME_HOME.") + fmt.Fprintln(rc.stdout, "Installation: ready; verified Harnesses:", all) + fmt.Fprintln(rc.stdout, "Daemon connection: not checked by install; run the installed oac-daemon start, then status.") + fmt.Fprintln(rc.stdout, "Model configuration: not checked; configure the Session model provider in Core and send a Turn.") return nil } +func verifyNativeComponents(root string, selected []string) error { + for _, name := range append([]string{"node"}, selected...) { + if _, ok := nativePins[name]; !ok { + return errors.New("installation contains an unsupported Harness") + } + c, err := componentReceipt(nativeComponentRoot(root, name)) + if err != nil || c.Version != nativePins[name] || len(c.Files) == 0 || checkComponentFiles(nativeComponentRoot(root, name), c) != nil { + return fmt.Errorf("installed %s is missing, modified or incompatible; reinstall separately (no automatic repair or upgrade)", name) + } + } + return nil +} + +func installNativeBinary(root string) error { + exe, err := os.Executable() + if err != nil { + return err + } + dir := filepath.Join(root, "bin") + if err = os.MkdirAll(dir, 0700); err != nil { + return err + } + dest := filepath.Join(dir, nativeExe("oac-daemon")) + digest := func(name string) (string, error) { + f, e := os.Open(name) + if e != nil { + return "", e + } + defer f.Close() + h := sha256.New() + _, e = io.Copy(h, f) + return hex.EncodeToString(h.Sum(nil)), e + } + want, err := digest(exe) + if err != nil { + return err + } + if got, e := digest(dest); e == nil { + if got != want { + return errors.New("install: existing daemon binary differs; in-place upgrades are unsupported") + } + return nil + } else if !errors.Is(e, os.ErrNotExist) { + return e + } + in, err := os.Open(exe) + if err != nil { + return err + } + defer in.Close() + out, err := os.CreateTemp(dir, ".oac-daemon-") + if err != nil { + return err + } + defer os.Remove(out.Name()) + _, err = io.Copy(out, in) + if err == nil { + err = out.Chmod(0700) + } + if err == nil { + err = out.Sync() + } + closeErr := out.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + return os.Rename(out.Name(), dest) +} + func runStart(rc *runContext, args []string) error { flags := newFlagSet("start") foreground := flags.Bool("foreground", false, "stay attached to this terminal") if err := flags.Parse(args); err != nil { - return err + return errors.New("start: invalid arguments") } if flags.NArg() != 0 { return errors.New("start: unexpected positional arguments") } - path, err := nativeInstallationPath() + root, err := paths.Root() + if err != nil { + return err + } + held, unlock, err := lockNativeInstallation(root) if err != nil { return err } - raw, err := os.ReadFile(path) var config nativeInstallation - if err != nil || decodeEnvironmentJSON(raw, &config) != nil { - return errors.New("start: installation configuration unavailable; run install first") + raw, err := runtimefs.ReadPrivate(held, "installation.json", 1<<20) + if err == nil { + err = decodeEnvironmentJSON(raw, &config) + } + if err == nil { + err = validateNativeInstallation(config) + } + if err == nil && len(config.Harnesses) == 0 { + err = errors.New("start: no installed Harnesses; rerun install with --harness") + } + if err == nil { + err = verifyNativeComponents(root, config.Harnesses) } - if config.Version != Version { - return errors.New("start: this installation version is unsupported; reinstall explicitly") + if err == nil { + err = probeNativeInstallation(root, config.Harnesses) } - for key, value := range map[string]string{ - "OAC_RUNTIME_WORKSPACE": config.Workspace, - "OAC_RUNTIME_CAPABILITY_DIRECTORY": config.CapabilityDirectory, - "OAC_RUNTIME_TOOL_ENV_FILE": config.ToolEnvironmentFile, - } { + unlock() + if err != nil { + return fmt.Errorf("start: installation unavailable or incompatible: %w", err) + } + values := nativeHarnessEnvironment(root, config.Harnesses) + values["OAC_RUNTIME_WORKSPACE"] = config.Workspace + values["OAC_RUNTIME_CAPABILITY_DIRECTORY"] = config.CapabilityDirectory + values["OAC_RUNTIME_TOOL_ENV_FILE"] = config.ToolEnvironmentFile + for key, value := range values { if err = os.Setenv(key, value); err != nil { return err } } - return runEnvironmentConnect(rc, paths.DefaultProfile, !*foreground, config.Remote, config.Environment, config.Credential, false) + return runEnvironmentConnect(rc, paths.DefaultProfile, !*foreground, config.Remote, config.Environment, config.Credential) +} + +func useInstalledNativeHome() { + if os.Getenv("OAC_RUNTIME_HOME") != "" { + return + } + exe, err := os.Executable() + if err != nil { + return + } + if resolved, e := filepath.EvalSymlinks(exe); e == nil { + exe = resolved + } + if filepath.Base(filepath.Dir(exe)) != "bin" { + return + } + root := filepath.Dir(filepath.Dir(exe)) + if _, err = os.Stat(filepath.Join(root, "daemon", "installation.json")); err == nil { + _ = os.Setenv("OAC_RUNTIME_HOME", root) + } } diff --git a/apps/parsar-daemon/internal/cli/native_install_input.go b/apps/parsar-daemon/internal/cli/native_install_input.go new file mode 100644 index 000000000..1ac4c9431 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_install_input.go @@ -0,0 +1,114 @@ +package cli + +import ( + "bufio" + "errors" + "flag" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" +) + +type nativeInstallOptions struct { + nativeInstallation + Directory, Bundle, Harness string + Interactive, NonInteractive bool +} + +func parseNativeInstall(rc *runContext, args []string) (nativeInstallOptions, error) { + var o nativeInstallOptions + flags := newFlagSet("install") + flags.StringVar(&o.Remote, "remote", "", "Environment remote_url from Core") + flags.StringVar(&o.Environment, "environment-id", "", "Environment ID from Core") + flags.StringVar(&o.Workspace, "workspace", "", "existing absolute workspace directory") + flags.StringVar(&o.Credential, "credential-file", "", "absolute executor credential JSON file (never the token)") + flags.StringVar(&o.CapabilityDirectory, "capability-directory", "", "capability snapshot destination") + flags.StringVar(&o.ToolEnvironmentFile, "tool-env-file", "", "optional tool environment JSON file") + flags.StringVar(&o.Directory, "install-dir", "", "installation directory (default OAC_RUNTIME_HOME or ~/.oac)") + flags.StringVar(&o.Bundle, "bundle-dir", "", "native distribution directory (defaults beside the executable)") + flags.StringVar(&o.Harness, "harness", "", "comma-separated codex,claude,minimax") + flags.BoolVar(&o.Interactive, "interactive", false, "ask for missing installation options") + flags.BoolVar(&o.NonInteractive, "non-interactive", false, "require command-line options; never prompt") + if err := flags.Parse(args); err != nil { + if errors.Is(err, flag.ErrHelp) { + flags.SetOutput(rc.stdout) + flags.PrintDefaults() + return o, flag.ErrHelp + } + // flag errors can contain supplied values, including accidental secrets. + return o, errors.New("install: invalid arguments; run install --help (use --credential-file for credentials)") + } + if flags.NArg() != 0 || (o.Interactive && o.NonInteractive) { + return o, errors.New("install: unexpected arguments or conflicting interaction modes") + } + if o.Directory == "" { + var err error + o.Directory, err = paths.Root() + if err != nil { + return o, err + } + } + if o.Bundle == "" { + exe, err := os.Executable() + if err != nil { + return o, err + } + o.Bundle = filepath.Dir(exe) + } + if len(args) == 0 && !o.NonInteractive { + if info, err := os.Stdin.Stat(); err == nil && info.Mode()&os.ModeCharDevice != 0 { + o.Interactive = true + } + } + if o.Interactive { + input := rc.stdin + if input == nil { + input = os.Stdin + } + if err := promptNativeInstall(input, rc.stdout, &o); err != nil { + return o, err + } + } + return o, nil +} + +func promptNativeInstall(input io.Reader, output io.Writer, o *nativeInstallOptions) error { + r := bufio.NewReader(input) + // Only paths and connection identifiers are requested; tokens are read later + // from the private credential file, never entered or echoed by this prompt. + for _, p := range []struct { + label string + value *string + optional bool + }{ + {"Harnesses (comma-separated codex,claude,minimax)", &o.Harness, false}, + {"Installation directory (Enter uses current default)", &o.Directory, true}, + {"Distribution directory (Enter uses current default)", &o.Bundle, true}, + {"Environment remote URL", &o.Remote, false}, + {"Environment ID", &o.Environment, false}, + {"Existing workspace directory", &o.Workspace, false}, + {"Credential JSON file path (do not enter a token)", &o.Credential, false}, + {"Capability snapshot directory (optional)", &o.CapabilityDirectory, true}, + {"Tool environment JSON file path (optional)", &o.ToolEnvironmentFile, true}, + } { + if *p.value != "" && !p.optional { + continue + } + fmt.Fprint(output, p.label+": ") + line, err := r.ReadString('\n') + if err != nil { + return errors.New("install: interactive input ended; use --non-interactive with complete arguments") + } + value := strings.TrimSpace(line) + if value != "" { + *p.value = value + } else if !p.optional { + return errors.New("install: required interactive value missing") + } + } + return nil +} diff --git a/apps/parsar-daemon/internal/cli/native_install_test.go b/apps/parsar-daemon/internal/cli/native_install_test.go index 21d21403f..55cf4b470 100644 --- a/apps/parsar-daemon/internal/cli/native_install_test.go +++ b/apps/parsar-daemon/internal/cli/native_install_test.go @@ -2,58 +2,224 @@ package cli import ( "bytes" + "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" "os" "path/filepath" + "runtime" + "strings" "testing" "github.com/google/uuid" ) -func TestNativeInstallationPersistsWithoutStartingOrDeleting(t *testing.T) { +func nativeInstallFixture(t *testing.T) (*runContext, []string, string, string) { + t.Helper() root := t.TempDir() + bundle := t.TempDir() + workspace := t.TempDir() t.Setenv("OAC_RUNTIME_HOME", root) - workspace := filepath.Join(root, "workspace") - if err := os.MkdirAll(workspace, 0700); err != nil { - t.Fatal(err) - } key := filepath.Join(root, "credential.json") environment := uuid.NewString() - body, _ := json.Marshal(map[string]string{"key_id": uuid.NewString(), "executor_token": "test-token", "environment_id": environment}) + body, _ := json.Marshal(map[string]string{"key_id": uuid.NewString(), "executor_token": "private-test-credential", "environment_id": environment}) if err := os.WriteFile(key, body, 0600); err != nil { t.Fatal(err) } + b := nativeBundle{Schema: 1, DaemonVersion: Version, OS: runtime.GOOS, Arch: runtime.GOARCH, Components: map[string]nativeComponent{}} + for _, name := range []string{"node", "codex", "claude"} { + data := []byte("fixture " + name) + sum := sha256.Sum256(data) + b.Components[name] = nativeComponent{Version: nativePins[name], Files: map[string]nativeFile{"program": {SHA256: hex.EncodeToString(sum[:]), Executable: true}}} + dir := nativeComponentRoot(bundle, name) + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "program"), data, 0700); err != nil { + t.Fatal(err) + } + } + raw, _ := json.Marshal(b) + if err := os.WriteFile(filepath.Join(bundle, "bundle.json"), raw, 0600); err != nil { + t.Fatal(err) + } + old := probeNativeInstallation + probeNativeInstallation = func(string, []string) error { return nil } + t.Cleanup(func() { probeNativeInstallation = old }) output := new(bytes.Buffer) - rc := &runContext{stdout: output, stderr: output} - args := []string{"--remote", "ws://localhost:12345/api/v1/agent-daemon/ws", "--environment-id", environment, "--workspace", workspace, "--credential-file", key} + rc := &runContext{stdin: strings.NewReader("must not read"), stdout: output, stderr: output} + args := []string{"--non-interactive", "--bundle-dir", bundle, "--harness", "codex", "--remote", "ws://localhost:12345/api/v1/agent-daemon/ws", "--environment-id", environment, "--workspace", workspace, "--credential-file", key} + return rc, args, root, bundle +} + +func TestNativeInstallationAdditiveReuseAndVersionRejection(t *testing.T) { + rc, args, root, _ := nativeInstallFixture(t) if err := runInstall(rc, args); err != nil { t.Fatal(err) } - path, _ := nativeInstallationPath() - raw, err := os.ReadFile(path) - if err != nil { + file := filepath.Join(root, "daemon", "installation.json") + first, _ := os.ReadFile(file) + if err := runInstall(rc, args); err != nil { + t.Fatalf("compatible rerun: %v", err) + } + again, _ := os.ReadFile(file) + if !bytes.Equal(first, again) { + t.Fatal("reuse rewrote settings") + } + if err := runInstall(rc, append(args, "--harness", "claude")); err != nil { t.Fatal(err) } var installed nativeInstallation - if json.Unmarshal(raw, &installed) != nil || installed.Environment != environment || installed.Workspace != workspace { - t.Fatal("installation did not persist its configuration") + if err := readNativeJSON(file, &installed); err != nil { + t.Fatal(err) + } + if strings.Join(installed.Harnesses, ",") != "claude,codex" { + t.Fatal("lost or failed to add Harness") } + if strings.Contains(rc.stdout.(*bytes.Buffer).String(), "private-test-credential") { + t.Fatal("credential leaked") + } + if err := runInstall(rc, append(args, "--workspace", t.TempDir())); err == nil { + t.Fatal("replaced connection settings") + } + installed.Version = "old-version" + raw, _ := json.Marshal(installed) + _ = os.WriteFile(file, raw, 0600) if err := runInstall(rc, args); err == nil { - t.Fatal("silently replaced existing installation") + t.Fatal("accepted historical installation") } - again, _ := os.ReadFile(path) - if !bytes.Equal(raw, again) { - t.Fatal("reinstall changed existing data") + if err := runStart(rc, []string{"--foreground"}); err == nil { + t.Fatal("started historical installation") } - if _, err := os.Stat(key); err != nil { - t.Fatal("credential file removed") + if got, _ := os.ReadFile(file); !bytes.Equal(got, raw) { + t.Fatal("modified historical state") } - installed.Version = "unsupported-old-version" - invalid, _ := json.Marshal(installed) - if err := os.WriteFile(path, invalid, 0600); err != nil { +} + +func TestNativeInstallationFailureAndRecoveryPreserveExisting(t *testing.T) { + rc, args, root, bundle := nativeInstallFixture(t) + if err := runInstall(rc, args); err != nil { t.Fatal(err) } - if err := runStart(rc, []string{"--foreground"}); err == nil { - t.Fatal("old installation accepted") + file := filepath.Join(root, "daemon", "installation.json") + before, _ := os.ReadFile(file) + probeNativeInstallation = func(string, []string) error { return errors.New("synthetic unavailable") } + if err := runInstall(rc, append(args, "--harness", "claude")); err == nil { + t.Fatal("published failed installation") + } + if after, _ := os.ReadFile(file); !bytes.Equal(before, after) { + t.Fatal("failed addition changed settings") + } + probeNativeInstallation = func(string, []string) error { return nil } + if err := runInstall(rc, append(args, "--harness", "claude")); err != nil { + t.Fatalf("could not reuse complete unpublished component: %v", err) + } + // Source corruption cannot affect an existing component, and cannot be used + // to repair a modified installed one silently. + _ = os.WriteFile(filepath.Join(nativeComponentRoot(root, "codex"), "program"), []byte("changed"), 0700) + if err := runInstall(rc, args); err == nil { + t.Fatal("silently repaired modified executable") + } + if got, _ := os.ReadFile(filepath.Join(nativeComponentRoot(root, "codex"), "program")); string(got) != "changed" { + t.Fatal("overwrote installed data") + } + _ = bundle +} + +func TestNativeInstallationMissingInputAndSecrets(t *testing.T) { + rc, _, root, _ := nativeInstallFixture(t) + for _, args := range [][]string{{"--non-interactive"}, {"--non-interactive", "--remote", "ws://user:secret@host/x"}, {"--interactive=secret"}, {"--token", "private-test-credential"}} { + err := runInstall(rc, args) + if err == nil { + t.Fatal("accepted incomplete input") + } + if strings.Contains(err.Error(), "secret") || strings.Contains(err.Error(), "private-test-credential") { + t.Fatal("echoed sensitive argument") + } + } + if _, err := os.Stat(filepath.Join(root, "daemon", "installation.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("incomplete input wrote installation") + } +} + +func TestNativeInstallationInteractiveMultipleHarnesses(t *testing.T) { + rc, args, root, bundle := nativeInstallFixture(t) + var o nativeInstallOptions + // Prompt and parameter paths feed the same installer options. + o.Directory = root + o.Bundle = bundle + // Existing connection fields need no second interactive input. + for i := 0; i < len(args)-1; i++ { + switch args[i] { + case "--remote": + o.Remote = args[i+1] + case "--environment-id": + o.Environment = args[i+1] + case "--workspace": + o.Workspace = args[i+1] + case "--credential-file": + o.Credential = args[i+1] + } + } + input := "codex,claude\n\n\n\n\n" + if err := promptNativeInstall(strings.NewReader(input), rc.stdout, &o); err != nil { + t.Fatal(err) + } + if err := runInstall(rc, append(args, "--harness", o.Harness)); err != nil { + t.Fatal(err) + } + var c nativeInstallation + _ = readNativeJSON(filepath.Join(root, "daemon", "installation.json"), &c) + if len(c.Harnesses) != 2 { + t.Fatal("multi-selection not installed") + } + if err := promptNativeInstall(strings.NewReader(""), rc.stdout, &nativeInstallOptions{}); err == nil { + t.Fatal("EOF accepted") + } +} + +func TestNativeInstallationLockAndManifestContainment(t *testing.T) { + rc, args, root, bundle := nativeInstallFixture(t) + _, unlock, err := lockNativeInstallation(root) + if err != nil { + t.Fatal(err) + } + if err = runInstall(rc, args); err == nil { + t.Fatal("concurrent installation accepted") + } + unlock() + var b nativeBundle + _ = readNativeJSON(filepath.Join(bundle, "bundle.json"), &b) + c := b.Components["codex"] + c.Files["../escaped"] = c.Files["program"] + b.Components["codex"] = c + raw, _ := json.Marshal(b) + _ = os.WriteFile(filepath.Join(bundle, "bundle.json"), raw, 0600) + if err = runInstall(rc, args); err == nil { + t.Fatal("accepted manifest traversal") + } + if _, err = os.Stat(filepath.Join(root, "components")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("manifest validation happened after mutation") + } +} + +func TestNativeInstallationPlatformMismatchAndUnsupportedSelection(t *testing.T) { + rc, args, _, bundle := nativeInstallFixture(t) + var b nativeBundle + _ = readNativeJSON(filepath.Join(bundle, "bundle.json"), &b) + b.OS = "other" + raw, _ := json.Marshal(b) + _ = os.WriteFile(filepath.Join(bundle, "bundle.json"), raw, 0600) + if err := runInstall(rc, args); err == nil { + t.Fatal("foreign bundle accepted") + } + if _, err := selectedNativeHarnesses("anything"); err == nil { + t.Fatal("unknown Harness accepted") + } + if runtime.GOOS == "windows" { + if _, err := selectedNativeHarnesses("minimax"); err == nil { + t.Fatal("unsupported Windows MiniMax accepted") + } } } diff --git a/apps/parsar-daemon/internal/cli/root.go b/apps/parsar-daemon/internal/cli/root.go index 441da1bc7..9a47b0f84 100644 --- a/apps/parsar-daemon/internal/cli/root.go +++ b/apps/parsar-daemon/internal/cli/root.go @@ -22,18 +22,19 @@ type command struct { // runContext bundles the streams a command writes to. Tests inject // buffers; production uses the OS streams. type runContext struct { + stdin io.Reader stdout io.Writer stderr io.Writer } func defaultRunContext() *runContext { - return &runContext{stdout: os.Stdout, stderr: os.Stderr} + return &runContext{stdin: os.Stdin, stdout: os.Stdout, stderr: os.Stderr} } // commands lists subcommands in --help render order: the user's // likely flow connect → status → stop / logs → logout. var commands = []command{ - {name: "install", summary: "Configure a native self-hosted daemon", run: runInstall}, + {name: "install", summary: "Install a native daemon and selected Harnesses", run: runInstall}, {name: "start", summary: "Start the installed native daemon", run: runStart}, {name: "resume", summary: "Wake one planned hosted suspension", run: runResume}, {name: "runtime-mcp-exec", summary: "Execute an installed MCP server", run: runRuntimeMCP}, @@ -52,6 +53,7 @@ func Execute(argv []string) error { } func execute(ctx *runContext, argv []string) error { + useInstalledNativeHome() if err := validateRuntimeConfiguration(); err != nil { return err } From d9df2e135701043b0e3c546618a1976b1d597554 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:36:05 +0000 Subject: [PATCH 04/25] build: reuse verified MiniMax tool archive cache --- scripts/build-mcode-harness.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/build-mcode-harness.sh b/scripts/build-mcode-harness.sh index 32ea55c3f..d6e9e1a40 100644 --- a/scripts/build-mcode-harness.sh +++ b/scripts/build-mcode-harness.sh @@ -20,6 +20,12 @@ if [[ "$(git -C "$source" rev-parse HEAD)" != "$revision" ]]; then fi mkdir "$context/upstream" git -C "$source" archive "$revision" | tar -x -C "$context/upstream" +# Upstream validates the cached archive and embedded tool hashes before use. +# Preserve this single optional download cache across fresh source exports. +if [[ -f "$source/.cache/artifacts/code-0.3.11.tgz" ]]; then + mkdir -p "$context/upstream/.cache/artifacts" + cp "$source/.cache/artifacts/code-0.3.11.tgz" "$context/upstream/.cache/artifacts/" +fi printf '%s\n' "$revision" > "$context/upstream/.oac-source-revision" cp "$package/"*.mjs "$package/"*.ts "$package/"*.json "$context/" test "$(node "$native/cli.js" --version)" = 0.4.12 From 29dabf1d979e57eba511da574123080f299df754 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:40:31 +0000 Subject: [PATCH 05/25] refactor(installer): keep native activation and checks in Harness adapters --- .../internal/agent/claudesdk/installation.go | 23 +++++ .../internal/agent/codex/installation.go | 30 +++++++ .../internal/agent/installation.go | 11 +++ .../internal/agent/installroot/probe.go | 35 ++++++++ .../internal/agent/mcode/installation.go | 26 ++++++ .../internal/cli/native_bundle.go | 15 +++- .../internal/cli/native_harness.go | 87 +++++-------------- 7 files changed, 156 insertions(+), 71 deletions(-) create mode 100644 apps/parsar-daemon/internal/agent/claudesdk/installation.go create mode 100644 apps/parsar-daemon/internal/agent/codex/installation.go create mode 100644 apps/parsar-daemon/internal/agent/installation.go create mode 100644 apps/parsar-daemon/internal/agent/installroot/probe.go create mode 100644 apps/parsar-daemon/internal/agent/mcode/installation.go diff --git a/apps/parsar-daemon/internal/agent/claudesdk/installation.go b/apps/parsar-daemon/internal/agent/claudesdk/installation.go new file mode 100644 index 000000000..f1f7777ee --- /dev/null +++ b/apps/parsar-daemon/internal/agent/claudesdk/installation.go @@ -0,0 +1,23 @@ +package claudesdk + +import ( + "context" + "fmt" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "path/filepath" + "runtime" +) + +func Installation() agent.Installation { + return agent.Installation{Version: "0.3.269", Supported: func() bool { return runtime.GOOS == "linux" || runtime.GOOS == "darwin" || runtime.GOOS == "windows" }, + Environment: func(dir, node string) map[string]string { + return map[string]string{"OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT": filepath.Join(dir, "dist", "main.js"), "OAC_RUNTIME_CLAUDE_SDK_NODE": node} + }, + Check: func(dir, node string, env []string) error { + got, err := CheckRuntime(context.Background(), Config{Node: node, Entrypoint: filepath.Join(dir, "dist", "main.js"), Env: env}) + if err != nil || got.SDK != "0.3.269" || !got.SupportsLocalRuntime() { + return fmt.Errorf("Claude installation is incompatible; Windows requires Git Bash") + } + return nil + }} +} diff --git a/apps/parsar-daemon/internal/agent/codex/installation.go b/apps/parsar-daemon/internal/agent/codex/installation.go new file mode 100644 index 000000000..478647966 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/installation.go @@ -0,0 +1,30 @@ +package codex + +import ( + "fmt" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/installroot" + "path/filepath" + "runtime" +) + +func Installation() agent.Installation { + binary := func(dir string) string { + name := "codex" + if runtime.GOOS == "windows" { + name += ".exe" + } + return filepath.Join(dir, "bin", name) + } + return agent.Installation{Version: "0.153.4", Supported: func() bool { return runtime.GOOS == "linux" || runtime.GOOS == "darwin" || runtime.GOOS == "windows" }, + Environment: func(dir, node string) map[string]string { + return map[string]string{"OAC_RUNTIME_CODEX_BIN": binary(dir)} + }, + Check: func(dir, node string, env []string) error { + got, err := installroot.Probe(binary(dir), []string{"--version"}, env, dir) + if err != nil || got != "codex-cli 0.153.4" { + return fmt.Errorf("Codex installation is incompatible") + } + return nil + }} +} diff --git a/apps/parsar-daemon/internal/agent/installation.go b/apps/parsar-daemon/internal/agent/installation.go new file mode 100644 index 000000000..aa5941f8d --- /dev/null +++ b/apps/parsar-daemon/internal/agent/installation.go @@ -0,0 +1,11 @@ +package agent + +// Installation is an optional adapter-owned native distribution contract. +// It supplies activation paths and readiness checks, never execution or model +// configuration. Runtime owns copying, checksums, publication and installation locks. +type Installation struct { + Version string + Supported func() bool + Environment func(directory, node string) map[string]string + Check func(directory, node string, environment []string) error +} diff --git a/apps/parsar-daemon/internal/agent/installroot/probe.go b/apps/parsar-daemon/internal/agent/installroot/probe.go new file mode 100644 index 000000000..bbf7c8db1 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/installroot/probe.go @@ -0,0 +1,35 @@ +package installroot + +import ( + "context" + "errors" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + "io" + "strings" + "time" +) + +// Native diagnostics are deliberately discarded: dependencies may echo their +// environment. Readiness is separate from model credentials and a live Turn. +func Probe(binary string, args, env []string, dir string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) + defer cancel() + p, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: args, Env: env, Dir: dir, OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond}) + if err != nil { + return "", errors.New("native component failed to start") + } + defer p.Cancel() + done := make(chan struct{}) + go func() { _, _ = io.Copy(io.Discard, p.Stderr); close(done) }() + raw, err := io.ReadAll(io.LimitReader(p.Stdout, 64*1024+1)) + if err != nil || len(raw) > 64*1024 { + p.Cancel() + } + _, _ = io.Copy(io.Discard, p.Stdout) + <-done + waitErr := p.Wait() + if err != nil || waitErr != nil || ctx.Err() != nil || len(raw) > 64*1024 { + return "", errors.New("native component compatibility check failed") + } + return strings.TrimSpace(string(raw)), nil +} diff --git a/apps/parsar-daemon/internal/agent/mcode/installation.go b/apps/parsar-daemon/internal/agent/mcode/installation.go new file mode 100644 index 000000000..3599dc295 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/installation.go @@ -0,0 +1,26 @@ +package mcode + +import ( + "fmt" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/installroot" + "path/filepath" + "runtime" +) + +func Installation() agent.Installation { + return agent.Installation{Version: SupportedVersion, Supported: func() bool { return runtime.GOOS == "linux" || runtime.GOOS == "darwin" }, + Environment: func(dir, node string) map[string]string { + return map[string]string{"OAC_RUNTIME_MCODE_BIN": filepath.Join(dir, "native", "cli.js"), "OAC_RUNTIME_MCODE_NODE": node, "OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE": filepath.Join(dir, "launch.mjs"), "OAC_RUNTIME_MCODE_AGENTS_API": "1"} + }, + Check: func(dir, node string, env []string) error { + got, err := installroot.Probe(node, []string{filepath.Join(dir, "native", "cli.js"), "--version"}, env, dir) + if err != nil || got != SupportedVersion { + return fmt.Errorf("MiniMax installation is incompatible") + } + if _, err = installroot.Probe(node, []string{filepath.Join(dir, "check.mjs")}, env, dir); err != nil { + return fmt.Errorf("MiniMax dependencies are unavailable; Bash is required") + } + return nil + }} +} diff --git a/apps/parsar-daemon/internal/cli/native_bundle.go b/apps/parsar-daemon/internal/cli/native_bundle.go index e4baf6d44..95c0eaaeb 100644 --- a/apps/parsar-daemon/internal/cli/native_bundle.go +++ b/apps/parsar-daemon/internal/cli/native_bundle.go @@ -33,7 +33,13 @@ type nativeFile struct { Executable bool `json:"executable"` } -var nativePins = map[string]string{"node": "22.22.0", "codex": "0.153.4", "claude": "0.3.269", "minimax": "0.4.12"} +var nativePins = func() map[string]string { + out := map[string]string{"node": "22.22.0"} + for name, spec := range nativeHarnesses { + out[name] = spec.Version + } + return out +}() func readNativeJSON(file string, value any) error { f, err := os.Open(file) @@ -189,11 +195,12 @@ func selectedNativeHarnesses(value string) ([]string, error) { var out []string for _, name := range strings.Split(value, ",") { name = strings.TrimSpace(name) - if name != "codex" && name != "claude" && name != "minimax" { + spec, ok := nativeHarnesses[name] + if !ok { return nil, errors.New("install: --harness requires codex,claude,minimax (comma-separated)") } - if name == "minimax" && runtime.GOOS == "windows" { - return nil, errors.New("install: MiniMax Code adapter is unsupported on Windows; select Codex or Claude Code") + if !spec.Supported() { + return nil, fmt.Errorf("install: %s adapter is unsupported on %s; select a supported Harness", name, runtime.GOOS) } if !slices.Contains(out, name) { out = append(out, name) diff --git a/apps/parsar-daemon/internal/cli/native_harness.go b/apps/parsar-daemon/internal/cli/native_harness.go index 23144e643..4e94bcfea 100644 --- a/apps/parsar-daemon/internal/cli/native_harness.go +++ b/apps/parsar-daemon/internal/cli/native_harness.go @@ -1,18 +1,19 @@ package cli import ( - "context" "errors" "fmt" - "io" + "os" "path/filepath" "runtime" "strings" - "time" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudesdk" - "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/clirunner" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/codex" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/installroot" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/mcode" ) func nativeExe(name string) string { @@ -34,19 +35,11 @@ func nativeHarnessEnvironment(root string, selected []string) map[string]string values := map[string]string{"PATH": filepath.Dir(node) + string(os.PathListSeparator) + os.Getenv("PATH")} for _, name := range selected { dir := nativeComponentRoot(root, name) - switch name { - case "codex": - values["OAC_RUNTIME_CODEX_BIN"] = filepath.Join(dir, "bin", nativeExe("codex")) - case "claude": - values[claudeSDKEntrypointEnv] = filepath.Join(dir, "dist", "main.js") - values[claudeSDKNodeEnv] = node - case "minimax": - values["OAC_RUNTIME_MCODE_BIN"] = filepath.Join(dir, "native", "cli.js") - values["OAC_RUNTIME_MCODE_NODE"] = node - values["OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE"] = filepath.Join(dir, "launch.mjs") - values["OAC_RUNTIME_MCODE_AGENTS_API"] = "1" - values["PATH"] = filepath.Join(dir, "bin") + string(os.PathListSeparator) + values["PATH"] + values["PATH"] = filepath.Join(dir, "bin") + string(os.PathListSeparator) + values["PATH"] + for key, value := range nativeHarnesses[name].Environment(dir, node) { + values[key] = value } + } return values } @@ -73,66 +66,26 @@ func withNativeEnv(values map[string]string) []string { return env } -// Native diagnostics are deliberately discarded: dependencies may echo their -// environment. Readiness is separate from model credentials and a live Turn. -func nativeProbeCommand(binary string, args, env []string, dir string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second) - defer cancel() - p, err := clirunner.Start(clirunner.StartOptions{Parent: ctx, Binary: binary, Args: args, Env: env, Dir: dir, OwnProcessGroup: true, KillTimeout: 250 * time.Millisecond}) - if err != nil { - return "", errors.New("native component failed to start") - } - defer p.Cancel() - done := make(chan struct{}) - go func() { _, _ = io.Copy(io.Discard, p.Stderr); close(done) }() - raw, err := io.ReadAll(io.LimitReader(p.Stdout, 64*1024+1)) - if err != nil || len(raw) > 64*1024 { - p.Cancel() - } - _, _ = io.Copy(io.Discard, p.Stdout) - <-done - waitErr := p.Wait() - if err != nil || waitErr != nil || ctx.Err() != nil || len(raw) > 64*1024 { - return "", errors.New("native component compatibility check failed") - } - return strings.TrimSpace(string(raw)), nil +// Installation registration is local to Runtime; Core does not see native paths. +var nativeHarnesses = map[string]agent.Installation{ + "codex": codex.Installation(), "claude": claudesdk.Installation(), "minimax": mcode.Installation(), } - var probeNativeInstallation = checkNativeInstallation func checkNativeInstallation(root string, selected []string) error { - values := nativeHarnessEnvironment(root, selected) - env := withNativeEnv(values) + env := withNativeEnv(nativeHarnessEnvironment(root, selected)) node := nativeNode(root) - version, err := nativeProbeCommand(node, []string{"--version"}, env, root) + version, err := installroot.Probe(node, []string{"--version"}, env, root) if err != nil || version != "v"+nativePins["node"] { - return errors.New("install: bundled Node is unavailable or incompatible; use the distribution for this operating system and architecture") + return errors.New("install: bundled Node is unavailable or incompatible; use the matching native distribution") } for _, name := range selected { - dir := nativeComponentRoot(root, name) - switch name { - case "codex": - version, err = nativeProbeCommand(values["OAC_RUNTIME_CODEX_BIN"], []string{"--version"}, env, root) - if err == nil && version != "codex-cli "+nativePins[name] { - err = errors.New("version mismatch") - } - case "claude": - var info claudesdk.RuntimeInfo - info, err = claudesdk.CheckRuntime(context.Background(), claudesdk.Config{Node: node, Entrypoint: values[claudeSDKEntrypointEnv], Env: env}) - if err == nil && (info.SDK != nativePins[name] || !info.SupportsLocalRuntime()) { - err = errors.New("adapter contract mismatch") - } - case "minimax": - version, err = nativeProbeCommand(node, []string{filepath.Join(dir, "native", "cli.js"), "--version"}, env, root) - if err == nil && version != nativePins[name] { - err = errors.New("version mismatch") - } - if err == nil { - _, err = nativeProbeCommand(node, []string{filepath.Join(dir, "check.mjs")}, env, root) - } + spec, ok := nativeHarnesses[name] + if !ok || !spec.Supported() { + return fmt.Errorf("install: %s is unsupported on this platform", name) } - if err != nil { - return fmt.Errorf("install: %s compatibility check failed; verify OS dependencies (Claude on Windows needs Git Bash; MiniMax needs Bash) and use a matching release; no files were replaced", name) + if err = spec.Check(nativeComponentRoot(root, name), node, env); err != nil { + return fmt.Errorf("install: %s; no installed files were replaced", err) } } return nil From b549d02046c494fbedbcc25ef60e83504abdf544 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Mon, 28 Sep 2026 18:39:06 +0000 Subject: [PATCH 06/25] Use native Runtime installation in Connect a host --- apps/web/DESIGN.md | 35 ++++--- apps/web/PRODUCT.md | 34 ++++--- apps/web/e2e/diagnostics.spec.ts | 6 +- apps/web/e2e/fixture-console.mjs | 6 +- apps/web/e2e/monitoring.spec.ts | 39 ++++---- .../features/fleet/use-sandbox-fleet.test.tsx | 2 +- .../features/sandbox/console-config.test.ts | 13 +-- .../src/features/sandbox/console-config.ts | 8 +- .../sandbox/enrollment-command.test.ts | 9 +- .../features/sandbox/enrollment-command.ts | 13 --- .../sandbox/sandbox-page-ownership.test.tsx | 2 +- .../sessions/ExecutorCredentialsSection.tsx | 18 ++-- .../sessions/ExecutorInstallPanel.tsx | 95 +++++-------------- .../web/src/features/sessions/SessionPage.tsx | 4 +- .../sessions/executor-credential-file.tsx | 28 ++---- .../sessions/executor-install.test.ts | 45 ++++----- .../src/features/sessions/executor-install.ts | 44 ++++----- apps/web/src/i18n/locales/en/sessions.ts | 45 +++------ apps/web/src/i18n/locales/zh-CN/sessions.ts | 45 +++------ 19 files changed, 164 insertions(+), 327 deletions(-) diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index ef8ab9f2f..b91a0f50b 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -495,16 +495,16 @@ Every resource list, the Session log and the project list share one grammar: page body to the next failed Turn and focuses it. - An active project's page ends its keys with a **How to call** section (see Dialogs) before its write operations. -- A self-hosted Session's **Executor credentials** section ends with a **Connect - a host** card when the console serves the self-hosted installer: a 13px/600 - title with a help tip (what revoke and rotate do to the host, how to reconnect, - how to remove the Runtime), one Graphite line with the steps (run the command, - paste a credential at its hidden prompt, safe to rerun), the command in a - Margin Gray Terminal block with an icon copy button, and the host requirements - on one dot-separated line. The command wraps rather than scrolls. Without a - public address, with a loopback one, or with a Session address that is not - `wss://`, one Graphite note takes the command's place; an archived project - keeps the command and says the host still needs a credential. +- A self-hosted Session's **Executor credentials** section ends with **Connect + a host**, independent of console installer assets. Keep native distribution + guidance, a Linux/macOS or PowerShell selector and one copyable command here. + The command pre-fills the Session remote URL, Environment ID and workspace; + interactive installation asks for a privately saved credential file and local + installation choices. Link the native guide instead of inventing a release + download URL. Requirements and reconnection details belong in the title help. + Installation does not start the daemon; connection status comes only from Core. + Missing connection facts show a note instead of a command. Loopback ws is valid + for native local connections. Archived projects require an existing credential. ### Dialogs Dialogs are 448px Paper cards with 8px corners, a 48px header and a 52px footer @@ -526,15 +526,12 @@ request runs. red underneath. The issued key appears in a read-only field with a copy button, under a notice that it is shown once; only "I've saved this key" dismisses it. Closing the dialog moves the key into a pending notice card on the page. -- **Executor credential dialog** (640px): the shown-once notice, then one line - saying what to do in order. With Connect a host available, the install - command's Terminal block comes first, so it is copied and run before the - credential is pasted and Done pressed. Then the credential as one line of JSON - (wrapped, never pretty-printed), Copy credential (primary: it is pasted at the - installer's hidden prompt) and Download credential file (outline), with a - Graphite hint for automation (`chmod 600`, `--credential-file`). Done is - outline and forgets the credential; closing the dialog keeps it in a pending - card, which points to the Connect a host command below. +- **Executor credential dialog** (640px): the shown-once notice, then a prompt + to save the JSON privately before Done. Download credential file is primary; + Copy credential is secondary. Installation commands are not repeated here. + Done forgets the credential; closing preserves it in the pending card. The + native installer reads the unchanged JSON file through its interactive prompt + or `--credential-file`; tokens never enter command arguments. - **Add node**: the sandbox limits first, then the one-time command in a Terminal block (expiry countdown and Copy command in its header), the three progress steps, and, once the installer's minute passes, an amber card with the reason diff --git a/apps/web/PRODUCT.md b/apps/web/PRODUCT.md index af4851e24..7c4e19b5a 100644 --- a/apps/web/PRODUCT.md +++ b/apps/web/PRODUCT.md @@ -54,9 +54,8 @@ workbench. - The Core key is not an Agents API identity and cannot call `/v1`. An administrator who wants to call the Agents API issues a project API key like any other caller. - `/console/config` reports the node installer (`node_installer`, - `node_installer_sha256`) and the self-hosted executor installer - (`self_hosted_installer`, `self_hosted_installer_sha256`); an installer is - offered only with a 64-hex digest. It also lists the providers it has node files + `node_installer_sha256`), offered only with a 64-hex digest. Native self-hosted + installation does not depend on this endpoint. It also lists the providers it has node files for (`node_artifacts`); without the deployment's provider, Add node says so and issues no command. Signing in grants administration, so sandbox administration is available unless the console explicitly reports @@ -204,8 +203,8 @@ workbench. executor needs, with the deployment's Core key. A Session page whose environment is self-hosted has an Executor credentials section: issue a credential (shown once as one line of JSON, to copy or download, never stored), rotate it (the - old one stops working immediately) or revoke it (the executor disconnects and - won't retry; its container keeps running until stopped). The file lets one + old one stops working immediately) or revoke it (the executor disconnects; + installed Runtime state and workspace contents are not deleted). The file lets one executor connect for that environment only; it cannot call the Agents API. - **Default provider observations.** Each configured harness offers Usage details for Core's last successful use and any newer classified provider error. Missing @@ -217,19 +216,18 @@ workbench. share one five-second read while visible. Never connected, connected, disconnected, bound credential revoked, and unknown are distinct; a recent heartbeat alone never proves connectivity. Only a fresh connected read marks - Run on host done. Stale or failed reads withhold completion. Recovery rotates - the bound key and reruns the same command rather than suggesting a new key. -- **Connect a host.** When the console serves the self-hosted installer, the - section also gives the command that installs the executor on the - administrator's host from Core's `public_url` (checksum-verified, no secret in - it; the installer asks for the credential at a hidden prompt, or reads - `--credential-file`). The same command is safe to rerun. Revoking or rotating - disconnects the host's executor; reconnecting takes that same credential, - rotated (Restore on a revoked row rotates and restores it), and the same command, because a - newly issued credential does not reconnect an environment that already - connected. Without a `public_url`, with a loopback one, or when the Session's - `remote_url` is not `wss://`, the section says why instead of showing a - command. + Host connected. Stale or failed reads withhold completion. Recovery rotates + the bound key, replaces the host credential file and starts the installed daemon. +- **Connect a host.** Native Linux/macOS and PowerShell installation instructions + depend on the Session's remote URL, Environment ID and workspace, not console + installer flags or served Python assets. Users privately save the issued JSON, + obtain a matching native distribution through the linked guide, and run the + interactive install command from its root. Installation asks for the credential + file path and does not automatically start the daemon. Run the installed binary + in the installation's bin directory with `start`. No model readiness is implied. + Credential rotation requires replacing the configured file and starting that + same installed daemon. A new key cannot reconnect an already-bound Environment. + Accept wss or loopback ws; withhold commands for missing or invalid facts. - **Typed write errors.** Known Core codes use shared bilingual copy and safe typed details. Exact Core field paths attach definite refusals to the relevant input. Unknown codes retain Core's fallback message; uncertain write outcomes diff --git a/apps/web/e2e/diagnostics.spec.ts b/apps/web/e2e/diagnostics.spec.ts index 63948f790..962380260 100644 --- a/apps/web/e2e/diagnostics.spec.ts +++ b/apps/web/e2e/diagnostics.spec.ts @@ -74,7 +74,7 @@ test("connection reads govern host completion and bound-key guidance", async ({ await openConsole(page, request, "sessions"); await page.getByRole("row").filter({ hasText: "Self-hosted" }).first().getByRole("button", { name: /^Open Session / }).click(); await expect(page.getByRole("region", { name: "Host connection", exact: true })).toContainText("Disconnected"); - await expect(page.getByText("Run on host · Done", { exact: true })).toHaveCount(0); + await expect(page.getByText("Host connected", { exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: "Rotate bound credential", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: /^Rotate credential / })).toHaveCount(0); revoked = true; @@ -84,9 +84,9 @@ test("connection reads govern host completion and bound-key guidance", async ({ revoked = false; connected = true; await expect(page.getByRole("region", { name: "Host connection", exact: true })).toContainText("Connected", { timeout: 10_000 }); - await expect(page.getByText("Run on host · Done", { exact: true })).toBeVisible(); + await expect(page.getByText("Host connected", { exact: true })).toBeVisible(); unavailable = true; await expect(page.getByRole("region", { name: "Host connection", exact: true })).toContainText("Unknown", { timeout: 10_000 }); - await expect(page.getByText("Run on host · Done", { exact: true })).toHaveCount(0); + await expect(page.getByText("Host connected", { exact: true })).toHaveCount(0); expect(await writes(request)).toEqual([]); }); diff --git a/apps/web/e2e/fixture-console.mjs b/apps/web/e2e/fixture-console.mjs index b79218fd6..3051a667d 100644 --- a/apps/web/e2e/fixture-console.mjs +++ b/apps/web/e2e/fixture-console.mjs @@ -36,7 +36,6 @@ const LOCAL_URL = "http://127.0.0.1:8091"; const OLD_URL = "https://core-old.example.com"; const publicUrl = () => (state.installation === "local" ? LOCAL_URL : PUBLIC_URL); /** The digest the console reports for its self-hosted executor installer; the same value as in monitoring.spec.ts. */ -const SELF_HOSTED_INSTALLER_SHA256 = "5e1f".repeat(16); /** Core reports one installation ID, a canonical UUID, in the installation and the deployment. */ const INSTALLATION_ID = "7f3c2a90-5b1e-4c2d-9e3f-0a1b2c3d4e5f"; @@ -201,14 +200,11 @@ async function consoleRoute(request, response, url) { return send(response, 200, { mode: "login" }, { "set-cookie": `${SESSION_COOKIE.split("=")[0]}=; Path=/; Max-Age=0` }); } if (url.pathname === "/console/config") { - // As Core's console: signing in grants administration, so it reports only its installers, - // both served from the node installation payload, and without that payload neither, and the - // providers whose node files that payload holds (always a list, empty without it). + // Console assets cover node enrollment only; native self-hosted installation is independent. const served = state.installers; return send(response, 200, { node_installer: served, node_installer_sha256: served ? "a".repeat(64) : "", node_artifacts: served ? state.nodeArtifacts : [], - self_hosted_installer: served, self_hosted_installer_sha256: served ? SELF_HOSTED_INSTALLER_SHA256 : "", }); } return error(response, 404, "Not found."); diff --git a/apps/web/e2e/monitoring.spec.ts b/apps/web/e2e/monitoring.spec.ts index 4e73326f3..8f4c70302 100644 --- a/apps/web/e2e/monitoring.spec.ts +++ b/apps/web/e2e/monitoring.spec.ts @@ -6,7 +6,6 @@ import { archiveProject, expectManagementBoundary, openConsole } from "./console test.afterEach(async ({ request }) => expectManagementBoundary(request)); /** The self-hosted installer digest the fixture console reports. */ -const SELF_HOSTED_INSTALLER_SHA256 = "5e1f".repeat(16); test("shows the deployment's health on Overview and each monitor page", async ({ page, request }) => { await openConsole(page, request, "overview"); @@ -81,22 +80,21 @@ test("shows a self-hosted Session's install command, issues its credential once, // Connect a host: the exact install command, which carries no secret. const install = section.getByRole("region", { name: "Connect a host" }); - await expect(install.getByLabel("Executor install command").locator("pre")).toHaveText(`(umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT -curl -fsS --max-time 30 --max-filesize 1048576 'https://core.example.com/node-install/self-hosted-install.pyz' -o "$d/install.pyz" && -printf '%s %s\\n' '${SELF_HOSTED_INSTALLER_SHA256}' "$d/install.pyz" | sha256sum -c --status && -python3 "$d/install.pyz" --source-url 'https://core.example.com' --environment-id '${environmentId}' --remote 'wss://core.example.com/api/v1/agent-daemon/ws')`); - await expect(install.getByRole("list", { name: "Host requirements" })).toContainText("HTTPS access to https://core.example.com"); + await expect(install.getByLabel("Executor install command").locator("pre")).toHaveText(`./oac-daemon install --interactive --remote 'wss://core.example.com/api/v1/agent-daemon/ws' --environment-id '${environmentId}' --workspace '/srv/work'`); + await expect(install.getByRole("link")).toHaveAttribute("href", /docs\/self-hosted-native.md$/); + await install.getByRole("combobox", { name: "Host platform" }).click(); + await page.getByRole("option", { name: "Windows · PowerShell" }).click(); + await expect(install.locator("pre")).toContainText(".\\oac-daemon.exe install --interactive"); + await install.screenshot({ path: test.info().outputPath("native-host.png") }); await section.getByRole("button", { name: "Issue credential" }).click(); const issued = page.getByRole("dialog", { name: "Executor credential" }); await expect(issued).toContainText("shown only once"); - // The command comes with it, so it can be run before the credential is pasted and the dialog closed. - await expect(issued).toContainText("Run this command on the host first, then paste the credential below at its prompt and press Done."); - await expect(issued.getByLabel("Executor install command")).toContainText("self-hosted-install.pyz"); - // One line of JSON to paste at the installer's hidden prompt; the file is for automation. - await expect(issued.getByRole("button", { name: "Copy credential" })).toHaveClass(/\bprimary\b/); + await expect(issued).toContainText("Download and privately save the credential file before choosing Done."); + await expect(issued.getByLabel("Executor install command")).toHaveCount(0); + await expect(issued.getByRole("button", { name: "Download credential file" })).toHaveClass(/\bprimary\b/); await expect(issued.getByLabel("Executor credential file")).toHaveText(new RegExp(`^\\{"key_id":"[0-9a-f-]{36}","environment_id":"${environmentId}","executor_token":"exec_fixture_\\d+"\\}$`)); - await expect(issued).toContainText("run chmod 600 and add --credential-file to the python3 line; the path must not go through a symlink"); + await expect(issued).toContainText("Enter its absolute path during interactive installation, or use --credential-file "); const download = page.waitForEvent("download"); await issued.getByRole("button", { name: "Download credential file" }).click(); expect((await download).suggestedFilename()).toMatch(/^executor-credential-[0-9a-f]{8}\.json$/); @@ -122,7 +120,7 @@ python3 "$d/install.pyz" --source-url 'https://core.example.com' --environment-i await credentials.getByRole("button", { name: /^Restore credential / }).click(); const rotation = page.getByRole("dialog", { name: "Restore credential?" }); await expect(rotation).toContainText("generating a new secret for the same credential"); - await expect(rotation).toContainText("rerun the Connect a host command there and paste the new credential at its prompt"); + await expect(rotation).toContainText("Replace the configured credential file on the host"); await rotation.getByRole("button", { name: "Restore" }).click(); const restored = page.getByRole("dialog", { name: "Executor credential" }); await expect(restored.getByLabel("Executor credential file")).toContainText("exec_fixture_2"); @@ -140,25 +138,22 @@ python3 "$d/install.pyz" --source-url 'https://core.example.com' --environment-i await expect(credentials.getByRole("button", { name: /^Rotate credential / })).toHaveCount(0); await expect(credentials.getByRole("button", { name: /^Revoke credential / })).toBeVisible(); // The command stays; the note says the host still needs a credential. - await expect(install).toContainText("It asks for a credential, which this archived project can't issue or rotate"); + await expect(install).toContainText("Installation requires an existing credential file"); }); -test("explains instead of giving the install command when Core's public address is loopback", async ({ page, request }) => { +test("offers the native command for a loopback Core", async ({ page, request }) => { await openConsole(page, request, "sessions", { installation: "local" }); await page.getByRole("row").filter({ hasText: "Self-hosted" }).first().getByRole("button", { name: /^Open Session / }).click(); const install = page.getByRole("region", { name: "Connect a host" }); - await expect(install).toContainText("Core's public address http://127.0.0.1:8091 is reachable only on the Core machine"); - await expect(install.locator("pre")).toHaveCount(0); + await expect(install.locator("pre")).toContainText("ws://127.0.0.1:8091/api/v1/agent-daemon/ws"); }); -test("hides Connect a host when the console does not serve the self-hosted installer", async ({ page, request }) => { - const config = page.waitForResponse((response) => new URL(response.url()).pathname === "/console/config"); +test("offers native installation without console installer assets", async ({ page, request }) => { await openConsole(page, request, "sessions", { installers: "none" }); await page.getByRole("row").filter({ hasText: "Self-hosted" }).first().getByRole("button", { name: /^Open Session / }).click(); const section = page.getByRole("region", { name: "Executor credentials" }); await expect(section).toContainText("No executor credentials yet"); - await config; - await expect(section.getByRole("region", { name: "Connect a host" })).toHaveCount(0); + await expect(section.getByRole("region", { name: "Connect a host" })).toContainText("install --interactive"); await expect(section.getByRole("button", { name: "Issue credential" })).toBeVisible(); }); @@ -186,7 +181,7 @@ test("issues a new executor credential after the unanswered one was rotated from const credentials = section.getByRole("table", { name: "Executor credentials" }); await credentials.getByRole("button", { name: /^Rotate credential / }).click(); // Rotating an active credential disconnects its host until the command is rerun with the new one. - await expect(page.getByRole("dialog", { name: "Rotate credential?" })).toContainText("The host's executor disconnects and won't retry until you rerun the Connect a host command"); + await expect(page.getByRole("dialog", { name: "Rotate credential?" })).toContainText("The host disconnects. Replace its configured credential file"); await page.getByRole("dialog", { name: "Rotate credential?" }).getByRole("button", { name: "Rotate" }).click(); await page.getByRole("dialog", { name: "Executor credential" }).getByRole("button", { name: "Done" }).click(); diff --git a/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx b/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx index d81903d95..928d5c408 100644 --- a/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx +++ b/apps/web/src/features/fleet/use-sandbox-fleet.test.tsx @@ -23,7 +23,7 @@ function Probe() { function render(latest: SandboxDeployment, previous = configured, failed = false) { const cache = new QueryClient({ defaultOptions: { queries: { retry: false } } }); const snapshot: FleetSnapshot = { deployment: previous, nodes: [node("n1")], allocations: [], loadedAt: 1 }; - const config: SandboxConsoleConfig = { sandbox_admin: true, node_installer: false, node_installer_sha256: "", self_hosted_installer: false, self_hosted_installer_sha256: "" }; + const config: SandboxConsoleConfig = { sandbox_admin: true, node_installer: false, node_installer_sha256: "" }; cache.setQueryData(consoleConfigQuery.queryKey, () => config); cache.setQueryData(fleetQuery(false).queryKey, snapshot); cache.setQueryData(sandboxDeploymentQuery.queryKey, latest); diff --git a/apps/web/src/features/sandbox/console-config.test.ts b/apps/web/src/features/sandbox/console-config.test.ts index 37895afdd..d95d292cb 100644 --- a/apps/web/src/features/sandbox/console-config.test.ts +++ b/apps/web/src/features/sandbox/console-config.test.ts @@ -7,24 +7,13 @@ describe("bundled console capabilities", () => { const fetch = vi.fn().mockResolvedValue(new Response(JSON.stringify({ node_installer: true, node_installer_sha256: "a".repeat(64) }))); vi.stubGlobal("fetch", fetch); const controller = new AbortController(); - expect(await sandboxConsoleConfig(controller.signal)).toEqual({ sandbox_admin: true, node_installer: true, node_installer_sha256: "a".repeat(64), self_hosted_installer: false, self_hosted_installer_sha256: "" }); + expect(await sandboxConsoleConfig(controller.signal)).toEqual({ sandbox_admin: true, node_installer: true, node_installer_sha256: "a".repeat(64) }); expect(fetch).toHaveBeenCalledWith("/console/config", { credentials: "include", signal: controller.signal }); }); it.each([{}, { sandbox_admin: "true", node_installer: true }, { sandbox_admin: false, node_installer: true, node_installer_sha256: "bad" }])("does not enable installation without a verified digest %j", async (body) => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(body)))); expect((await sandboxConsoleConfig(new AbortController().signal))?.node_installer).toBe(false); }); - it.each([ - [{ self_hosted_installer: true, self_hosted_installer_sha256: "b".repeat(64) }, true], - [{ self_hosted_installer: false, self_hosted_installer_sha256: "b".repeat(64) }, false], - [{ self_hosted_installer: "true", self_hosted_installer_sha256: "b".repeat(64) }, false], - [{ self_hosted_installer: true }, false], - [{ self_hosted_installer: true, self_hosted_installer_sha256: "B".repeat(64) }, false], - [{ self_hosted_installer: true, self_hosted_installer_sha256: "b".repeat(63) }, false], - ])("offers the self-hosted installer only when enabled with a verified digest %j", async (body, offered) => { - vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(JSON.stringify(body)))); - expect((await sandboxConsoleConfig(new AbortController().signal))?.self_hosted_installer).toBe(offered); - }); it("reports unavailable capability on an absent console endpoint", async () => { vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("Not found", { status: 404 }))); expect(await sandboxConsoleConfig(new AbortController().signal)).toBeNull(); diff --git a/apps/web/src/features/sandbox/console-config.ts b/apps/web/src/features/sandbox/console-config.ts index b90512eb0..ed139bbe5 100644 --- a/apps/web/src/features/sandbox/console-config.ts +++ b/apps/web/src/features/sandbox/console-config.ts @@ -5,9 +5,6 @@ export interface SandboxConsoleConfig { sandbox_admin: boolean; node_installer: boolean; node_installer_sha256: string; - /** The self-hosted executor installer (`/node-install/self-hosted-install.pyz`); false without a verified digest. */ - self_hosted_installer: boolean; - self_hosted_installer_sha256: string; /** * The providers whose node files this console serves. Absent when the console * does not report them (an older console), which blocks nothing; a reported @@ -20,8 +17,7 @@ const SHA256 = /^[a-f0-9]{64}$/; /** * The console's capability flags. Signing in with the Core key grants - * administration, so Core reports only its installers (node and self-hosted - * executor, each with its digest) and the providers it has node files for; + * administration, so Core reports only its node installer and digest and the providers it has node files for; * sandbox administration is available * unless the console says `sandbox_admin: false`. An installer is offered only * with a well-formed SHA-256 digest. @@ -38,8 +34,6 @@ export async function sandboxConsoleConfig(signal: AbortSignal): Promise { const digest = "a".repeat(64); @@ -36,13 +36,6 @@ $s python3 "$d/node-install.pyz" --uninstall --installation-id '7f3c2a90-5b1e-4c expect(nodeLogCommand("7f3c2a90-fixture", "user")).toBe("journalctl --user -u oac-node-7f3c2a90-fixture.service"); expect(nodeLogCommand("a b", "user")).toBe("journalctl --user -u 'oac-node-a b.service'"); }); - it("creates the exact self-hosted executor install command, every value quoted", () => { - const command = selfHostedInstallCommand({ publicUrl: "https://core.example", digest: "b".repeat(64), environmentId: "env'1", remoteUrl: "wss://core.example/api/v1/agent-daemon/ws" }); - expect(command).toBe(`(umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT -curl -fsS --max-time 30 --max-filesize 1048576 'https://core.example/node-install/self-hosted-install.pyz' -o "$d/install.pyz" && -printf '%s %s\\n' '${"b".repeat(64)}' "$d/install.pyz" | sha256sum -c --status && -python3 "$d/install.pyz" --source-url 'https://core.example' --environment-id 'env'\\''1' --remote 'wss://core.example/api/v1/agent-daemon/ws')`); - }); // "as root": a root shell runs the sudo command without sudo; "no sudo": the no-sudo command. it.each(["success", "download failure", "checksum mismatch", "installer failure", "as root", "no sudo"])("executes safely, passes the token only on stdin and cleans private downloads after %s", (scenario) => { const parent = join(homedir(), ".oac", "tests"); diff --git a/apps/web/src/features/sandbox/enrollment-command.ts b/apps/web/src/features/sandbox/enrollment-command.ts index 18486ba7c..9123a7647 100644 --- a/apps/web/src/features/sandbox/enrollment-command.ts +++ b/apps/web/src/features/sandbox/enrollment-command.ts @@ -48,19 +48,6 @@ export function nodeUninstallCommand({ sourceUrl, installationId, scriptDigest, return `${nodeInstaller(sourceUrl, scriptDigest, mode)}${runInstaller(mode)} --uninstall --installation-id ${quote(installationId)}${force ? " --force" : ""})`; } -/** - * Installs a self-hosted executor for one Environment from this Core's - * public address. It carries no secret: the installer asks for the executor - * credential at a hidden prompt (or reads `--credential-file`), and rerunning - * it resumes the same installation. - */ -export function selfHostedInstallCommand({ publicUrl, digest, environmentId, remoteUrl }: { publicUrl: string; digest: string; environmentId: string; remoteUrl: string }): string { - return `(umask 077; d=$(mktemp -d) || exit; trap 'rm -rf "$d"' EXIT -curl -fsS --max-time 30 --max-filesize 1048576 ${quote(publicUrl + "/node-install/self-hosted-install.pyz")} -o "$d/install.pyz" && -printf '%s %s\\n' ${quote(digest)} "$d/install.pyz" | sha256sum -c --status && -python3 "$d/install.pyz" --source-url ${quote(publicUrl)} --environment-id ${quote(environmentId)} --remote ${quote(remoteUrl)})`; -} - /** * The node service's journal. The installer names the unit after the * installation (node_install.py `unit_name`): a system unit in sudo mode, a user diff --git a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx index adb03fd2c..cb869e3d4 100644 --- a/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx +++ b/apps/web/src/features/sandbox/sandbox-page-ownership.test.tsx @@ -21,7 +21,7 @@ function cache(provider: SandboxDeployment["provider"]) { mode: provider === "e2b" ? "direct" : "nodes", reset: null, resources: { allocations: 0, pending: 0 }, suspension: null, rollout: { state: "settled", previous_generation_sandboxes: 0, nodes: null }, }; - const config: SandboxConsoleConfig = { sandbox_admin: true, node_installer: false, node_installer_sha256: "", self_hosted_installer: false, self_hosted_installer_sha256: "" }; + const config: SandboxConsoleConfig = { sandbox_admin: true, node_installer: false, node_installer_sha256: "" }; client.setQueryData(sandboxConsoleConfigQuery.queryKey, () => config); client.setQueryData(sandboxDeploymentQuery.queryKey, deployment); client.setQueryData(sandboxSnapshotQuery.queryKey, { deployment, nodes: [], allocations: [], nodesError: null, readAt: 0 }); diff --git a/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx index 5930adfc7..9b9d29e24 100644 --- a/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx +++ b/apps/web/src/features/sessions/ExecutorCredentialsSection.tsx @@ -68,20 +68,18 @@ function seconds(value: string | null): number | null { * administrator rotates it (issued, secret lost) or issues it again (not * issued). A kept key ID that is listed is never sent again: its row's actions * own it. Rotating a revoked credential restores it with a new secret, which - * is how a host whose credential was revoked reconnects: its installer accepts - * only the same key ID. An archived project's credentials are listed and + * is how a host whose credential was revoked reconnects: Core retains + * the bound key ID. An archived project's credentials are listed and * revoked but neither issued nor rotated. Below the list, Connect a host gives * the command that installs the executor with one of these credentials. */ -export function ExecutorCredentialsSection({ projectId, sessionId, environmentId, remoteUrl }: { projectId: string; sessionId: string; environmentId: string; remoteUrl: string }) { +export function ExecutorCredentialsSection({ projectId, sessionId, environmentId, remoteUrl, workspaceDirectory = "" }: { projectId: string; sessionId: string; environmentId: string; remoteUrl: string; workspaceDirectory?: string }) { const { t, i18n } = useTranslation("sessions"); const locale = i18n.resolvedLanguage; const toast = useToast(); const { byId, refresh: refreshProjects } = useProjects(); const archived = byId.get(projectId)?.status === "archived"; - const install = useExecutorInstall(environmentId, remoteUrl); - // The one-time dialog repeats the command, so it is copied and run before the credential is pasted. - const command = install.kind === "ready" ? install.command : null; + const install = useExecutorInstall(environmentId, remoteUrl, workspaceDirectory); const query = useQuery(executorConnectionQuery(projectId, sessionId, environmentId)); const credentials = query.data?.data ?? null; const connectionStale = failedLast(query) || query.isStale; @@ -296,7 +294,7 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId {archived ?

{t("executor.archived")}

: null} {shown && !shown.open && !shown.done ? (
- +
) : null} @@ -309,7 +307,7 @@ export function ExecutorCredentialsSection({ projectId, sessionId, environmentId onClose={dismissShown} footer={} > - {shown ? : null} + {shown ? : null}

{t(`executor.rotateDialog.${rotation.reason}`, { id: shortId(rotation.keyId) })}

{rotation.reason === "revoked" ? null :

{t("executor.rotateDialog.consequence")}

} - {/* A connected host needs the command again; the text names it only when it is shown here. */} - {rotation.reason === "lost" ? null :

{t(`executor.rotateDialog.${rotation.reason === "revoked" ? "reconnect" : "disconnect"}.${command ? "command" : "installer"}`)}

} + {/* Reconnection uses the installed daemon and the replacement credential file. */} + {rotation.reason === "lost" ? null :

{t(`executor.rotateDialog.${rotation.reason === "revoked" ? "reconnect" : "disconnect"}`)}

} ) : null} diff --git a/apps/web/src/features/sessions/ExecutorInstallPanel.tsx b/apps/web/src/features/sessions/ExecutorInstallPanel.tsx index 344f63bcf..a4a0d56da 100644 --- a/apps/web/src/features/sessions/ExecutorInstallPanel.tsx +++ b/apps/web/src/features/sessions/ExecutorInstallPanel.tsx @@ -1,82 +1,37 @@ -import { useQuery } from "@tanstack/react-query"; import { Check, Copy } from "lucide-react"; -import { useEffect, useId, useRef, type RefObject } from "react"; +import { useEffect, useId, useRef, useState, type RefObject } from "react"; import { useTranslation } from "react-i18next"; - import { HelpTip } from "../../components/console-ui"; -import { installationQuery } from "../../lib/installation"; +import { ConsoleSelect } from "../../components/console-select"; import { useCopy } from "../api-keys/IssuedKey"; -import { sandboxConsoleConfigQuery } from "../sandbox/sandbox-queries"; -import { executorInstall, type ExecutorInstall } from "./executor-install"; - -const DOCKER_SOCKET = "/var/run/docker.sock"; +import { executorInstall, type ExecutorInstall, type HostShell } from "./executor-install"; -/** The install command's state, including the reads it waits for. */ -export type ExecutorInstallRead = ExecutorInstall | { kind: "loading" } | { kind: "failed"; retry: () => void }; - -/** - * Reads what Connect a host needs: the console's self-hosted installer and, - * only when it is offered, Core's public address. - */ -export function useExecutorInstall(environmentId: string, remoteUrl: string): ExecutorInstallRead { - const config = useQuery(sandboxConsoleConfigQuery); - const offered = config.data?.self_hosted_installer === true; - const installation = useQuery({ ...installationQuery, enabled: offered }); - if (!offered) return { kind: "unavailable" }; - if (installation.data === undefined) { - return installation.isError && !installation.isFetching ? { kind: "failed", retry: () => void installation.refetch() } : { kind: "loading" }; - } - return executorInstall({ config: config.data, publicUrl: installation.data.public_url, localOnly: installation.data.local_only, environmentId, remoteUrl }); +export function useExecutorInstall(environmentId: string, remoteUrl: string, workspaceDirectory: string): ExecutorInstall { + return executorInstall({ environmentId, remoteUrl, workspaceDirectory }); } -/** - * Connect a host: the command that installs this Environment's self-hosted - * executor from Core's public address. It holds no secret; the installer asks - * for a credential from the section above at a hidden prompt. Shown only when - * the console serves the self-hosted installer with a verified digest. - */ -export function ExecutorInstallPanel({ install, archived, connected = false }: { install: ExecutorInstallRead; archived: boolean; connected?: boolean }) { +/** One home for native installation instructions; issuing a credential does not establish a connection. */ +export function ExecutorInstallPanel({ install, archived, connected = false }: { install: ExecutorInstall; archived: boolean; connected?: boolean }) { const { t } = useTranslation("sessions"); - const { t: tCommon } = useTranslation("common"); const headingId = useId(); - if (install.kind === "unavailable") return null; - - let body; - if (install.kind === "failed") { - body =

{t("executor.install.failed")}

; - } else if (install.kind === "loading") { - body =
; - } else { - body = install.kind === "no_address" ?

{t("executor.install.noAddress")}

- : install.kind === "local_only" ?

{t("executor.install.localOnly", { url: install.publicUrl })}

- : install.kind === "not_wss" ?

{t("executor.install.notWss", { remote: install.remoteUrl || "—" })}

- : <> -

{archived ? t("executor.install.archived") : t("executor.install.steps")}

- -
- {/* The list carries the label for assistive technology; the visible one is not read twice. Safari drops a list's semantics without list-style unless its role is explicit. */} - -
    - {["Linux amd64", "Python 3.9+", "curl", "sha256sum", "Docker CLI", t("executor.install.docker", { socket: DOCKER_SOCKET }), t("executor.install.https", { url: install.publicUrl })].map((item, index) => ( -
  • {index ? : null}{item}
  • - ))} -
-
- ; - } - return ( -
-
-

{t("executor.install.title")}

- {t("executor.install.lifecycle")} - - {connected ? -
- {body} -
- ); + const [shell, setShell] = useState("posix"); + return
+
+

{t("executor.install.title")}

+ {t("executor.install.lifecycle")} + + {connected ? +
+

{t(archived ? "executor.install.archived" : "executor.install.steps")}

+ {t("executor.install.guide")} + {install.kind === "ready" ? <> + { if (value === "posix" || value === "powershell") setShell(value); }} /> + +

{t("executor.install.start")}

+ :

{t("executor.install.unavailable")}

} +
; } /** Selects a block's text when the clipboard refused it (a plain-HTTP console has none), to copy by hand. */ diff --git a/apps/web/src/features/sessions/SessionPage.tsx b/apps/web/src/features/sessions/SessionPage.tsx index 66d62003f..512f49850 100644 --- a/apps/web/src/features/sessions/SessionPage.tsx +++ b/apps/web/src/features/sessions/SessionPage.tsx @@ -118,7 +118,7 @@ export function SessionPage() { const waiting = waitingFor(session); const metadata = Object.entries(session.metadata).filter(([key]) => key !== "title"); const harness = session.agent.x_agents_core?.harness; - const environment = session.environment as { type: string; id?: unknown; remote_url?: unknown }; + const environment = session.environment as { type: string; id?: unknown; remote_url?: unknown; workspace_directory?: unknown }; body = ( {history.gone ?

{t("detail.gone", { time })}

: null} @@ -170,7 +170,7 @@ export function SessionPage() { {environment.type === "self_hosted" && typeof environment.id === "string" ? ( - + ) : null}