diff --git a/.github/workflows/native-installer.yml b/.github/workflows/native-installer.yml new file mode 100644 index 000000000..44d620945 --- /dev/null +++ b/.github/workflows/native-installer.yml @@ -0,0 +1,81 @@ +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 -ldflags "-X github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/cli.Version=$GITHUB_SHA" -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: Build the pinned MiniMax companion on Unix + if: runner.os != 'Windows' + run: | + source_repository="$(node -p 'require("./packages/mcode-harness/source.json").repository')" + source_revision="$(node -p 'require("./packages/mcode-harness/source.json").revision')" + source_version="$(node -p 'require("./packages/mcode-harness/source.json").version')" + git init --quiet "$RUNNER_TEMP/mcode-source" + git -C "$RUNNER_TEMP/mcode-source" remote add origin "$source_repository" + git -C "$RUNNER_TEMP/mcode-source" fetch --depth 1 origin "$source_revision" + git -C "$RUNNER_TEMP/mcode-source" checkout --detach FETCH_HEAD + npm install --prefix "$RUNNER_TEMP/mcode-native" --no-audit --no-fund --include=optional --install-strategy=nested "@minimax-ai/code@$source_version" + MCODE_NATIVE_SOURCE="$RUNNER_TEMP/mcode-source" \ + MCODE_CLI_DIR="$RUNNER_TEMP/mcode-native/node_modules/@minimax-ai/code" \ + MCODE_HARNESS_BUILD_DIR="$RUNNER_TEMP/minimax-runtime" \ + bash scripts/build-mcode-harness.sh + - 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: | + cd "$RUNNER_TEMP" + tar -C native-installer -czf "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/CONTRIBUTING.md b/CONTRIBUTING.md index ae6877f63..f5237bd1b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2246,13 +2246,11 @@ uninstall with the previous release before adding it again. The machine configuration route rejects `X-Parsar-Node-ID` with `400 invalid_request`; only `X-OAC-Node-ID` identifies a retained node credential. -Self-hosted installations use `oac-selfhost` and `~/.oac/self-hosted/`. -The installer refuses an existing `~/.parsar/self-hosted/` for the -same Environment, even with a custom new install directory. Old executors keep -running with their own image and history; operators explicitly stop and remove -them before replacing them. New installation never adopts their native history. -Credential replacement for a new installer-owned executor retains its existing -container and ownership checks. +User-managed hosts use the native daemon installer on Linux, macOS and Windows. +It does not select a supplier or create compute resources. The retired Docker +self-hosted installer/launcher and console payload are not retained as fallback. +Existing environments, credentials, workspaces and native history are never +automatically deleted or adopted by a new installation. One Runtime image contains the existing daemon, shared helpers and three native harness packages. Their differences remain in the adapters. Core keeps exclusive @@ -3855,8 +3853,8 @@ and use its absolute `dist/main.js` as the private factory entrypoint. Validate relocation and real provider cancellation/continuation before accepting an artifact. Linux x64/glibc with Node22 is the currently exercised platform; other hosts require their own native acceptance. Do not reuse a bundle across -platforms or libc variants. Automatic Node installation, managed activation and -release publication remain separate work. Operator-configured daemon discovery/registration is supported as +platforms or libc variants. Standalone Claude archives require operator-supplied Node. The native installer +bundles Node and owns user-managed activation; release publication remains separate. Operator-configured daemon discovery/registration is supported as specified above. The exported `dist/runtime_check.js` companion is the local readiness contract. @@ -3870,7 +3868,7 @@ with shared process-group ownership, bounded output and a 15-second deadline plus bounded cleanup. Both native and bridge probes have five-second limits. Return unavailable on failed or malformed probes; never forward native diagnostics or treat local readiness as provider authentication, public capability acceptance -or filesystem isolation. Automatic installation remains separate. +or filesystem isolation. The native installer reuses this readiness check after copying its release components. ### Harness selection and Agent defaults @@ -4056,3 +4054,45 @@ terminal transaction: event/input receipt for normal terminal projections, one post-lock database wall-clock sample for forced incomplete Items. Preserve historical terminal nulls and public native completion times. See the [diagnostics contract](contracts/agents-api/session-diagnostics.md). + +## Native daemon and Harness installation + +`oac-daemon install` owns interactive selection and CLI-only installation through +one options/validation path. No installation-options file input is supported. +Persisted installation state and explicitly supplied credential/tool-variable +files serve runtime operation, not a second installer configuration language. +The release bundles pinned Node/npm, native Harnesses and required adapter assets; +registration lives in CLI and native activation/readiness in each adapter's optional +`agent.Installation` descriptor. Core never selects native paths or OS-specific +installation steps. See [native installation](docs/self-hosted-native.md). +An installed daemon discovers and registers only the adapter kinds named by its +verified installation manifest. The host PATH stays available to tools; its other +Harness executables and activation variables cannot extend that installation. +Direct `connect` rejects an installation manifest and directs the operator to +`start`; unmanaged image bootstrap remains available without that manifest. + +All mutations use the installation directory lock. Publish complete checksum-verified +components from staging, then commit configuration after native readiness passes. +Re-running with the same connection settings adds selected Harnesses and validates +existing contents. Never overwrite, upgrade, auto-repair or migrate installed +components. Missing, modified, wrong-platform or incompatible content is an explicit +error. A partial addition must preserve the old configuration and allow reuse of +complete components; it must not remove previous files or data. + +Installers run as the current user in writable directories. Native subprocess +diagnostics must not expose sensitive parameters or environment values. Adapter +readiness checks receive the installer cancellation context and must reap owned +processes before returning after interruption. Readiness, +authenticated connection and model configuration are separate reported facts. +Starting execution must not download or install Harnesses. Ordinary stop/reconnect +must preserve capability snapshots and native Session state. + +`scripts/build-native-installer.mjs` packages native inputs, validates pins/startup +and hashes every component file. It uses contained regular files and rejects +escaping links. Claude's dedicated frozen `pnpm deploy` export is reified with +the hoisted linker for this distribution before contained links are flattened; +the original standalone Claude archive contract remains unchanged. The native +installer workflow builds and tests on Linux, macOS and Windows, including actual +installation, addition/reuse, missing arguments and unsupported Windows MiniMax. +Native CI startup checks do not replace real model execution evidence or claim +manual Windows acceptance. Heavy builds belong on remote servers or CI. diff --git a/README.md b/README.md index 61216bf21..d3a9e3f60 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ **Open-source Agents API infrastructure, with your choice of native harness.** Run Codex, Claude Code and MiniMax Code behind the OpenAI Agents API, on machines you -control. Core serves the API and runs each Session in a sandbox; Web is the +control. Core serves the API and drives each Session through its Runtime; Web is the administrator console that issues the keys applications call Core with. ## Get started @@ -56,9 +56,9 @@ Applications then set `OPENAI_BASE_URL` to `https://core.example/v1` and self-hosted executors. - **One Runtime path.** The same daemon handles initial files, tool configuration, packages, setup, Skills/Plugins and execution. Providers place and bootstrap - compute, then renew or reclaim it. The protocol is platform-neutral; the current - packaged implementation and isolation are Linux-only. Harness differences stay - in adapters. + compute, then renew or reclaim it. User-managed daemons support Linux, macOS and + Windows with the launching account's permissions. Managed Providers remain + Linux-only and provide outer isolation. Harness differences stay in adapters. - **A console for administrators.** Web signs in with the Core key, issues Project API keys, adds nodes, sets default models and shows metrics and Session history. It never runs Agents on anyone's behalf. @@ -75,6 +75,7 @@ native differences. | [Install Core and Web](docs/getting-started/install.md) | Prerequisites, download, installer options, HTTPS and the reverse proxy, first sign-in | | [Nodes](docs/getting-started/nodes.md) | Adding, removing and troubleshooting nodes | | [Self-hosted executors](docs/getting-started/self-hosted.md) | Connecting an application's own machine to a Session | +| [Native daemon and Harness installer](docs/self-hosted-native.md) | Linux/macOS/Windows, interactive or CLI installation, adding Harnesses, prerequisites and operation | | [Operations](docs/getting-started/operations.md) | The `oac` command, the Core key, backups, upgrades, troubleshooting | | [Configuration reference](docs/configuration.md) | Every setting in `config.json` and in Web | | [Call the API](docs/getting-started/quickstart.md) | The application developer's quickstart | diff --git a/apps/docs/content/docs/admin-api.mdx b/apps/docs/content/docs/admin-api.mdx index aa70cec65..7a52b13b6 100644 --- a/apps/docs/content/docs/admin-api.mdx +++ b/apps/docs/content/docs/admin-api.mdx @@ -22,7 +22,7 @@ The browser uses the console's own origin and signs in with the | `GET /console/auth` | No body | `200 {"mode":"login"}` or `200 {"mode":"authenticated"}` | | `POST /console/auth/login` | `Content-Type: application/json`, body `{"core_key":"…"}`; other members are rejected | `200 {"mode":"authenticated"}` and an HttpOnly, SameSite=Strict session cookie (Secure over HTTPS) | | `POST /console/auth/logout` | No credential payload | `200 {"mode":"login"}`; clears the cookie and the server-side session | -| `GET /console/config` | Signed-in session | `node_installer`, `node_installer_sha256`, `self_hosted_installer`, `self_hosted_installer_sha256`, and `node_artifacts`: the providers (`docker`, `microsandbox`) whose node assets this console holds | +| `GET /console/config` | Signed-in session | `node_installer`, `node_installer_sha256`, `node_artifacts`: the providers (`docker`, `microsandbox`) whose node assets this console holds | Sign-in errors use the console's `{"error": "…"}` envelope: 400 for a malformed body, 401 for a wrong key, 415 for a non-JSON body, 429 with `Retry-After` when diff --git a/apps/docs/content/docs/configure.mdx b/apps/docs/content/docs/configure.mdx index 24c5a9cb6..d4e4ac4a2 100644 --- a/apps/docs/content/docs/configure.mdx +++ b/apps/docs/content/docs/configure.mdx @@ -253,8 +253,8 @@ loads the file with `env_file` and systemd with `EnvironmentFile`, so Compose mu A Web you run without the installer reads the variables in [Connecting the administrator console to Core](/bootstrap-projects-keys#server-configuration-and-login), plus `OAC_WEB_NODE_PAYLOAD_DIR`: the absolute path of the matched distribution's -node payload (the installer's `node-payload/`). Without it, Add node and the -self-hosted install command are unavailable. +node payload (the installer's `node-payload/`). Without it, Add node is +unavailable. Native self-hosted installation uses its separate platform bundle. Core fails at startup, naming the replacement, while a retired variable is set: `AGENTS_API_DAEMON_WS_URL` (use `OAC_PUBLIC_URL`), `AGENTS_API_CONFIG_FILE` (no diff --git a/apps/docs/content/docs/install.mdx b/apps/docs/content/docs/install.mdx index cb8d2e98a..de553026a 100644 --- a/apps/docs/content/docs/install.mdx +++ b/apps/docs/content/docs/install.mdx @@ -505,7 +505,7 @@ The installation directory, `~/.oac/core` by default, mode `0700`: | `secrets/database.password` | PostgreSQL password | | `state.json` | Format 2, installation ID, Compose project name, image IDs and source commit. Written by the tools only | | `generated/` | Files derived from `config.json`: `compose.json`, `core.env`, `core-key-digests.json`, `settings.json`, `config.schema.json`, and `runtime-history.json` or the native Core unit when used. `oac apply` rewrites them; don't edit them | -| `node-payload/` | The node and self-hosted installers, the manifest and the node files that Web serves at `/node-install/` | +| `node-payload/` | The managed node installer, the manifest and the node files that Web serves at `/node-install/` | | `state/e2b/` | Private E2B receipts | | `native/` | Core binaries, with `--native-core` only | diff --git a/apps/docs/content/docs/self-hosted-execution.mdx b/apps/docs/content/docs/self-hosted-execution.mdx index 7b587b5cd..164b0620d 100644 --- a/apps/docs/content/docs/self-hosted-execution.mdx +++ b/apps/docs/content/docs/self-hosted-execution.mdx @@ -51,15 +51,16 @@ Otherwise creation fails with 400 `model_provider_required`. 2. In Web, open **Session log**, then the Session's **Executor credentials** section. Choose **Issue credential**, then **Download credential file**. Core returns the credential only once; retain it privately before choosing **Done**. -3. On the host, run `oac-daemon install` with the returned `remote_url`, Environment - ID, matching workspace and absolute credential-file path, then `oac-daemon start`. +3. Extract the native distribution and run `oac-daemon install --interactive` + (or supply all options with `--non-interactive --harness`). Use the returned + remote URL, Environment ID, matching workspace and credential file path, then + start the installed `bin/oac-daemon`. The [native guide](/self-hosted-native#install-and-start) has Linux/macOS and PowerShell examples. No Docker installation is required for this native path. -The console's existing **Connect a host** download command targets the Linux -container installer, not the native installation command. Use the native guide for -this workflow; downloading an executor credential is independent of the console's -installer assets. Core must be reachable from the host: `wss://` is required +The console's **Connect a host** flow provides native installation guidance and +the executor credential download. Obtain the matching native distribution before +running its command. Core must be reachable from the host: `wss://` is required outside loopback, while a local Core can use a loopback `ws://` URL. A connected Environment proves only the machine connection. Send a Turn to check diff --git a/apps/docs/content/docs/self-hosted-native.mdx b/apps/docs/content/docs/self-hosted-native.mdx index 78f323661..d3c8ae1ad 100644 --- a/apps/docs/content/docs/self-hosted-native.mdx +++ b/apps/docs/content/docs/self-hosted-native.mdx @@ -3,163 +3,187 @@ title: "Native daemon installation" description: "Install and operate a self-hosted daemon on Linux, macOS or Windows." --- -A self-hosted Runtime runs the same `oac-daemon` on Linux, macOS or Windows. -Core uses the same Environment protocol on all three platforms; native harness -adapters handle harness differences. Core-managed Providers remain Linux-only. +Use the same `oac-daemon` on a user-managed Linux, macOS or Windows machine. +Physical machines, VMs and user-owned sandboxes use the same installer. Core does +not create or reclaim these machines. Core-managed Docker, E2B and microsandbox +Providers remain Linux-only and receive prebuilt Runtime images or templates. -The daemon runs tools with the permissions of the user who starts it. It adds no -filesystem, permission or network sandbox. Tools can access whatever that user -can access, including Runtime state and credentials. Authentication, ordinary -credential storage, atomic state writes and process cleanup do not isolate tools -from the host. When isolation is needed, run the Runtime inside an outer sandbox; -Core creates that outer boundary for managed Docker/E2B/microsandbox Environments. -The daemon does not enforce `disabled` or `restricted` network modes; combinations -without corresponding outer enforcement are unsupported. +The daemon runs with its starting account's permissions, without an inner sandbox +or privilege elevation. Tools can access whatever that account can access. Use an +outer container or VM when isolation is required. Connection authentication, +Project/Environment authorization, credential permissions, Files path validation, +process cleanup and snapshot consistency still apply; they do not isolate tools +from their own account. `disabled` and `restricted` network modes require an outer +implementation that enforces them. -## Supported platforms +## Platforms and prerequisites | Platform | Codex | Claude Code | MiniMax Code | | --- | --- | --- | --- | | Linux | Supported | Supported | Supported | | macOS | Supported | Supported | Supported | -| Windows | Supported | Supported | Not supported by the current adapter | +| Windows | Supported | Supported | Unsupported by the current adapter | + +Use a distribution built for the machine's OS and architecture. Windows support +is validated on a native CI runner; manual Windows machine acceptance is not yet +recorded. Native Linux/macOS runs and native CI qualify the corresponding bundles. +Supported does not mean every model provider or optional native feature works in +every combination. Session capabilities are checked by the existing Harness contract. + +The distribution contains Node 22.22.0/npm and the selected release's components: +Codex 0.153.4, Claude Agent SDK 0.3.269 with native Claude Code 2.1.269 and the +project's adapter, and the patched MiniMax Code 0.4.12 companion on Unix. Arbitrary +official CLI installations are not adopted. They remain untouched while the +installer creates its private, verified copy. Compatible components from this +same installation are reused after checking their version, contents and startup. + +Claude on Windows requires Git Bash. Bash is also needed for Runtime setup and +MiniMax tools. Node/npm and MiniMax's ripgrep are included; Python/pip, when needed +by a Session's capability dependencies, must be available. Missing system +components are reported. Install those through the host's normal administration +process; the daemon never runs apt, sudo or an elevation command. + +## Obtain a distribution + +Before a public release exists, build a native package with the repository's +`native-installer` workflow and download its artifact for the target platform. +Extract the archive before running the executable. The workflow publishes CI +artifacts, not a production release or an automatic update channel. + +For a local release build, use `scripts/build-native-installer.mjs --daemon PATH +--node DIR --codex DIR --claude DIR --minimax DIR --output ABS`. Select the +components to include; omit MiniMax on Windows. Build on the target OS, using +the existing pinned native artifacts. The builder verifies native startup and +creates `bundle.json`, `oac-daemon` (or `.exe`) and `components/`. +`bundle.json` describes release files and checksums. It does not accept user +installation options, connection settings or credentials. + +The Claude source is the existing compiled adapter export from modern +`pnpm deploy`. Reify its dedicated frozen lock with +`pnpm install --prod --frozen-lockfile --config.node-linker=hoisted --ignore-scripts` +in a fresh export before packaging. This preserves dependency resolution when +the builder converts contained links to regular files. MiniMax must be built +from the pinned source with this revision's patches and native dependencies; +copying a Linux companion onto macOS does not produce a macOS distribution. -Native CI passing is the platform support criterion. Windows has not yet had -manual machine acceptance. Managed Providers support Linux only. - -Use a current-version daemon binary for the host's OS and architecture. The daemon -installer saves local configuration; native harnesses and their dependencies must -already be available to its process. Claude on Windows requires Git Bash. +## Install and start Create a `self_hosted` Session with its model provider and an existing absolute -`workspace_directory` on the executor host. Obtain its `environment.id` and -`environment.remote_url`, then have the Core administrator issue an -[executor credential](/self-hosted-execution#without-web). Save the returned -JSON as a private local file. It contains `key_id`, `environment_id` and -`executor_token`; do not put the token in command arguments. +`workspace_directory` on the executor host. Save its `environment.id` and +unchanged `environment.remote_url`. Have the administrator issue an +[executor credential](/self-hosted-execution#without-web) and save the JSON +as a private file. It contains `key_id`, `environment_id` and `executor_token`. +Pass the file path, never the token. Use `wss://` outside loopback; `ws://` is +accepted only for a loopback Core. The workspace must match the Session. -Use the exact `remote_url` from Core. It uses `wss://` outside loopback; `ws://` is -accepted only for a loopback Core. The Environment ID must be a canonical UUID. -Paths must be clean absolute paths in the host's syntax, and the workspace must -already exist. The install workspace must match the Session's configured workspace. +From the extracted distribution, interactive installation asks for Harnesses +(comma-separated, multiple selections allowed), installation directory, workspace +and connection settings. Existing command-line values also work with the prompts: -## Install and start - -Choose a separate `OAC_RUNTIME_HOME` for each installation. It contains local -configuration, connection state, logs and the default capability snapshot. Keep -this same value for install, start, status, logs and stop. +```sh +./oac-daemon install --interactive +``` -On Linux or macOS: +Noninteractive installation accepts command-line arguments only and never waits +for input. On Linux or macOS: ```sh -export OAC_RUNTIME_HOME="$HOME/.oac/runtime-example" mkdir -p "$HOME/agent-workspace" chmod 600 "$HOME/executor-credential.json" -oac-daemon install \ +./oac-daemon install --non-interactive \ + --harness codex,claude,minimax \ + --install-dir "$HOME/.oac/runtime-example" \ --remote 'wss://core.example/api/v1/agent-daemon/ws' \ --environment-id '11111111-2222-4333-8444-555555555555' \ --workspace "$HOME/agent-workspace" \ --credential-file "$HOME/executor-credential.json" -oac-daemon start +"$HOME/.oac/runtime-example/bin/oac-daemon" start ``` On Windows, use native absolute paths in PowerShell: ```powershell -$env:OAC_RUNTIME_HOME = "$HOME\.oac\runtime-example" New-Item -ItemType Directory -Force "$HOME\agent-workspace" | Out-Null -oac-daemon.exe install ` +.\oac-daemon.exe install --non-interactive ` + --harness codex,claude ` + --install-dir "$HOME\.oac\runtime-example" ` --remote 'wss://core.example/api/v1/agent-daemon/ws' ` --environment-id '11111111-2222-4333-8444-555555555555' ` --workspace "$HOME\agent-workspace" ` --credential-file "$HOME\executor-credential.json" -oac-daemon.exe start +& "$HOME\.oac\runtime-example\bin\oac-daemon.exe" start ``` -Replace the example URL and UUID with the Session's actual values. On Windows, keep -the credential file under the intended user's normal private storage permissions. - - -Optional install flags: +Replace the URL and UUID with the Session's values. Keep the credential file in +the account's private storage. The installer does not print credential contents +or pass them in the background daemon's arguments. -| Flag | Meaning | +| Option | Meaning | | --- | --- | -| `--capability-directory ABS` | Local destination for the installed capability snapshot; defaults to `capabilities` under `OAC_RUNTIME_HOME`. This is an operator setting, not a public API destination | -| `--tool-env-file ABS` | JSON object of string-valued tool environment variables. Use it for explicitly configured tool/MCP variables; it is not a dotenv or shell file | - -For example, a tool environment file can contain `{"EXAMPLE_SETTING":"value"}`. -Treat files containing credentials as secrets. Retain the credential and tool -configuration files at their configured paths; installation records those paths. - -Runtime preparation uses one Go implementation on all platforms. Its local -initialization and package directories default to `initialization` and `packages` -under `OAC_RUNTIME_HOME`. Operators can select absolute paths with -`OAC_RUNTIME_INITIALIZATION_DIRECTORY` and `OAC_RUNTIME_PACKAGE_DIRECTORY`; -managed images set these to `/environment/initialization` and -`/environment/packages`. They change storage layout, not execution permissions. -`OAC_RUNTIME_TOOL_ENV_FILE` selects an explicit tool-variable JSON file; preparation -does not overwrite an existing configuration. - -npm and Python packages install only into the Runtime package directory using -npm's prefix and pip's target options. Node/npm and Python/pip must already be -installed. Setup requires Bash; on Windows it requires Git Bash, found from Git's -installation or `CLAUDE_CODE_GIT_BASH_PATH`. Missing dependencies fail preparation; -Runtime does not substitute PowerShell, cmd.exe or WSL for Bash setup. - - -`oac-daemon start --foreground` stays attached to the terminal instead of starting -in the background. An operator can use their own service manager to run it; the -native installer does not configure one. - -## Operate the same installation - -With the same `OAC_RUNTIME_HOME` still set: - -```sh -oac-daemon status -oac-daemon logs -n 100 -oac-daemon logs -f -oac-daemon stop -``` - -Use `oac-daemon.exe` for these commands on Windows. A connected Environment proves -the machine connection, not model execution: send a Turn to validate the selected -native harness and model. Stopping the daemon preserves the workspace, native -history and capability snapshot; deleting a Session does not remove host files. - -After credential rotation, stop the daemon, replace the JSON at the configured -credential path with the newly issued credential for the same `key_id`, then start -it again. Do not rerun `install` over an existing installation. Revocation prevents -the old credential from reconnecting. - -## Capabilities and versions - -`capability_directories` supplies local source directories when the Session is -created. Runtime snapshots them before execution and resolves Skills and Plugin -MCP from the common `installed.json` format used for managed bundles. Paths use the -executor host's native syntax, not Core's filesystem. Reconnect reuses the snapshot; -source edits do not silently replace it. A new Session captures new configuration. -See [local capability directories](/self-hosted-execution#local-capability-directories). -Read-only snapshot modes are an integrity hint, not protection from the same user. - -Installation is current-version only. It refuses an existing installation, and -`start` refuses configuration from another daemon version. There is no in-place -upgrade, adoption of a historical container installation, or cross-version native -history migration. Preserve an old installation's files and use a fresh Runtime -home and Session when moving versions. - -System dependencies must be present before Runtime execution: install them while -building the managed image/template, or prepare them as the self-hosted user. -Runtime does not run apt, request sudo or elevate the daemon's privileges. -`packages.system` is explicitly rejected on Session and Template input, including -an empty or null value; it is never ignored. A missing preinstalled dependency -fails the operation that requires it. npm/Python package installation and setup retain their supported -initialization flow using the launching user's existing permissions. - - -On Windows, stdio MCP commands named `npm` or `npx` (including explicit -`.cmd` paths) use the selected installation's JavaScript entrypoint with Node. -Ordinary executables run directly. Other batch wrappers must configure -`cmd.exe` explicitly, with the wrapper arguments required by that command. +| `--harness` | Comma-separated `codex`, `claude`, `minimax`; unsupported combinations fail | +| `--install-dir ABS` | User-writable installation; defaults to `OAC_RUNTIME_HOME`, then `~/.oac` | +| `--bundle-dir ABS` | Extracted release directory; defaults beside the executable | +| `--remote`, `--environment-id`, `--workspace`, `--credential-file` | Required connection inputs, equally available in both modes | +| `--capability-directory ABS` | Capability snapshot destination, default `capabilities` under the installation | +| `--tool-env-file ABS` | Optional JSON object of string-valued tool/MCP variables, not installation options | + +Installation reports three independent facts: local installation readiness, +connection not yet checked, and model configuration not yet checked. It does not +start a daemon, provision a machine, configure a model or create an OS service. +`start --foreground` runs under an operator's preferred service manager. + +## Add Harnesses and operate the installation + +Run the original distribution's install command again with identical connection +options and the Harnesses to add. The installer retains already selected Harnesses, +checks compatible existing contents and adds only missing components. No default +deletion, replacement or upgrade occurs. All installation writes use one lock. +A component is published only after its copy passes checksum verification; +interrupted additions can reuse complete components on the next run. Installation +settings are committed only after all selected Harnesses pass readiness checks. + +The installed `bin/oac-daemon` locates its own installation. Use that executable +for `start`, `status`, `logs -n 100`, `logs -f` and `stop`. Direct `connect` is +rejected for an installed Runtime; `start` validates its components and selection. An explicit +`OAC_RUNTIME_HOME` overrides this location; keep it consistent if set. If adding a +Harness while the daemon is running, restart it to refresh Harness discovery. +`status` reports local profile/PID-file information only. Check **Host connection** +in Core or the Environment connection API for authenticated connection state. + +A connected Environment proves machine authentication, not model availability. +Configure the model provider through the existing Session/Agent mechanism and +send a Turn to verify execution. Rotate a credential by stopping the daemon, +replacing the JSON at the configured path with the rotated credential for the +same `key_id`, and starting again. Revocation blocks the old credential. + +An incompatible daemon version, component version or modified installation is +an explicit error. Use a separate installation directory; there is no old-version +upgrade, migration or automatic repair. Preserve previous files and history. +Stopping a daemon, cancelling a Turn or deleting a Session never removes the +user's machine, workspace, native history or capability snapshot. + +## Common preparation and execution + +After authenticated connection every Runtime follows the same flow: Harness +availability, workspace and capability preparation, fixed `installed.json`, then +execution, cancellation and recovery. Provider image contents and self-hosted +`capability_directories` enter the same parser and installation result. Adapters +receive Skill paths, Plugin results and MCP declarations from that snapshot. +Reconnect reuses it; new Sessions capture new configuration. Preparation or +recovery errors never trigger silent reinstall, replay or replacement native Sessions. + +Runtime initialization/package directories default to `initialization` and +`packages` under the installation. Managed images use their own storage layout +through `OAC_RUNTIME_INITIALIZATION_DIRECTORY` and `OAC_RUNTIME_PACKAGE_DIRECTORY`; +this does not change the preparation protocol or account permissions. npm and +Python dependencies use user-writable prefix/target directories. Setup uses Bash, +including Git Bash on Windows. Supplying `packages.system` in configuration is +rejected even when empty or null; managed images/templates must include system +dependencies before launch. Official API read responses retain the required +`system: []` field, which does not imply support for installing system packages. + +On Windows, stdio MCP commands named npm or npx (including explicit .cmd +paths) run through the selected installation's JavaScript entrypoint with Node. +Other batch wrappers require an explicit cmd.exe command and its arguments. [Repository source](https://github.com/MiniMax-AI/parsar-core/blob/281f243aa785ea25512a6157daa1d44197b9723e/docs/self-hosted-native.md) diff --git a/apps/docs/content/guide-sources.json b/apps/docs/content/guide-sources.json index 86b98d32b..d9bf3dc52 100644 --- a/apps/docs/content/guide-sources.json +++ b/apps/docs/content/guide-sources.json @@ -4,8 +4,8 @@ "docs/getting-started/README.md": "06ee21845be57504ee780eaf063753941cb67ab6bf3fbac69319bb3bf7b64dbb", "docs/design-principles.md": "564a9b7a79f429e4d4501d87b3f759b3ea7db76a5befc399f37df4d61f6ebbc2", "docs/web/architecture.md": "80bdb8053c06c2b2030b193aae17d5434545f9c72af50e9d3df3ade522fe25ed", - "docs/getting-started/install.md": "a4f5591793ba37a7200fa69f462b7bad2a7466eebc3aa5bc2514d1ab0b15238e", - "docs/configuration.md": "478b988c06a61f5555f8ec2b5fe4a9379c74e28cea4108734af2e478397905fe", + "docs/getting-started/install.md": "e2043e0abdd7fc77bbad8d7fe3e17310421a94497c3b73236e653d525c8d34c1", + "docs/configuration.md": "0c15f6253233f4766cae5dda4d33a120c7c53a4770353a86db4890e2a8e7e078", "docs/web/core-connection.md": "46f86520e70bf41079708e2c398655aa9087ad9488c8e661d9a73035d11098c4", "docs/getting-started/quickstart.md": "5effeffcfb343f7939910f0c1758e1492d49e7bd66c409b1819cefe67af592d7", "docs/api/README.md": "592fc2aef474e36eb760be0dd1b2269b6766c0d984f826eaf6f47711ea139387", @@ -13,11 +13,11 @@ "docs/api/public-agent-api.md": "5b04e5ec9311df851376f66fc1584dd48ca123cd8c26ca63277c10b9172e9955", "contracts/agents-api/environments.md": "4c4b9423b22919d6aa6bd98d96422e1a8d610d3442d4cf7731a575bd31fd43f0", "docs/getting-started/nodes.md": "c1bc854c4798b9757bbb8cea4474a03442ba6f4c10444d5505733e8b94046038", - "docs/getting-started/self-hosted.md": "a2eb73cd5d3930cd2851d52e9a60a45f5bd2b0f2f60bfb1da9997ef52dd9a5b5", - "docs/self-hosted-native.md": "36831e323bd198771bec853f832d2bc08530f194165a11c824b4a5ca905ebc27", + "docs/getting-started/self-hosted.md": "99d8851399bde2310196ac89dae1c365bba144421dbb33153094d30072de1783", + "docs/self-hosted-native.md": "e69819c3201109c8be5413716853eafa22d9d84b57aac780a4d335149b6a926b", "apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", "docs/web/README.md": "9fce295f6f6ff04acefe338679c1e54723e123dd0027a778202adfa9ab95b6c4", - "docs/api/web-management.md": "dc79df33dade6139749752c9ce53d4e126354cfabccada3e7826de4dd20fae62", + "docs/api/web-management.md": "d5f982922320069be5017cd524095d86eee0d66fd0d1cf97d1cdd97544d0dce5", "contracts/agents-api/runtime-observability-api.md": "cd46e777fe716a7e76374f4655c96dfbad9d8be1bfca6203ed6118ab87874c2b", "docs/getting-started/operations.md": "f39d7eaf403ed92c6a1f37c732a19195a389784f78799b3c391654f7d394ac2e", "apps/docs/scripts/guides.json": "1da7b90e34505a2c149410627f1463103eb638ce0dd02004abd58406fd07a114" @@ -29,9 +29,9 @@ "content/docs/concepts.zh.mdx": "c60644f395dbac5ee607cdb053249e05ec8352d28de97ef1c38d6c1e4443bbb1", "content/docs/execution-model.mdx": "83f44bbba5b2cece1e6f74511bbd10b0db6286782cf2ed8fd402636194477031", "content/docs/execution-model.zh.mdx": "9976a9203c6a83d49c2c753e3a8528a369078e87aeb88850654da948ac8c17e1", - "content/docs/install.mdx": "736f74a503fcb7227aa0ec047c06beba83fedb25d66a3a4f0bc6a53f82286c0a", + "content/docs/install.mdx": "d04c9e67804ed2619aeaaf5a3ef7bfeba497b31914d2e66c23731f919ef83d93", "content/docs/install.zh.mdx": "f207607f0cfceef2012a4e772fc347ef254486327584289fee1c85414dffc907", - "content/docs/configure.mdx": "3180e9945c386f95673fc68d05adff1b9f16dc8c1dc258dff9dff1c0ba46c381", + "content/docs/configure.mdx": "3e8ef7df783f897e39046eaabefd2b644103d5dc1fe7dfd487be285db6a401cd", "content/docs/configure.zh.mdx": "1c4b149c332ade6f2eeca52319e3e7ccfaac3213db2fd084d66e2da517b6bbd6", "content/docs/bootstrap-projects-keys.mdx": "bc4f999166f48184b92275653e735f6727858b58ec6f78cfef5be3dcb8d750b5", "content/docs/bootstrap-projects-keys.zh.mdx": "509b4dcea4c865d6834bcaef78baa681180d48274931230f958a39e46011c88f", @@ -47,14 +47,14 @@ "content/docs/environments-and-files.zh.mdx": "ee64577220a798f023b330c65302c14438f25a34a78c4e36144bfdb4304a3ae5", "content/docs/hosted-providers.mdx": "a0ccf06394e28a79c8e98503852a8f6a7558b22928ecdaa1804b04b16ecf92b4", "content/docs/hosted-providers.zh.mdx": "d7f6115f052a09dc943f0ed516ff30a27da5847ed24d387d9d1c7e7faa006ab0", - "content/docs/self-hosted-execution.mdx": "cad6d197ad1c49d06174f304a067f3e5e57b5b23c739cab6b5c6fa5e6dadd5fa", + "content/docs/self-hosted-execution.mdx": "d4c2c2e6f3bf8747a2bb41b0d5b010d0c9654c8c43dde746649bbe7d040a5550", "content/docs/self-hosted-execution.zh.mdx": "48627ebf94a278b37fb814ec8cf0186447268bb36da9982f354f88e1d6d47500", - "content/docs/self-hosted-native.mdx": "a00486af6b408fad6fbea03f7a74a675665647febbfffb50de51712757848f00", + "content/docs/self-hosted-native.mdx": "cb6a4db003249f2eb830d85e253a823eb53f6af6780bacf295b345cd55203799", "content/docs/self-hosted-native.zh.mdx": "686eb7c22c0bbe59c753d5e8271309d60fad43131129ba5df4fcde44610f2d78", "public/images/source/apps/web/public/onboarding/monitor-en.webp": "29dc220cb1250c7016b7c4bf7f30e9510b07c07b814a48c3aa9303b2d76f20b2", "content/docs/console.mdx": "95d3e6f3795f07809e59c88b395a0fa1c5a746e3b878b3fae84a7222f187f4f5", "content/docs/console.zh.mdx": "72f953ba46dcc95933773ac0c4dffdc0d0a499be9d9a1cb9467db2659f1fa10d", - "content/docs/admin-api.mdx": "5807056736f190680f93f73e07f64eb496fdd4ead765914b3ea8066e857f8834", + "content/docs/admin-api.mdx": "73983577a885cc6889770885bdab6e07b498ccd33b7c0aff2720102fd78aa9be", "content/docs/admin-api.zh.mdx": "07006de30df3080e54c3cd79ed0ea5337dce557aa2f11afea84edb7f92884d38", "content/docs/observability.mdx": "b9bd7151b204c36ceaf4d200a4a6420ecc8b8e23eefa44253474f9c947e49b30", "content/docs/observability.zh.mdx": "9574b3412a5754f41ab2d3a16062ac1cad06766be481a8e6bff1b84acbde7e7e", 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..d4ed22a20 --- /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{AgentKind: "claude_sdk", 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(ctx context.Context, dir, node string, env []string) error { + got, err := CheckRuntime(ctx, 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..3153759c9 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/codex/installation.go @@ -0,0 +1,31 @@ +package codex + +import ( + "context" + "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{AgentKind: "codex", 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(ctx context.Context, dir, node string, env []string) error { + got, err := installroot.Probe(ctx, 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..2083b8052 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/installation.go @@ -0,0 +1,14 @@ +package agent + +import "context" + +// 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 { + AgentKind string + Version string + Supported func() bool + Environment func(directory, node string) map[string]string + Check func(context.Context, string, string, []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..b0a12fc3d --- /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(parent context.Context, binary string, args, env []string, dir string) (string, error) { + ctx, cancel := context.WithTimeout(parent, 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..eff3a77e7 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/mcode/installation.go @@ -0,0 +1,28 @@ +package mcode + +import ( + "context" + "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{AgentKind: "mcode", 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, "bridge.mjs"), "OAC_RUNTIME_MCODE_AGENTS_API": "1"} + }, + Check: func(ctx context.Context, dir, node string, env []string) error { + got, err := installroot.Probe(ctx, node, []string{filepath.Join(dir, "native", "cli.js"), "--version"}, env, dir) + if err != nil || got != SupportedVersion { + return fmt.Errorf("MiniMax installation is incompatible") + } + raw, err := installroot.Probe(ctx, node, []string{filepath.Join(dir, "check.mjs")}, env, dir) + if err != nil || ValidateWorkspaceReadiness([]byte(raw)) != nil { + return fmt.Errorf("MiniMax companion is unavailable or incompatible; use the current native distribution and verify Bash") + } + return nil + }} +} diff --git a/apps/parsar-daemon/internal/cli/agent_discovery.go b/apps/parsar-daemon/internal/cli/agent_discovery.go index a2233066a..59b5c183d 100644 --- a/apps/parsar-daemon/internal/cli/agent_discovery.go +++ b/apps/parsar-daemon/internal/cli/agent_discovery.go @@ -16,6 +16,7 @@ import ( // agentCLIDiscovery is the daemon startup snapshot advertised in heartbeat. type agentCLIDiscovery struct { + installedKinds map[string]bool MCodeWorkspace *mcode.WorkspaceConfig ClaudeSDK *claudeSDKDiscovery ClaudeCode proto.SupportedAgentKind @@ -25,6 +26,10 @@ type agentCLIDiscovery struct { MCode proto.SupportedAgentKind } +func (d agentCLIDiscovery) permits(kind string) bool { + return d.installedKinds == nil || d.installedKinds[kind] +} + type agentCLIChecks struct { ClaudeSDK func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error) ClaudeCode func(context.Context, string) (string, error) @@ -44,11 +49,11 @@ func defaultAgentCLIChecks() agentCLIChecks { } } -func preflightAgentCLIs(rc *runContext, profile string) (agentCLIDiscovery, error) { - return discoverAgentCLIs(rc, profile, defaultAgentCLIChecks()) +func preflightAgentCLIs(parent context.Context, rc *runContext, profile string) (agentCLIDiscovery, error) { + return discoverAgentCLIs(parent, rc, profile, defaultAgentCLIChecks()) } -func discoverAgentCLIs(rc *runContext, profile string, checks agentCLIChecks) (agentCLIDiscovery, error) { +func discoverAgentCLIs(parent context.Context, rc *runContext, profile string, checks agentCLIChecks) (agentCLIDiscovery, error) { if checks.ClaudeCode == nil { checks.ClaudeCode = claudecode.CheckCLIAvailable } @@ -62,6 +67,7 @@ func discoverAgentCLIs(rc *runContext, profile string, checks agentCLIChecks) (a checks.Pi = pi.CheckCLIAvailable } out := agentCLIDiscovery{ + installedKinds: rc.installedKinds, ClaudeCode: proto.SupportedAgentKind{ Kind: "claude_code", Capabilities: proto.AgentKindCapabilities{ @@ -117,73 +123,88 @@ func discoverAgentCLIs(rc *runContext, profile string, checks agentCLIChecks) (a }, } - claudeCtx, cancelClaude := context.WithTimeout(context.Background(), cliVersionTimeout) - claudeVersion, claudeErr := checks.ClaudeCode(claudeCtx, "") - cancelClaude() - if claudeErr == nil { - out.ClaudeCode.Available = true - out.ClaudeCode.Version = claudeVersion - fmt.Fprintf(rc.stdout, "Claude Code preflight ok (%s)\n", claudeVersion) - } else if errors.Is(claudeErr, claudecode.ErrCLINotFound) { - fmt.Fprintln(rc.stderr, "oac-daemon: Claude Code CLI not found on PATH; claude_code unavailable.") - fmt.Fprintf(rc.stderr, " Install instructions: %s\n", claudecode.InstallURL) - } else { - fmt.Fprintf(rc.stderr, "oac-daemon: `claude --version` failed; claude_code unavailable: %v\n", claudeErr) - fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", claudecode.InstallURL) + if out.permits("claude_code") { + claudeCtx, cancelClaude := context.WithTimeout(parent, cliVersionTimeout) + claudeVersion, claudeErr := checks.ClaudeCode(claudeCtx, "") + cancelClaude() + if claudeErr == nil { + out.ClaudeCode.Available = true + out.ClaudeCode.Version = claudeVersion + fmt.Fprintf(rc.stdout, "Claude Code preflight ok (%s)\n", claudeVersion) + } else if errors.Is(claudeErr, claudecode.ErrCLINotFound) { + fmt.Fprintln(rc.stderr, "oac-daemon: Claude Code CLI not found on PATH; claude_code unavailable.") + fmt.Fprintf(rc.stderr, " Install instructions: %s\n", claudecode.InstallURL) + } else { + fmt.Fprintf(rc.stderr, "oac-daemon: `claude --version` failed; claude_code unavailable: %v\n", claudeErr) + fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", claudecode.InstallURL) + } } - opencodeCtx, cancelOpenCode := context.WithTimeout(context.Background(), cliVersionTimeout) - opencodeVersion, opencodeErr := checks.OpenCode(opencodeCtx, "") - cancelOpenCode() - if opencodeErr == nil { - out.OpenCode.Available = true - out.OpenCode.Version = opencodeVersion - fmt.Fprintf(rc.stdout, "OpenCode preflight ok (%s)\n", opencodeVersion) - } else if errors.Is(opencodeErr, opencodeagent.ErrCLINotFound) { - fmt.Fprintln(rc.stderr, "oac-daemon: OpenCode CLI not found on PATH; opencode unavailable.") - fmt.Fprintf(rc.stderr, " Install instructions: %s\n", opencodeagent.InstallURL) - } else { - fmt.Fprintf(rc.stderr, "oac-daemon: `opencode --version` failed; opencode unavailable: %v\n", opencodeErr) - fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", opencodeagent.InstallURL) + if out.permits("opencode") { + opencodeCtx, cancelOpenCode := context.WithTimeout(parent, cliVersionTimeout) + opencodeVersion, opencodeErr := checks.OpenCode(opencodeCtx, "") + cancelOpenCode() + if opencodeErr == nil { + out.OpenCode.Available = true + out.OpenCode.Version = opencodeVersion + fmt.Fprintf(rc.stdout, "OpenCode preflight ok (%s)\n", opencodeVersion) + } else if errors.Is(opencodeErr, opencodeagent.ErrCLINotFound) { + fmt.Fprintln(rc.stderr, "oac-daemon: OpenCode CLI not found on PATH; opencode unavailable.") + fmt.Fprintf(rc.stderr, " Install instructions: %s\n", opencodeagent.InstallURL) + } else { + fmt.Fprintf(rc.stderr, "oac-daemon: `opencode --version` failed; opencode unavailable: %v\n", opencodeErr) + fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", opencodeagent.InstallURL) + } } - codexCtx, cancelCodex := context.WithTimeout(context.Background(), cliVersionTimeout) - codexVersion, codexErr := checks.Codex(codexCtx, "") - cancelCodex() - if codexErr == nil { - out.Codex.Available = true - out.Codex.Version = codexVersion - out.Codex.Capabilities.NativeSessionRecovery = codex.SupportsNativeSessionRecovery(codexVersion) - out.Codex.Capabilities.LocalEnvironment = codex.SupportsLocalEnvironment(codexVersion) - out.Codex.Capabilities.MCPHTTPRequired = codex.SupportsNativeSessionRecovery(codexVersion) - fmt.Fprintf(rc.stdout, "Codex preflight ok (%s)\n", codexVersion) - } else if errors.Is(codexErr, codex.ErrCLINotFound) { - fmt.Fprintln(rc.stderr, "oac-daemon: Codex CLI not found on PATH; codex unavailable.") - fmt.Fprintf(rc.stderr, " Install instructions: %s\n", codex.InstallURL) - } else { - fmt.Fprintf(rc.stderr, "oac-daemon: `codex --version` failed; codex unavailable: %v\n", codexErr) - fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", codex.InstallURL) + if out.permits("codex") { + codexCtx, cancelCodex := context.WithTimeout(parent, cliVersionTimeout) + codexVersion, codexErr := checks.Codex(codexCtx, "") + cancelCodex() + if codexErr == nil { + out.Codex.Available = true + out.Codex.Version = codexVersion + out.Codex.Capabilities.NativeSessionRecovery = codex.SupportsNativeSessionRecovery(codexVersion) + out.Codex.Capabilities.LocalEnvironment = codex.SupportsLocalEnvironment(codexVersion) + out.Codex.Capabilities.MCPHTTPRequired = codex.SupportsNativeSessionRecovery(codexVersion) + fmt.Fprintf(rc.stdout, "Codex preflight ok (%s)\n", codexVersion) + } else if errors.Is(codexErr, codex.ErrCLINotFound) { + fmt.Fprintln(rc.stderr, "oac-daemon: Codex CLI not found on PATH; codex unavailable.") + fmt.Fprintf(rc.stderr, " Install instructions: %s\n", codex.InstallURL) + } else { + fmt.Fprintf(rc.stderr, "oac-daemon: `codex --version` failed; codex unavailable: %v\n", codexErr) + fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", codex.InstallURL) + } } - piCtx, cancelPi := context.WithTimeout(context.Background(), cliVersionTimeout) - piVersion, piErr := checks.Pi(piCtx, "") - cancelPi() - if piErr == nil { - out.Pi.Available = true - out.Pi.Version = piVersion - fmt.Fprintf(rc.stdout, "pi preflight ok (%s)\n", piVersion) - } else if errors.Is(piErr, pi.ErrCLINotFound) { - fmt.Fprintln(rc.stderr, "oac-daemon: pi CLI not found on PATH; pi unavailable.") - fmt.Fprintf(rc.stderr, " Install instructions: %s\n", pi.InstallURL) - } else { - fmt.Fprintf(rc.stderr, "oac-daemon: `pi --version` failed; pi unavailable: %v\n", piErr) - fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", pi.InstallURL) + if out.permits("pi") { + piCtx, cancelPi := context.WithTimeout(parent, cliVersionTimeout) + piVersion, piErr := checks.Pi(piCtx, "") + cancelPi() + if piErr == nil { + out.Pi.Available = true + out.Pi.Version = piVersion + fmt.Fprintf(rc.stdout, "pi preflight ok (%s)\n", piVersion) + } else if errors.Is(piErr, pi.ErrCLINotFound) { + fmt.Fprintln(rc.stderr, "oac-daemon: pi CLI not found on PATH; pi unavailable.") + fmt.Fprintf(rc.stderr, " Install instructions: %s\n", pi.InstallURL) + } else { + fmt.Fprintf(rc.stderr, "oac-daemon: `pi --version` failed; pi unavailable: %v\n", piErr) + fmt.Fprintf(rc.stderr, " Re-install or upgrade: %s\n", pi.InstallURL) + } } - out.MCode = discoverMCode(rc, checks.MCode) - discoverMCodeWorkspace(rc, &out) - out.ClaudeSDK = discoverClaudeSDK(rc, profile, checks.ClaudeSDK) + if out.permits("mcode") { + out.MCode = discoverMCode(parent, rc, checks.MCode) + discoverMCodeWorkspace(parent, rc, &out) + } + if out.permits("claude_sdk") { + out.ClaudeSDK = discoverClaudeSDK(parent, rc, profile, checks.ClaudeSDK) + } + if err := parent.Err(); err != nil { + return out, err + } if !out.ClaudeCode.Available && !out.OpenCode.Available && !out.Codex.Available && !out.Pi.Available && !out.MCode.Available && (out.ClaudeSDK == nil || !out.ClaudeSDK.Info.Available) { return out, fmt.Errorf("connect: no supported agent CLI available (install Claude Code, OpenCode, Codex, pi, or mcode, or configure a Claude SDK runtime)") } diff --git a/apps/parsar-daemon/internal/cli/agent_registration.go b/apps/parsar-daemon/internal/cli/agent_registration.go index ea93bbf30..eb3bf55d8 100644 --- a/apps/parsar-daemon/internal/cli/agent_registration.go +++ b/apps/parsar-daemon/internal/cli/agent_registration.go @@ -13,9 +13,15 @@ import ( ) func registerAgentKinds(registry *agent.Registry, agentCLIs agentCLIDiscovery, serverURL string) { - registerProductAgentKind(registry, agentCLIs.ClaudeCode, withSkillUploadServer(withCapabilityDownloads(claudecode.Factory, serverURL), serverURL)) - registerProductAgentKind(registry, agentCLIs.OpenCode, withSkillUploadServer(withCapabilityDownloads(opencodeagent.Factory, serverURL), serverURL)) - registerProductAgentKind(registry, agentCLIs.Codex, withSkillUploadServer(withCapabilityDownloads(codex.Factory, serverURL), serverURL)) + registerProduct := func(info proto.SupportedAgentKind, factory agent.Factory) { + if agentCLIs.permits(info.Kind) { + registerProductAgentKind(registry, info, factory) + } + } + + registerProduct(agentCLIs.ClaudeCode, withSkillUploadServer(withCapabilityDownloads(claudecode.Factory, serverURL), serverURL)) + registerProduct(agentCLIs.OpenCode, withSkillUploadServer(withCapabilityDownloads(opencodeagent.Factory, serverURL), serverURL)) + registerProduct(agentCLIs.Codex, withSkillUploadServer(withCapabilityDownloads(codex.Factory, serverURL), serverURL)) if agentCLIs.Codex.Available { registry.RegisterExecutor("codex", withExecutorCapabilities(codex.NewExecutorFactory(), serverURL)) } @@ -28,12 +34,12 @@ func registerAgentKinds(registry *agent.Registry, agentCLIs agentCLIDiscovery, s return prepared, err }) } - registerProductAgentKind(registry, agentCLIs.Pi, withSkillUploadServer(withCapabilityDownloads(pi.Factory, serverURL), serverURL)) + registerProduct(agentCLIs.Pi, withSkillUploadServer(withCapabilityDownloads(pi.Factory, serverURL), serverURL)) if agentCLIs.MCodeWorkspace != nil { registry.RegisterKind(agentCLIs.MCode, mcode.Factory) registry.RegisterPreparation("mcode", true, mcode.NewPreparationFactory(*agentCLIs.MCodeWorkspace)) } else { - registerProductAgentKind(registry, agentCLIs.MCode, withSkillUploadServer(withCapabilityDownloads(mcode.Factory, serverURL), serverURL)) + registerProduct(agentCLIs.MCode, withSkillUploadServer(withCapabilityDownloads(mcode.Factory, serverURL), serverURL)) } if agentCLIs.MCode.Available { registry.RegisterExecutor("mcode", withExecutorCapabilities(mcode.NewExecutorFactory(agentCLIs.MCodeWorkspace), serverURL)) diff --git a/apps/parsar-daemon/internal/cli/claude_sdk.go b/apps/parsar-daemon/internal/cli/claude_sdk.go index 0b552bf2f..587867740 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk.go @@ -22,7 +22,7 @@ type claudeSDKDiscovery struct { Config claudesdk.Config } -func discoverClaudeSDK(rc *runContext, profile string, check func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error)) *claudeSDKDiscovery { +func discoverClaudeSDK(parent context.Context, rc *runContext, profile string, check func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error)) *claudeSDKDiscovery { entrypoint := os.Getenv(claudeSDKEntrypointEnv) if entrypoint == "" { return nil @@ -81,7 +81,7 @@ func discoverClaudeSDK(rc *runContext, profile string, check func(context.Contex if check == nil { check = claudesdk.CheckRuntime } - info, err := check(context.Background(), out.Config) + info, err := check(parent, out.Config) if err != nil { return fail(err) } diff --git a/apps/parsar-daemon/internal/cli/claude_sdk_live_linux_test.go b/apps/parsar-daemon/internal/cli/claude_sdk_live_linux_test.go index f9b137da4..468fd2681 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk_live_linux_test.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk_live_linux_test.go @@ -57,7 +57,7 @@ func TestLiveRegisteredClaudeSDK(t *testing.T) { t.Setenv(name, value) } stdout, stderr := &strings.Builder{}, &strings.Builder{} - discovery, err := discoverAgentCLIs(&runContext{stdout: stdout, stderr: stderr}, "acceptance", unavailableCLIChecks()) + discovery, err := discoverAgentCLIs(t.Context(), &runContext{stdout: stdout, stderr: stderr}, "acceptance", unavailableCLIChecks()) if err != nil || discovery.ClaudeSDK == nil || !discovery.ClaudeSDK.Info.Available { t.Fatal("SDK-only discovery failed", err) } diff --git a/apps/parsar-daemon/internal/cli/claude_sdk_test.go b/apps/parsar-daemon/internal/cli/claude_sdk_test.go index ce8eaa35f..eeca011c9 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk_test.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk_test.go @@ -57,7 +57,7 @@ func TestClaudeSDKDiscoveryAndRegistration(t *testing.T) { checks.Pi = func(context.Context, string) (string, error) { return "test-pi", nil } } stdout, stderr := &strings.Builder{}, &strings.Builder{} - discovery, err := discoverAgentCLIs(&runContext{stdout: stdout, stderr: stderr}, "test", checks) + discovery, err := discoverAgentCLIs(t.Context(), &runContext{stdout: stdout, stderr: stderr}, "test", checks) if (err == nil) != (tc.ready || tc.legacy) { t.Fatalf("startup readiness: %v", err) } @@ -116,7 +116,7 @@ func TestClaudeSDKInvalidPathsFailBeforeProbe(t *testing.T) { } else { t.Setenv("OAC_RUNTIME_HOME", "relative-home") } - out := discoverClaudeSDK(&runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "default", func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error) { + out := discoverClaudeSDK(t.Context(), &runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "default", func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error) { t.Fatal("invalid paths reached runtime probe") return claudesdk.RuntimeInfo{}, nil }) @@ -137,7 +137,7 @@ func TestClaudeSDKFeatureDiscovery(t *testing.T) { } t.Setenv(claudeSDKNodeEnv, node) for _, features := range [][]string{nil, {"mcp_http_tools"}, {"mcp_http_bearer_auth"}, {"mcp_http_tools", "mcp_http_bearer_auth"}, {"mcp_http_required"}, {"mcp_http_tools", "mcp_http_required"}, {"subagent_resources"}, {"structured_output"}} { - out := discoverClaudeSDK(&runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "default", func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error) { + out := discoverClaudeSDK(t.Context(), &runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "default", func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error) { info := claudesdk.RuntimeInfo{SDK: "0.3.269", Native: "2.1.269 (Claude Code)", Features: features} return info, nil }) diff --git a/apps/parsar-daemon/internal/cli/connect.go b/apps/parsar-daemon/internal/cli/connect.go index 400d49fd6..c6f216504 100644 --- a/apps/parsar-daemon/internal/cli/connect.go +++ b/apps/parsar-daemon/internal/cli/connect.go @@ -62,11 +62,19 @@ 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) } + installation, err := nativeInstallationPath() + if err != nil { + return err + } + if _, err = os.Lstat(installation); err == nil { + return errors.New("connect: this Runtime has a native installation; use oac-daemon start to validate its installed Harnesses") + } else if !errors.Is(err, os.ErrNotExist) { + return errors.New("connect: cannot inspect native installation; use oac-daemon start") + } // Hydrate inline pairing inputs from env in BOTH parent and the // re-execed background child. Server-spawned sandboxes pass the // token via OAC_RUNTIME_DAEMON_CONNECT_TOKEN/URL env rather than --url @@ -78,11 +86,13 @@ 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) + connectCtx, stop := daemonize.NotifyContext(context.Background()) + defer stop() + return runEnvironmentConnect(connectCtx, ctx, *profile, *background, *remote, *environment, *credentialFile) } inlinePair := strings.TrimSpace(*serverURL) != "" || strings.TrimSpace(*token) != "" @@ -113,12 +123,12 @@ func runConnect(ctx *runContext, args []string) error { argv = scrubInlineConnectArgs(os.Args) extraEnv = inlineConnectEnv(*serverURL, *token, *deviceName) } - return spawnBackground(ctx, *profile, argv, extraEnv) + return spawnBackground(context.Background(), ctx, *profile, argv, extraEnv) } // Self-check before pairing/loading credentials so a machine with // no supported agent CLI fails before consuming a one-shot token. - agentCLIs, err := preflightAgentCLIs(ctx, *profile) + agentCLIs, err := preflightAgentCLIs(context.Background(), ctx, *profile) if err != nil { return err } @@ -199,7 +209,7 @@ func resolveConnectProfile(profile, serverURL, token, deviceName string) (auth.P // returns after printing the child PID; child re-enters runConnect // with BackgroundSentinelEnv set so the same mainLoop runs in either // mode. -func spawnBackground(rc *runContext, profile string, argv []string, extraEnv []string) error { +func spawnBackground(ctx context.Context, rc *runContext, profile string, argv []string, extraEnv []string) error { logPath, err := paths.LogFile(profile) if err != nil { return fmt.Errorf("connect: %w", err) @@ -221,6 +231,9 @@ func spawnBackground(rc *runContext, profile string, argv []string, extraEnv []s return fmt.Errorf("connect: %w", err) } + if err := ctx.Err(); err != nil { + return err + } pid, err := daemonize.Spawn(argv, daemonize.ReExecOptions{ LogPath: logPath, PIDPath: pidPath, @@ -230,6 +243,12 @@ func spawnBackground(rc *runContext, profile string, argv []string, extraEnv []s return fmt.Errorf("connect: spawn background: %w", err) } + if err := ctx.Err(); err != nil { + if stopErr := daemonize.StopPIDFile(pidPath, killTimeout); stopErr != nil { + return fmt.Errorf("connect: interrupted startup cleanup: %w", stopErr) + } + return err + } fmt.Fprintf(rc.stdout, "oac-daemon: backgrounded (pid=%d)\n", pid) fmt.Fprintf(rc.stdout, " logs : %s\n", logPath) fmt.Fprintf(rc.stdout, " pid : %s\n", pidPath) @@ -242,10 +261,10 @@ func spawnBackground(rc *runContext, profile string, argv []string, extraEnv []s // unblocks the read pump and any in-flight Send so the daemon exits // without orphaning agent subprocesses. func mainLoop(rc *runContext, profile string, prof auth.Profile, agentCLIs agentCLIDiscovery) error { - return mainLoopRemote(rc, profile, prof, agentCLIs, "") + return mainLoopRemote(context.Background(), rc, profile, prof, agentCLIs, "") } -func mainLoopRemote(rc *runContext, profile string, prof auth.Profile, agentCLIs agentCLIDiscovery, remote string) error { +func mainLoopRemote(parent context.Context, rc *runContext, profile string, prof auth.Profile, agentCLIs agentCLIDiscovery, remote string) error { // Route through obs/log so daemon log lines pick up the same // trace_id / span_id auto-injection as the server side — when the // daemon adopts an envelope's trace, every log call under that ctx @@ -257,7 +276,7 @@ func mainLoopRemote(rc *runContext, profile string, prof auth.Profile, agentCLIs Out: rc.stderr, }) - rootCtx, cancel := daemonize.NotifyContext(context.Background()) + rootCtx, cancel := daemonize.NotifyContext(parent) defer cancel() bootCtx, bootCancel := context.WithTimeout(rootCtx, bootstrapTimeout) diff --git a/apps/parsar-daemon/internal/cli/connect_environment.go b/apps/parsar-daemon/internal/cli/connect_environment.go index 72ea39eb7..65440c9c1 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(parent context.Context, rc *runContext, profile string, background bool, remote, environment, credentialFile string) error { base, err := environmentBase(remote) if err != nil { return err @@ -148,30 +148,33 @@ 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 != "" { - return parkEnvironment(rc.stderr, message) + if message := environmentRejection(err, keyID, environment); parks && message != "" { + return parkEnvironment(parent, rc.stderr, message) } return err } - ctx, cancel := context.WithTimeout(context.Background(), bootstrapTimeout) + ctx, cancel := context.WithTimeout(parent, bootstrapTimeout) defer cancel() bound, err := enrollEnvironment(ctx, environmentClient(), base, environment, credential) if err != nil { return rejected(err) } + if err = parent.Err(); err != nil { + return err + } if err = bindEnvironmentRuntime(remote, bound, credentialFile); err != nil { return err } if background && !daemonize.IsBackgroundChild() { - return spawnBackground(rc, profile, os.Args, nil) + return spawnBackground(parent, rc, profile, os.Args, nil) } // Discovery consumes the immutable Runtime binding; it must follow enrollment. - discovery, err := preflightAgentCLIs(rc, profile) + discovery, err := preflightAgentCLIs(parent, rc, profile) if err != nil { return err } prof := auth.Profile{ServerURL: base, RuntimeID: bound.DeviceID, RunnerCredential: credential} - return rejected(mainLoopRemote(rc, profile, prof, discovery, remote)) + return rejected(mainLoopRemote(parent, rc, profile, prof, discovery, remote)) } var ( @@ -182,13 +185,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) @@ -204,8 +203,8 @@ func environmentRejection(err error, keyID, environment string, selfHosted bool) // until SIGINT or SIGTERM and exits successfully. Docker's unless-stopped policy // restarts every exit, so an exit would loop; a parked Runtime still restarts // after a reboot, makes one enrollment request and parks again. -func parkEnvironment(stderr io.Writer, message string) error { - ctx, stop := daemonize.NotifyContext(context.Background()) +func parkEnvironment(parent context.Context, stderr io.Writer, message string) error { + ctx, stop := daemonize.NotifyContext(parent) defer stop() fmt.Fprintln(stderr, "oac-daemon: "+message) <-ctx.Done() 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/connect_test.go b/apps/parsar-daemon/internal/cli/connect_test.go index af4464f15..c3077655e 100644 --- a/apps/parsar-daemon/internal/cli/connect_test.go +++ b/apps/parsar-daemon/internal/cli/connect_test.go @@ -72,7 +72,7 @@ func TestDiscoverAgentCLIsAllowsOpenCodeWithoutClaude(t *testing.T) { t.Setenv(claudeSDKEntrypointEnv, "") stdout, stderr := &strings.Builder{}, &strings.Builder{} rc := &runContext{stdout: stdout, stderr: stderr} - got, err := discoverAgentCLIs(rc, "default", agentCLIChecks{ + got, err := discoverAgentCLIs(t.Context(), rc, "default", agentCLIChecks{ MCode: func(context.Context, string) (string, error) { return "", mcode.ErrCLINotFound }, ClaudeCode: func(context.Context, string) (string, error) { return "", claudecode.ErrCLINotFound @@ -117,7 +117,7 @@ func TestDiscoverAgentCLIsBothMissingFails(t *testing.T) { t.Setenv(claudeSDKEntrypointEnv, "") stdout, stderr := &strings.Builder{}, &strings.Builder{} rc := &runContext{stdout: stdout, stderr: stderr} - got, err := discoverAgentCLIs(rc, "default", agentCLIChecks{ + got, err := discoverAgentCLIs(t.Context(), rc, "default", agentCLIChecks{ MCode: func(context.Context, string) (string, error) { return "", mcode.ErrCLINotFound }, ClaudeCode: func(context.Context, string) (string, error) { return "", claudecode.ErrCLINotFound @@ -147,7 +147,7 @@ func TestDiscoverAgentCLIsBothAvailable(t *testing.T) { t.Setenv(claudeSDKEntrypointEnv, "") stdout, stderr := &strings.Builder{}, &strings.Builder{} rc := &runContext{stdout: stdout, stderr: stderr} - got, err := discoverAgentCLIs(rc, "default", agentCLIChecks{ + got, err := discoverAgentCLIs(t.Context(), rc, "default", agentCLIChecks{ MCode: func(context.Context, string) (string, error) { return "0.4.12", nil }, ClaudeCode: func(context.Context, string) (string, error) { return "claude 2.0.0", nil diff --git a/apps/parsar-daemon/internal/cli/mcode.go b/apps/parsar-daemon/internal/cli/mcode.go index da0fc60ac..a8bf3e807 100644 --- a/apps/parsar-daemon/internal/cli/mcode.go +++ b/apps/parsar-daemon/internal/cli/mcode.go @@ -8,12 +8,12 @@ import ( "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" ) -func discoverMCode(rc *runContext, check func(context.Context, string) (string, error)) proto.SupportedAgentKind { +func discoverMCode(parent context.Context, rc *runContext, check func(context.Context, string) (string, error)) proto.SupportedAgentKind { if check == nil { check = mcode.CheckCLIAvailable } result := proto.SupportedAgentKind{Kind: "mcode", Capabilities: proto.AgentKindCapabilities{Streaming: true, Permissions: true, Resume: true}} - ctx, cancel := context.WithTimeout(context.Background(), cliVersionTimeout) + ctx, cancel := context.WithTimeout(parent, cliVersionTimeout) defer cancel() version, err := check(ctx, "") if err != nil { diff --git a/apps/parsar-daemon/internal/cli/mcode_execution_test.go b/apps/parsar-daemon/internal/cli/mcode_execution_test.go index 1203f20c6..1f03f381f 100644 --- a/apps/parsar-daemon/internal/cli/mcode_execution_test.go +++ b/apps/parsar-daemon/internal/cli/mcode_execution_test.go @@ -14,7 +14,7 @@ func TestMCodeExecutionOptInIsVersionBound(t *testing.T) { t.Run(tc.enabled+"/"+tc.version, func(t *testing.T) { t.Setenv("OAC_RUNTIME_MCODE_AGENTS_API", tc.enabled) rc := &runContext{stdout: io.Discard, stderr: io.Discard} - info := discoverMCode(rc, func(context.Context, string) (string, error) { return tc.version, nil }) + info := discoverMCode(t.Context(), rc, func(context.Context, string) (string, error) { return tc.version, nil }) if !info.Available || info.Capabilities.EnvironmentNone != tc.qualified || info.Capabilities.DurableInputReceipts != tc.qualified || info.Capabilities.SubagentObservations != tc.qualified { t.Fatalf("capabilities=%+v", info.Capabilities) } diff --git a/apps/parsar-daemon/internal/cli/mcode_workspace.go b/apps/parsar-daemon/internal/cli/mcode_workspace.go index c3cc98fe4..e100edb4e 100644 --- a/apps/parsar-daemon/internal/cli/mcode_workspace.go +++ b/apps/parsar-daemon/internal/cli/mcode_workspace.go @@ -13,7 +13,7 @@ import ( "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/paths" ) -func discoverMCodeWorkspace(rc *runContext, discovery *agentCLIDiscovery) { +func discoverMCodeWorkspace(parent context.Context, rc *runContext, discovery *agentCLIDiscovery) { fail := func(err error) { discovery.MCode.Available = false fmt.Fprintf(rc.stderr, "oac-daemon: mcode workspace unavailable: %v\n", err) @@ -61,7 +61,7 @@ func discoverMCodeWorkspace(rc *runContext, discovery *agentCLIDiscovery) { } c, err := mcode.ConfigureLocal(binary, node, os.Getenv("OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE"), root, os.Getenv("OAC_RUNTIME_WORKSPACE"), binding.NetworkPolicy()) if err == nil { - err = mcode.CheckWorkspace(context.Background(), c) + err = mcode.CheckWorkspace(parent, c) } if err != nil { fail(err) diff --git a/apps/parsar-daemon/internal/cli/mcp_test.go b/apps/parsar-daemon/internal/cli/mcp_test.go index 1ff92f69e..aee418290 100644 --- a/apps/parsar-daemon/internal/cli/mcp_test.go +++ b/apps/parsar-daemon/internal/cli/mcp_test.go @@ -12,7 +12,7 @@ func TestMCPHTTPBearerDiscoveryExcludesUnconfiguredSDK(t *testing.T) { t.Setenv(claudeSDKEntrypointEnv, "") checks := unavailableCLIChecks() checks.Codex = func(context.Context, string) (string, error) { return "codex 0.153.4", nil } - discovery, err := discoverAgentCLIs(&runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "test", checks) + discovery, err := discoverAgentCLIs(t.Context(), &runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "test", checks) if err != nil { t.Fatal(err) } @@ -32,7 +32,7 @@ func TestMCPRequiredDiscoveryRequiresPinnedNative(t *testing.T) { for _, version := range []string{"codex-cli 0.153.4", "codex-cli 0.153.3", "codex-cli 0.154.0"} { checks := unavailableCLIChecks() checks.Codex = func(context.Context, string) (string, error) { return version, nil } - got, err := discoverAgentCLIs(&runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "test", checks) + got, err := discoverAgentCLIs(t.Context(), &runContext{stdout: &strings.Builder{}, stderr: &strings.Builder{}}, "test", checks) if err != nil { t.Fatal(err) } 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..51f6adb34 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_bundle.go @@ -0,0 +1,230 @@ +package cli + +import ( + "context" + "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 = 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) + 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(ctx context.Context, 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 err := ctx.Err(); err != nil { + return err + } + 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), nativeCopyReader{ctx: ctx, Reader: 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 + } + if err := ctx.Err(); 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) + spec, ok := nativeHarnesses[name] + if !ok { + return nil, errors.New("install: --harness requires codex,claude,minimax (comma-separated)") + } + 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) + } + } + slices.Sort(out) + return out, nil +} + +type nativeCopyReader struct { + io.Reader + ctx context.Context +} + +func (r nativeCopyReader) Read(p []byte) (int, error) { + if err := r.ctx.Err(); err != nil { + return 0, err + } + return r.Reader.Read(p) +} diff --git a/apps/parsar-daemon/internal/cli/native_discovery_test.go b/apps/parsar-daemon/internal/cli/native_discovery_test.go new file mode 100644 index 000000000..67a74543d --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_discovery_test.go @@ -0,0 +1,62 @@ +package cli + +import ( + "context" + "io" + "os" + "path/filepath" + "reflect" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent/claudesdk" +) + +func TestNativeInstallationDiscoversAndRegistersOnlySelectedHarness(t *testing.T) { + for _, name := range []string{"codex", "claude", "minimax"} { + t.Run(name, func(t *testing.T) { + root := t.TempDir() + t.Setenv("OAC_RUNTIME_HOME", root) + t.Setenv(claudeSDKEntrypointEnv, filepath.Join(root, "host-claude", "main.js")) + binary, err := os.Executable() + if err != nil { + t.Fatal(err) + } + t.Setenv(claudeSDKNodeEnv, binary) + called := make(map[string]bool) + check := func(kind, version string) func(context.Context, string) (string, error) { + return func(context.Context, string) (string, error) { called[kind] = true; return version, nil } + } + checks := agentCLIChecks{ + ClaudeCode: check("claude_code", "2.1.269"), + OpenCode: check("opencode", "1.0.0"), + Codex: check("codex", "codex-cli 0.153.4"), + Pi: check("pi", "0.1.0"), + MCode: check("mcode", "0.4.12"), + ClaudeSDK: func(context.Context, claudesdk.Config) (claudesdk.RuntimeInfo, error) { + called["claude_sdk"] = true + return claudesdk.RuntimeInfo{SDK: "0.3.269"}, nil + }, + } + kinds := nativeInstallationKinds([]string{name}) + rc := &runContext{stdout: io.Discard, stderr: io.Discard, installedKinds: kinds} + path := os.Getenv("PATH") + discovery, err := discoverAgentCLIs(t.Context(), rc, "default", checks) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(called, kinds) { + t.Fatalf("probed unselected host Harness: got %v, want %v", called, kinds) + } + registry := agent.NewRegistry() + registerAgentKinds(registry, discovery, "") + expected := []string{nativeHarnesses[name].AgentKind} + if !reflect.DeepEqual(registry.Kinds(), expected) { + t.Fatalf("registered unselected host Harness: %v", registry.Kinds()) + } + if os.Getenv("PATH") != path { + t.Fatal("installation discovery removed the tool PATH") + } + }) + } +} 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..41d6cb766 --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_harness.go @@ -0,0 +1,103 @@ +package cli + +import ( + "context" + "errors" + "fmt" + + "os" + "path/filepath" + "runtime" + "strings" + + "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/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 { + 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) + 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 +} + +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 +} + +// 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(ctx context.Context, root string, selected []string) error { + env := withNativeEnv(nativeHarnessEnvironment(root, selected)) + node := nativeNode(root) + version, err := installroot.Probe(ctx, node, []string{"--version"}, env, root) + if err != nil || version != "v"+nativePins["node"] { + return errors.New("install: bundled Node is unavailable or incompatible; use the matching native distribution") + } + for _, name := range selected { + spec, ok := nativeHarnesses[name] + if !ok || !spec.Supported() { + return fmt.Errorf("install: %s is unsupported on this platform", name) + } + if err = spec.Check(ctx, nativeComponentRoot(root, name), node, env); err != nil { + return fmt.Errorf("install: %s; no installed files were replaced", err) + } + } + return nil +} + +// Installed discovery is confined to verified adapters; ordinary tool PATH +// remains available to the selected Harness and its tools. +func nativeInstallationKinds(selected []string) map[string]bool { + kinds := make(map[string]bool, len(selected)) + for _, name := range selected { + kinds[nativeHarnesses[name].AgentKind] = true + } + return kinds +} diff --git a/apps/parsar-daemon/internal/cli/native_install.go b/apps/parsar-daemon/internal/cli/native_install.go index e1273a284..17ee20d0c 100644 --- a/apps/parsar-daemon/internal/cli/native_install.go +++ b/apps/parsar-daemon/internal/cli/native_install.go @@ -1,26 +1,34 @@ package cli import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "errors" + "flag" "fmt" + "io" "os" "path/filepath" + "slices" + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/daemonize" "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 +39,290 @@ 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(*credential, *environment); err != nil { + if _, _, err = executorCredential(c.Credential, c.Environment); err != nil { return err } - if *toolEnv != "" && runtimefs.ValidateLocalPath(*toolEnv) != nil { - return errors.New("install: absolute tool environment file required") + 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 } - config := nativeInstallation{Version: Version, Remote: *remote, Environment: *environment, Workspace: *workspace, Credential: *credential, CapabilityDirectory: *capabilities, ToolEnvironmentFile: *toolEnv} - path, err := nativeInstallationPath() if err != nil { return err } - if err = os.MkdirAll(filepath.Dir(path), 0700); err != nil { + ctx, stop := daemonize.NotifyContext(context.Background()) + defer stop() + 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") + } + selected, err := selectedNativeHarnesses(o.Harness) + if err != nil { + return err + } + 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, len(previous.Harnesses) > 0); err != nil { + return err + } + for _, name := range append([]string{"node"}, selected...) { + if err = installNativeComponent(ctx, o.Bundle, o.Directory, name, bundle.Components[name]); err != nil { + return err + } + } + if err = probeNativeInstallation(ctx, o.Directory, all); err != nil { return err } - raw, _ := json.MarshalIndent(config, "", " ") - if err = runtimefs.WritePrivateAtomic(held, filepath.Base(path), raw); err != nil { + if err = ctx.Err(); 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.") + o.Harnesses = all + raw, _ = json.MarshalIndent(o.nativeInstallation, "", " ") + if err = runtimefs.WritePrivateAtomic(held, "installation.json", raw); err != nil { + return err + } + 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 check Host connection in Core.") + 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, existing bool) 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 + } + if existing { + return errors.New("install: existing daemon binary is missing; preserve the installation and reinstall separately") + } + 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 { + ctx, stop := daemonize.NotifyContext(context.Background()) + defer stop() 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 config.Version != Version { - return errors.New("start: this installation version is unsupported; reinstall explicitly") + if err == nil { + err = verifyNativeComponents(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, - } { + if err == nil { + err = probeNativeInstallation(ctx, root, config.Harnesses) + } + unlock() + if err != nil { + return fmt.Errorf("start: installation unavailable or incompatible: %w", err) + } + previousKinds := rc.installedKinds + rc.installedKinds = nativeInstallationKinds(config.Harnesses) + defer func() { rc.installedKinds = previousKinds }() + 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(ctx, 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_interrupt_unix_test.go b/apps/parsar-daemon/internal/cli/native_install_interrupt_unix_test.go new file mode 100644 index 000000000..d8a7e84da --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_install_interrupt_unix_test.go @@ -0,0 +1,108 @@ +//go:build unix + +package cli + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +func TestNativeInstallerInterruptHelper(t *testing.T) { + if os.Getenv("OAC_TEST_INSTALL_INTERRUPT") != "1" { + t.Skip("subprocess helper") + } + var args []string + if json.Unmarshal([]byte(os.Getenv("OAC_TEST_INSTALL_ARGS")), &args) != nil { + os.Exit(3) + } + err := runInstall(&runContext{stdout: io.Discard, stderr: io.Discard}, args) + if err != nil { + os.Exit(2) + } + os.Exit(0) +} + +func TestNativeInstallationInterruptReapsProbe(t *testing.T) { + _, args, root, bundle := nativeInstallFixture(t) + marker := filepath.Join(t.TempDir(), "probe-pids") + var b nativeBundle + if err := readNativeJSON(filepath.Join(bundle, "bundle.json"), &b); err != nil { + t.Fatal(err) + } + // A real, separately grouped subprocess must be reaped by installation's + // signal handler, not just by a caller returning a synthetic probe error. + script := []byte("#!/bin/sh\nsleep 300 &\nprintf '%s %s' \"$$\" \"$!\" > \"$OAC_TEST_PROBE_PIDS\"\nwait\n") + sum := sha256.Sum256(script) + b.Components["node"] = nativeComponent{Version: nativePins["node"], Files: map[string]nativeFile{"bin/node": {SHA256: hex.EncodeToString(sum[:]), Executable: true}}} + dir := filepath.Join(nativeComponentRoot(bundle, "node"), "bin") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "node"), script, 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) + } + arguments, _ := json.Marshal(args) + cmd := exec.Command(os.Args[0], "-test.run=^TestNativeInstallerInterruptHelper$") + cmd.Env = append(os.Environ(), "OAC_TEST_INSTALL_INTERRUPT=1", "OAC_TEST_INSTALL_ARGS="+string(arguments), "OAC_TEST_PROBE_PIDS="+marker) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + defer func() { _ = cmd.Process.Kill() }() + var pids []string + deadline := time.Now().Add(10 * time.Second) + for time.Now().Before(deadline) { + if data, err := os.ReadFile(marker); err == nil { + pids = strings.Fields(string(data)) + if len(pids) == 2 { + break + } + } + time.Sleep(20 * time.Millisecond) + } + if len(pids) != 2 { + t.Fatal("probe did not start") + } + defer func() { + for _, id := range pids { + pid, _ := strconv.Atoi(id) + p, _ := os.FindProcess(pid) + _ = p.Kill() + } + }() + if err := cmd.Process.Signal(os.Interrupt); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err == nil { + t.Fatal("interrupted installation succeeded") + } + case <-time.After(5 * time.Second): + t.Fatal("installer did not settle after interrupt") + } + for _, pid := range pids { + state, _ := exec.Command("ps", "-p", pid, "-o", "stat=").Output() + if s := strings.TrimSpace(string(state)); s != "" && !strings.HasPrefix(s, "Z") { + t.Fatal("interrupted installer left a running native process") + } + } + if _, err := os.Stat(filepath.Join(root, "daemon", "installation.json")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("interrupted installation committed settings") + } +} diff --git a/apps/parsar-daemon/internal/cli/native_install_test.go b/apps/parsar-daemon/internal/cli/native_install_test.go index 21d21403f..0deb2f5b2 100644 --- a/apps/parsar-daemon/internal/cli/native_install_test.go +++ b/apps/parsar-daemon/internal/cli/native_install_test.go @@ -2,58 +2,268 @@ package cli import ( "bytes" + "context" + "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(context.Context, 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(context.Context, 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(context.Context, 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") + } + } +} + +func TestNativeInstallationDoesNotRepairMissingDaemon(t *testing.T) { + rc, args, root, _ := nativeInstallFixture(t) + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + binary := filepath.Join(root, "bin", nativeExe("oac-daemon")) + if err := os.Remove(binary); err != nil { + t.Fatal(err) + } + if err := runInstall(rc, args); err == nil { + t.Fatal("silently repaired a modified installation") + } + if _, err := os.Stat(binary); !errors.Is(err, os.ErrNotExist) { + t.Fatal("recreated a removed daemon") + } +} + +func TestNativeInstallationConnectCannotBypassValidation(t *testing.T) { + rc, args, root, _ := nativeInstallFixture(t) + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + for _, mode := range []string{"installed", "missing-component"} { + t.Run(mode, func(t *testing.T) { + if mode == "missing-component" { + if err := os.Remove(filepath.Join(nativeComponentRoot(root, "codex"), "program")); err != nil { + t.Fatal(err) + } + } + for _, connection := range [][]string{ + nil, + {"--remote", "ws://127.0.0.1:1/api/v1/agent-daemon/ws"}, + {"--url", "http://127.0.0.1:1", "--token", "private-test-token"}, + } { + err := runConnect(rc, connection) + if err == nil || !strings.Contains(err.Error(), "use oac-daemon start") || strings.Contains(err.Error(), "private-test-token") { + t.Fatal("connect bypassed native installation validation", err) + } + } + }) } } diff --git a/apps/parsar-daemon/internal/cli/native_start_interrupt_unix_test.go b/apps/parsar-daemon/internal/cli/native_start_interrupt_unix_test.go new file mode 100644 index 000000000..316498fda --- /dev/null +++ b/apps/parsar-daemon/internal/cli/native_start_interrupt_unix_test.go @@ -0,0 +1,93 @@ +//go:build unix + +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestNativeStartInterruptHelper(t *testing.T) { + if os.Getenv("OAC_TEST_START_INTERRUPT") != "1" { + t.Skip("subprocess helper") + } + probeNativeInstallation = func(context.Context, string, []string) error { return nil } + if err := runStart(&runContext{stdout: os.Stdout, stderr: os.Stderr}, nil); err != nil { + os.Exit(2) + } + os.Exit(0) +} + +func TestNativeStartInterruptCancelsEnrollment(t *testing.T) { + rc, args, root, _ := nativeInstallFixture(t) + requested := make(chan struct{}) + release := make(chan struct{}) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + close(requested) + <-release + var config nativeInstallation + if err := readNativeJSON(filepath.Join(root, "daemon", "installation.json"), &config); err != nil { + return + } + _ = json.NewEncoder(w).Encode(environmentEnrollment{ + DeviceID: "aaaaaaaa-1111-4111-8111-aaaaaaaaaaaa", + SessionID: "bbbbbbbb-1111-4111-8111-bbbbbbbbbbbb", + EnvironmentID: config.Environment, + WorkspaceDirectory: config.Workspace, + }) + })) + defer server.Close() + defer close(release) + for i := range args { + if args[i] == "--remote" { + args[i+1] = "ws" + strings.TrimPrefix(server.URL, "http") + "/api/v1/agent-daemon/ws" + } + } + if err := runInstall(rc, args); err != nil { + t.Fatal(err) + } + cmd := exec.Command(os.Args[0], "-test.run=^TestNativeStartInterruptHelper$") + cmd.Env = append(os.Environ(), "OAC_TEST_START_INTERRUPT=1") + output := new(bytes.Buffer) + cmd.Stdout, cmd.Stderr = output, output + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer func() { _ = cmd.Process.Kill() }() + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case <-requested: + case <-time.After(10 * time.Second): + t.Fatal("start did not reach enrollment") + } + if err := cmd.Process.Signal(os.Interrupt); err != nil { + t.Fatal(err) + } + select { + case err := <-done: + if err == nil { + t.Fatal("interrupted startup succeeded") + } + case <-time.After(5 * time.Second): + _ = cmd.Process.Kill() + <-done + t.Fatal("start ignored interrupt while enrollment was pending") + } + if strings.Contains(output.String(), "backgrounded") { + t.Fatal("interrupted startup spawned a daemon") + } + if _, err := os.Stat(filepath.Join(root, "daemon", "default", "connect.pid")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("interrupted startup published a background process") + } +} diff --git a/apps/parsar-daemon/internal/cli/root.go b/apps/parsar-daemon/internal/cli/root.go index 441da1bc7..c8189653a 100644 --- a/apps/parsar-daemon/internal/cli/root.go +++ b/apps/parsar-daemon/internal/cli/root.go @@ -19,21 +19,23 @@ type command struct { run func(ctx *runContext, args []string) error } -// runContext bundles the streams a command writes to. Tests inject -// buffers; production uses the OS streams. +// runContext carries command I/O and an optional installed Harness selection. +// Tests inject streams; production uses the OS streams. type runContext struct { - stdout io.Writer - stderr io.Writer + installedKinds map[string]bool + 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 +54,7 @@ func Execute(argv []string) error { } func execute(ctx *runContext, argv []string) error { + useInstalledNativeHome() if err := validateRuntimeConfiguration(); err != nil { return err } diff --git a/apps/parsar-daemon/internal/cli/status.go b/apps/parsar-daemon/internal/cli/status.go index fff3cfda9..dc1e8d4e0 100644 --- a/apps/parsar-daemon/internal/cli/status.go +++ b/apps/parsar-daemon/internal/cli/status.go @@ -32,7 +32,7 @@ func runStatus(ctx *runContext, args []string) error { prof, err := auth.Load(*profile) switch { case errors.Is(err, auth.ErrNotPaired): - fmt.Fprintln(ctx.stdout, "paired : no legacy profile (use `oac-daemon connect --url ... --token ...`)") + fmt.Fprintln(ctx.stdout, "paired : no saved pairing profile; check Host connection in Core for a self-hosted Runtime") case err != nil: fmt.Fprintf(ctx.stdout, "paired : ERROR — %v\n", err) default: diff --git a/apps/web/DESIGN.md b/apps/web/DESIGN.md index ef8ab9f2f..af7e292af 100644 --- a/apps/web/DESIGN.md +++ b/apps/web/DESIGN.md @@ -495,16 +495,18 @@ 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. + Reconnection after credential rotation requires `stop`, replacement of the + configured credential file, then `start`; disconnection does not imply process exit. + 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 +528,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..d97b1ea73 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,20 @@ 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, stops the installed daemon, replaces the host credential file + and starts the daemon again. +- **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 stopping the installed daemon, replacing the + configured file and starting that same daemon again. A disconnected daemon may + still be running; `start` alone does not replace it. 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..84ceb0b11 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("Run the installed daemon’s stop command, replace the configured credential file on the host, then run start to reconnect."); 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. Run the installed daemon’s stop command, replace its configured credential file, then run start."); 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}