Skip to content

feat(runner): per-request workspace_path override (multi-project on one harnessd) #559

Description

@dennisonbertram

Problem

A single harnessd instance is bound to a single workspace at startup (`HARNESS_WORKSPACE` env var, default cwd). The tool catalog is built once with `BuildOptions{WorkspaceRoot: workspace}` (`cmd/harnessd/main.go:447`) and every tool — Read, Write, Edit, Bash, Grep, Ls, Glob — resolves paths relative to that single root. Two runs against two different projects step on each other's files.

Today's options:

  1. One harnessd per project — works, but burns a process per repo (~26 MB RSS each).
  2. `workspace_type: "worktree"` — provisions a git worktree per run, but only useful for branches off the SAME base repo (`baseOpts.RepoPath` is set once at startup).
  3. `workspace_type: "container"` — passes `validateWorkspaceType` but `provisionRunWorkspace` errors with "unknown workspace type" in standalone harnessd. Container mode is only wired through `symphd`.
  4. `symphd` — the orchestrator does support multi-project dispatch via the workspace pool. But that's a heavier setup than "I just want to point my one harnessd at two repos."

Proposal

Add a `workspace_path` field to `RunRequest` (`internal/harness/types.go:268`) that overrides the runner's startup workspace for that single run. No provisioning, no isolation — just rebind the tool catalog's `WorkspaceRoot` per run.

```go
type RunRequest struct {
// ... existing fields ...

// WorkspaceRoot, when set, overrides the harnessd's startup workspace
// for filesystem and shell tools in this run. Path must be absolute and
// exist; relative paths are rejected at StartRun time. Empty falls back
// to the server's startup HARNESS_WORKSPACE.
//
// This is a non-isolating override: the run shares the host filesystem
// with the parent process. For isolation use WorkspaceType ("worktree",
// "container", "vm") instead.
WorkspaceRoot string \`json:"workspace_root,omitempty"\`

}
```

Wiring sketch

The tool catalog is currently built once at startup (`cmd/harnessd/main.go:447` calls `harness.NewDefaultRegistryWithOptions(workspace, ...)`). To honor per-request overrides, the runner needs to either:

  • Option A (simpler): at StartRun time, when `req.WorkspaceRoot` is non-empty and ≠ startup workspace, build a fresh tool registry scoped to that path and stash it on the runState. `BuildToolList` already takes `BuildOptions` so this is mostly threading.
  • Option B (more invasive): parameterize `WorkspaceRoot` on every tool call rather than baking it at registry construction time. Bigger refactor; better long-term.

Option A is the smallest change and fits the existing pattern.

Validation

  • `req.WorkspaceRoot` must be absolute (`filepath.IsAbs`).
  • Must exist and be a directory (`os.Stat`).
  • Should be cleaned (`filepath.Clean`) and resolved (`filepath.EvalSymlinks`) before binding.
  • Optional safety: a startup config knob `workspace_root_allowlist` to restrict which paths are accepted (defense against using harnessd as an arbitrary-fs-access proxy across tenant boundaries).

Acceptance

  • POST /v1/runs with `{"prompt": "...", "workspace_root": "/some/other/repo"}` runs all tools rooted at `/some/other/repo`.
  • Two runs with different `workspace_root` values, started in flight, do not interfere.
  • Tool errors (e.g. `Read` of a path outside `workspace_root`) are scoped to that run's root, not the startup root.
  • Smoke test: build the URL shortener (`/tmp/harness-demo-shortener`) and the calc test (`/tmp/harness-sandbox-20260429/repo`) concurrently against one harnessd.

References

  • `internal/harness/types.go:268` — RunRequest struct
  • `cmd/harnessd/main.go:447` — registry construction
  • `internal/harness/runner.go:683` — `provisionRunWorkspace` flow (separate from this; this issue is about non-provisioning rebind)
  • Investigation: `docs/investigations/2026-04-29-harness-real-world-build.md` — proves single-project flow works end-to-end via direct API.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    deferredPlanned but not current priorityenhancementNew feature or requestneeds-clarificationIssue needs more detail before implementation

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions