Files
boocode/openspec/changes/boocontrol-ssh-verbmode/design.md

4.5 KiB

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.sqlALTER 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

interface RemoteOps {
  read(): Promise<string>;               // throws on failure
  backup(now: Date): Promise<string>;    // returns backup path
  write(content: string): Promise<void>; // throws on failure
  restart(restartCmd: string): Promise<void>;
}

// 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 <modelsDir>/...') (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."