Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,15 @@ same values:
`BASEURL` out of the Spinloop and still point your agent at the endpoint. A
`BASEURL` in the Spinloop wins if you do set one.

Deployed something and just want to point an agent at it from another
machine? `spinloop harness --env dev-2` on its own — no Spinloop at all —
configures the harness straight from what is deployed there:

```sh
spinloop remote deploy path/to/Spinloop --env dev-2 # from wherever you deployed it
spinloop harness --env dev-2 --prompt "..." # from anywhere with dev-2 registered
```

Every URL and the region can be overridden with the matching
[`SPINLOOP_REMOTE_*`](docs/env-vars.md) environment variable. The commands
sign with an AWS credential resolved per region: explicit environment
Expand Down
57 changes: 25 additions & 32 deletions cmd/spinloop/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,15 @@ func harnessCmd() *cobra.Command {
Use: "harness",
Short: "launch the active harness, optionally applying a Spinloop first",
Long: `launches the active harness, forwarding any trailing args to it. A
leading argument that names a Spinloop — a registered alias or a path — is
applied first and not forwarded; put -- before the harness's own args to
keep them, and a leading -- opts out of this entirely. --spinloop/-O applies
a Spinloop first, as if you had run apply before it. --get prints the active
harness instead of launching it; --set <name> stores the default harness and
exits. Honours -H/--harness and SPINLOOP_HARNESS.`,
Spinloop — a registered alias or a path — can be named anywhere among
spinloop's own flags (--env, -H, --spinloop, --fleet, ...), in any order; the
first argument that is neither one of those flags nor a Spinloop name starts
the harness's own args, forwarded byte-for-byte from there. Put -- before the
harness's own args if one of them would otherwise be mistaken for a Spinloop
name, and a leading -- opts out of Spinloop-naming entirely. --spinloop/-O
applies a Spinloop first, as if you had run apply before it. --get prints the
active harness instead of launching it; --set <name> stores the default
harness and exits. Honours -H/--harness and SPINLOOP_HARNESS.`,
Args: cobra.ArbitraryArgs,
DisableFlagParsing: true,
SilenceErrors: true,
Expand All @@ -120,12 +123,13 @@ exits. Honours -H/--harness and SPINLOOP_HARNESS.`,
ValidArgsFunction: harnessSlot,
RunE: func(c *cobra.Command, args []string) error {
resolve(c)
// Parsing is spinloop's own (not Cobra's): a leading positional
// that names a Spinloop is consumed, and everything else forwards
// byte-for-byte, so the flag set stops at the first positional
// exactly as the flag package did.
// Parsing is spinloop's own (not Cobra's): spinloop's own flags are
// recognised wherever they appear, one leading positional that
// names a Spinloop is consumed alongside them, and everything from
// the first argument that is neither forwards byte-for-byte.
fs := c.Flags()
if err := fs.Parse(args); err != nil {
rest, err := splitHarnessArgs(fs, &spinloopPath, args)
if err != nil {
return err
}

Expand Down Expand Up @@ -157,27 +161,6 @@ exits. Honours -H/--harness and SPINLOOP_HARNESS.`,
return nil
}

// Take the first positional argument as the Spinloop to wear when it
// names one — a registered alias, a path, or a directory holding
// one. Everything else is forwarded to the harness untouched, so
// this can only claim an argument the harness could not have used
// anyway. An explicit `--` opts out, for an alias that collides
// with one of the harness's own subcommands.
rest := fs.Args()
if !spinloopPath.set && !flagsTerminated(args, rest) && len(rest) > 0 && namesAnSpinloopOrAlias(rest[0]) {
spinloopPath.set, spinloopPath.path = true, rest[0]
// Reslice rather than rebuild: rest shares its backing array
// with args, so appending to it would write over the caller's
// arguments.
rest = rest[1:]
// The `--` that separated spinloop's Spinloop from the harness's
// own args is ours to drop; any other `--` belongs to the
// harness and is forwarded.
if len(rest) > 0 && rest[0] == "--" {
rest = rest[1:]
}
}

// A named Spinloop — the flag's value, a leading positional, or the
// alias SPINLOOP_ALIAS names — travels to its fleet only by flag, so
// a fleet.yaml in the working directory is not picked up for it. A
Expand All @@ -200,6 +183,16 @@ exits. Honours -H/--harness and SPINLOOP_HARNESS.`,
if err != nil {
return err
}
} else if route.envName != "" {
// No Spinloop was applied, but --env still names where the
// model is served from: configure the harness from what is
// actually deployed there instead of doing nothing with the
// flag.
var err error
sel, envDir, remoteResp, choice, err = applyFromEnvironment(providers, h, route)
if err != nil {
return err
}
} else if route.fleetPath != "" {
return fmt.Errorf("--fleet needs a Spinloop: it is the Spinloop's model that decides which node can serve you")
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/spinloop/fleet.go
Original file line number Diff line number Diff line change
Expand Up @@ -944,7 +944,7 @@ func runFleetHarness(sp spinloopPathFlag, fleetPath, node, prefer, harnessName s
route.fleetPath = fleet.DefaultFile
}

sel, envDir, remoteResp, choice, err := applyRoutedSpinloop(sel, resolvedPath, "", h, route)
sel, envDir, remoteResp, choice, err := applyRoutedSpinloop(sel, resolvedPath, "", h, route, false)
if err != nil {
return err
}
Expand Down
238 changes: 238 additions & 0 deletions cmd/spinloop/harness_remote_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"strings"
"testing"

"github.com/spinloop-ai/spinloop/internal/fleet"
"github.com/spinloop-ai/spinloop/internal/harness"
"github.com/spinloop-ai/spinloop/internal/remote"
"github.com/spinloop-ai/spinloop/internal/spinloop"
Expand Down Expand Up @@ -41,6 +42,26 @@ func envServer(t *testing.T) *httptest.Server {
}))
}

