# Design — BooControl SSH editor verb-mode + model pull ## Files touched - `apps/control/src/services/ssh-config.ts` — add the `RemoteOps` seam + `shellOps`/`wrapperOps`; thread `mode` through `readRemoteConfig`/`applyRemoteConfig`. - `apps/control/src/services/model-pull.ts` (new) — non-blocking pull job runner. - `apps/control/src/routes/ssh-config.ts` — accept `sshMode` in PATCH; pass mode to read/diff/apply; add `POST /api/hosts/:id/pull`. - `apps/control/src/schema.sql` — `ALTER TABLE control_hosts ADD COLUMN IF NOT EXISTS ssh_mode TEXT NOT NULL DEFAULT 'shell'`. - `apps/web/src/components/control/HostConfigEditor.tsx` — SSH-mode selector + Pull-model field. - `apps/control/src/services/__tests__/ssh-config.test.ts` — add wrapper-mode mapping tests (keep existing shell-mode tests). - `apps/control/src/services/__tests__/model-pull.test.ts` (new) — repo-id validation + verb emission. ## RemoteOps seam ```ts interface RemoteOps { read(): Promise; // throws on failure backup(now: Date): Promise; // returns backup path write(content: string): Promise; // throws on failure restart(restartCmd: string): Promise; } // shell: today's behavior — emits `cat 'p'`, `cp 'p' 'p.bak-ts'`, `cat > 'p'`, restartCmd. function shellOps(target, configPath, exec): RemoteOps // wrapper: emits the verbs `read` / `backup` / `write`(stdin) / `restart`. function wrapperOps(target, exec): RemoteOps ``` `applyRemoteConfig` selects ops from `opts.mode` (default `'shell'`). Shell `backup` computes the name via `backupFilename` then `cp`; wrapper `backup` sends the `backup` verb and reads the returned path from stdout (the wrapper stamps it). Everything else (validate, diff via `computeDiff`, health-wait) is unchanged, so the existing shell-mode tests pass byte-for-byte. ## Pull job `runModelPull({ target, repo, mode }, exec, emitter)`: 1. Validate `repo` against `^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$`; reject early. 2. `exec(target, 'pull ' + repo)` (wrapper) or `exec(target, 'huggingface-cli download ' + repo + ' --local-dir /...')` (shell). Wrapper mode is the supported path; shell mode requires a `models_dir` and is best-effort. 3. Publish `control_job` frames: `running` at start, `completed`/`failed` at end, `detail.kind = 'pull'`, `detail.repo`, and tail output in `detail.line`. Reuses jobType `action` from the existing `ControlJobFrame` (no contracts change). ## Backward compatibility - `ssh_mode` defaults to `shell` -> existing hosts behave exactly as P9.1. - `applyRemoteConfig` `mode` defaults to `shell` -> existing call sites + tests unchanged. - No `control_job` schema change; the web `useControlStream` already accepts `jobType: 'action'`. ## Implementation notes (sam-desktop host findings, 2026-06-13) - **Windows wrapper must target PowerShell 5.1.** sam-desktop's default `powershell` is Windows PowerShell 5.1, which lacks the `??` null-coalescing operator. `boocontrol-edit.ps1` was changed to an explicit `if ($null -eq $cmd)` guard. Verb chain verified live: `read` returns the real config, `whoami` -> denied, `pull ../x` -> bad repo id. - **This host's `sshd_config` has no `Match Group administrators` block**, so sshd uses the per-user `~/.ssh/authorized_keys` for the admin user `samki` (NOT `administrators_authorized_keys`, which is silently ignored). The forced-command key must go in `C:\Users\samki\.ssh\authorized_keys`. (Stock Windows OpenSSH ships the admin-match block; this install's is stripped.) - **No `Subsystem sftp`** in this host's `sshd_config`, so `scp`/`sftp` fail ("subsystem request failed"). Deploy the wrapper via `powershell -EncodedCommand` (base64 UTF-16LE) over the exec channel, or add `Subsystem sftp sftp-server.exe` + restart sshd. The go-live runbook uses the encoded-command method. ## Validation lenses folded in - **V1 (adversarial):** wrapper `backup` must return the path the wrapper chose, not a client-computed one (clock skew between control host and GPU host) -> wrapper `backup` reads stdout. - **V2 (adversarial):** a `wrapper`-mode host without the script must fail loudly -> verbs surface the non-zero exit + stderr per pipeline step; no shell fallback. - **JD1 (junior):** server-side repo validation duplicates the wrapper's -> intentional defense in depth; documented. - **JD2 (junior):** reusing jobType `action` keeps the change additive; a dedicated `pull` type is deferred (would touch contracts + web union) with reopen trigger "if pull needs distinct UI filtering."