// deployedEnvServer answers the env Lambda call the way an upgraded control
// plane does for an environment with a model deployed to it: base_url/api_key
// alongside the deploy-config facts a bare `spinloop harness --env` needs to
// configure the harness with no Spinloop at all.
func deployedEnvServer(t *testing.T) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"base_url": "http://198.51.100.1:8000/v1",
"api_key": "sk-remote",
"deployed": true,
"runner": "llamacpp",
"modelId": "org/model",
"servedName": "q3",
"contextSize": 32768
}`))
}))
}

// The key of a remote endpoint is known only to the control plane, and
// `spinloop harness` fetches it and hands it to the agent it launches. The apply
// that precedes the launch must therefore not warn that no key is set: it is
Expand Down Expand Up @@ -80,6 +101,118 @@ func TestApplyBeforeLaunch_RemoteKeySilencesTheMissingKeyWarning(t *testing.T) {
}
}

// TestHarness_EnvFlagAfterTheAlias is the regression guard for the ordering
// bug where --env was silently forwarded to the harness, instead of being
// consumed by spinloop, whenever it followed the Spinloop-naming alias
// (`spinloop harness dev-3 --env dev-1 ...`, the order the flag's own name
// suggests). --env must be honoured, and dropped from what is forwarded, no
// matter which side of the alias it is on.
func TestHarness_EnvFlagAfterTheAlias(t *testing.T) {
isolateConfig(t)
stubAWSEnv(t)
t.Setenv("OPENAI_API_KEY", "")

server := envServer(t)
defer server.Close()
dir := remoteSpinloopDir(t, "dev-1", server.URL, "http://198.51.100.1:8000/v1")
captureStdout(t, func() {
if err := cmdAlias([]string{"-n", "dev-3", dir}); err != nil {
t.Fatalf("cmdAlias: %v", err)
}
})

argsFile := filepath.Join(t.TempDir(), "args")
envFile := filepath.Join(t.TempDir(), "env")
stubDir := t.TempDir()
body := "#!/bin/sh\nprintf '%s\\n' \"$@\" > " + argsFile + "\nenv > " + envFile + "\n"
if err := os.WriteFile(filepath.Join(stubDir, "opencode"), []byte(body), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", stubDir+string(os.PathListSeparator)+os.Getenv("PATH"))

captureStderr(t, func() {
captureStdout(t, func() {
if err := cmdHarness([]string{"dev-3", "--env", "dev-1", "--prompt", "hello"}); err != nil {
t.Fatalf("cmdHarness: %v", err)
}
})
})

got, err := os.ReadFile(argsFile)
if err != nil {
t.Fatalf("harness was not launched: %v", err)
}
if strings.TrimSpace(string(got)) != "--prompt\nhello" {
t.Errorf("forwarded args = %q, want \"--prompt\\nhello\" (--env consumed, not forwarded)", got)
}

env, err := os.ReadFile(envFile)
if err != nil {
t.Fatalf("could not read the launched agent's env: %v", err)
}
if v, _ := envValue(strings.Split(string(env), "\n"), "OPENAI_API_KEY"); v != "sk-remote" {
t.Errorf("launched agent's OPENAI_API_KEY = %q, want the fetched remote key", v)
}
if v, _ := envValue(strings.Split(string(env), "\n"), "OPENAI_BASE_URL"); v != "http://198.51.100.1:8000/v1" {
t.Errorf("launched agent's OPENAI_BASE_URL = %q, want the remote endpoint's address", v)
}
}

// TestHarness_BareEnvAutoConfiguresAndLaunches is the end-to-end check for the
// UX this change adds: `spinloop harness --env <name>` with no Spinloop at
// all configures the harness from what is deployed and launches it, trailing
// args forwarded exactly as they are for a Spinloop-driven launch.
func TestHarness_BareEnvAutoConfiguresAndLaunches(t *testing.T) {
home := isolateConfig(t)
stubAWSEnv(t)
t.Setenv("OPENAI_API_KEY", "")

server := deployedEnvServer(t)
defer server.Close()
registerEnv(t, "dev-3", remote.Config{
StartURL: server.URL, StopURL: server.URL, EnvURL: server.URL,
BaseURL: "http://198.51.100.1:8000/v1", Region: "eu-west-1", Environment: "dev-3",
})

forwarded, out := launchedArgs(t, []string{"--env", "dev-3", "--prompt", "hello"})
if forwarded != "--prompt\nhello" {
t.Errorf("forwarded args = %q, want \"--prompt\\nhello\"", forwarded)
}
if !strings.Contains(out, "Configuring from what is deployed to dev-3") {
t.Errorf("expected the auto-configure to be reported:\n%s", out)
}

m := readConfigMap(t, filepath.Join(home, ".config", "opencode", "opencode.json"))
if m["model"] != "dev-3/q3" {
t.Errorf("default model = %v, want dev-3/q3 (from the deploy-config)", m["model"])
}
}

// TestHarness_AppliedSpinloopStillWinsOverDeployConfig guards the precedence
// rule: applying a Spinloop alongside --env uses the Spinloop's own values,
// never the environment's deploy-config, exactly as a hand-written BASEURL
// already wins over the environment's registered address.
func TestHarness_AppliedSpinloopStillWinsOverDeployConfig(t *testing.T) {
home := isolateConfig(t)
stubAWSEnv(t)
t.Setenv("OPENAI_API_KEY", "")

server := deployedEnvServer(t) // deploy-config's servedName is "q3"
defer server.Close()
dir := remoteSpinloopDir(t, "dev-3", server.URL, "http://198.51.100.1:8000/v1")
mustWrite(t, filepath.Join(dir, "Spinloop"), "PROVIDER llamacpp\nALIAS from-spinloop\n")

forwarded, _ := launchedArgs(t, []string{dir, "--env", "dev-3", "--", "run"})
if forwarded != "run" {
t.Errorf("forwarded args = %q, want \"run\"", forwarded)
}

m := readConfigMap(t, filepath.Join(home, ".config", "opencode", "opencode.json"))
if m["model"] != "dev-3/from-spinloop" {
t.Errorf("default model = %v, want dev-3/from-spinloop (the Spinloop's ALIAS, not the deploy-config's servedName)", m["model"])
}
}

// spinloopSelectionResult carries the applyBeforeLaunch results these tests read
// out of the
// output-capturing closure.
Expand Down Expand Up @@ -271,6 +404,111 @@ func TestApplyBeforeLaunch_EnvAndFleetConflict(t *testing.T) {
}
}

// A bare `spinloop harness --env <name>` — no Spinloop at all — configures
// the harness from what the environment reports is deployed to it.
func TestApplyFromEnvironment_ConfiguresFromDeployConfig(t *testing.T) {
home := isolateConfig(t)
stubAWSEnv(t)
t.Setenv("OPENAI_API_KEY", "")

server := deployedEnvServer(t)
defer server.Close()
registerEnv(t, "dev-3", remote.Config{
StartURL: server.URL, StopURL: server.URL, EnvURL: server.URL,
BaseURL: "http://198.51.100.1:8000/v1", Region: "eu-west-1", Environment: "dev-3",
})

h, _ := harness.Lookup("opencode")
var sel spinloop.Selection
var resp *remote.Response
var choice *fleet.Choice
var err error
captureStderr(t, func() {
captureStdout(t, func() {
sel, _, resp, choice, err = applyFromEnvironment("", h, routeOptions{envName: "dev-3"})
})
})
if err != nil {
t.Fatalf("applyFromEnvironment: %v", err)
}
if choice != nil {
t.Errorf("no fleet routing should have happened, got %+v", choice)
}
if resp == nil || resp.APIKey != "sk-remote" {
t.Fatalf("the endpoint's key was not fetched: %+v", resp)
}
if sel.Provider != "llamacpp" || sel.Alias != "q3" || sel.Context != "32768" {
t.Errorf("selection = %+v, want provider llamacpp, alias q3, context 32768", sel)
}

// The written config is exactly what an equivalent Spinloop
// (`PROVIDER llamacpp\nALIAS q3\nCONTEXT 32768`) applied with --env dev-3
// would have produced.
m := readConfigMap(t, filepath.Join(home, ".config", "opencode", "opencode.json"))
if m["model"] != "dev-3/q3" {
t.Errorf("default model = %v, want dev-3/q3", m["model"])
}
}

// An unregistered runner value could never actually reach a deploy-config —
// runnerFor already refuses anything but llamacpp/vllm at deploy time — but
// the launch must still fail rather than trust one, should it ever happen.
func TestApplyFromEnvironment_UnrecognisedRunnerFails(t *testing.T) {
isolateConfig(t)
stubAWSEnv(t)

server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"base_url":"http://198.51.100.1:8000/v1","api_key":"sk-remote","deployed":true,"runner":"bogus","servedName":"q3"}`))
}))
defer server.Close()
registerEnv(t, "dev-3", remote.Config{
StartURL: server.URL, StopURL: server.URL, EnvURL: server.URL, Region: "eu-west-1", Environment: "dev-3",
})

h, _ := harness.Lookup("opencode")
captureStderr(t, func() {
captureStdout(t, func() {
if _, _, _, _, err := applyFromEnvironment("", h, routeOptions{envName: "dev-3"}); err == nil {
t.Fatal("an unrecognised runner should fail the launch")
}
})
})
}

// With nothing deployed to the environment, a bare `--env` launch fails
// naming the environment and how to fix it, rather than launching an
// unconfigured harness or falling through to applySelection's generic error.
func TestApplyFromEnvironment_FailsWithNothingDeployed(t *testing.T) {
home := isolateConfig(t)
stubAWSEnv(t)

server := envServer(t) // base_url/api_key only — no deploy-config fields
defer server.Close()
registerEnv(t, "dev-3", remote.Config{
StartURL: server.URL, StopURL: server.URL, EnvURL: server.URL, Region: "eu-west-1", Environment: "dev-3",
})

h, _ := harness.Lookup("opencode")
var err error
captureStderr(t, func() {
captureStdout(t, func() {
_, _, _, _, err = applyFromEnvironment("", h, routeOptions{envName: "dev-3"})
})
})
if err == nil {
t.Fatal("a launch with nothing to auto-configure from should fail")
}
for _, want := range []string{"dev-3", "remote deploy", "remote bootstrap"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error should mention %q, got: %v", want, err)
}
}
if _, err := os.Stat(filepath.Join(home, ".config", "opencode", "opencode.json")); !os.IsNotExist(err) {
t.Errorf("no config should have been written (stat: %v)", err)
}
}

// remoteLaunchResolver widens a lookup rather than replacing it: an exported
// key or one from the .env is the user's own and still wins.
func TestRemoteLaunchResolver_KeepsTheLocalValue(t *testing.T) {
Expand Down
Loading