From 01f66dd17b3d36a35f9ac5fbce38501ff390c6aa Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Sun, 13 Sep 2026 15:21:53 +0100 Subject: [PATCH 1/3] feat(harness): auto-configure launches from --env alone spinloop harness --env now configures and launches the harness from what is actually deployed to that environment when no Spinloop is applied: the env Lambda reports the runner, served model and context size alongside base_url/api_key, so a second machine only needs the environment registered (not a copy of the Spinloop) to launch against it. Nothing deployed, or a control plane predating this, fails loudly with the fix (deploy, or `remote bootstrap`) rather than launching unconfigured. Alongside this, fix spinloop harness's own flag parsing: --env (and its other flags) previously had to appear before a leading alias or path to be recognised, and an unrecognised flag anywhere before that point caused a hard "unknown flag" failure. Flags are now recognised wherever they sit relative to the Spinloop name. --- README.md | 9 + cmd/spinloop/commands.go | 57 ++--- cmd/spinloop/fleet.go | 2 +- cmd/spinloop/harness_remote_test.go | 238 ++++++++++++++++++ cmd/spinloop/main.go | 130 +++++++++- cmd/spinloop/remote.go | 16 ++ cmd/spinloop/remote_deploy_test.go | 18 ++ docs/commands/harness.md | 53 +++- docs/commands/remote.md | 10 + docs/spinloop-file.md | 6 + .../harness-env-autoconfig/.openspec.yaml | 2 + .../changes/harness-env-autoconfig/design.md | 175 +++++++++++++ .../harness-env-autoconfig/proposal.md | 81 ++++++ .../specs/harness-remote-env/spec.md | 33 +++ .../specs/remote-env/spec.md | 23 ++ .../changes/harness-env-autoconfig/tasks.md | 27 ++ remote/lambda/env/index.ts | 52 +++- remote/test/env-deploy-config.test.ts | 80 ++++++ 18 files changed, 954 insertions(+), 58 deletions(-) create mode 100644 openspec/changes/harness-env-autoconfig/.openspec.yaml create mode 100644 openspec/changes/harness-env-autoconfig/design.md create mode 100644 openspec/changes/harness-env-autoconfig/proposal.md create mode 100644 openspec/changes/harness-env-autoconfig/specs/harness-remote-env/spec.md create mode 100644 openspec/changes/harness-env-autoconfig/specs/remote-env/spec.md create mode 100644 openspec/changes/harness-env-autoconfig/tasks.md create mode 100644 remote/test/env-deploy-config.test.ts diff --git a/README.md b/README.md index 3ab38258..b93a528c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/spinloop/commands.go b/cmd/spinloop/commands.go index 47f63f3d..ac3cb439 100644 --- a/cmd/spinloop/commands.go +++ b/cmd/spinloop/commands.go @@ -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 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 stores the default +harness and exits. Honours -H/--harness and SPINLOOP_HARNESS.`, Args: cobra.ArbitraryArgs, DisableFlagParsing: true, SilenceErrors: true, @@ -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 } @@ -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 @@ -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") } diff --git a/cmd/spinloop/fleet.go b/cmd/spinloop/fleet.go index 1ec37001..701f4663 100644 --- a/cmd/spinloop/fleet.go +++ b/cmd/spinloop/fleet.go @@ -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 } diff --git a/cmd/spinloop/harness_remote_test.go b/cmd/spinloop/harness_remote_test.go index 1827f479..7bffe828 100644 --- a/cmd/spinloop/harness_remote_test.go +++ b/cmd/spinloop/harness_remote_test.go @@ -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" @@ -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 @@ -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 ` 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. @@ -271,6 +404,111 @@ func TestApplyBeforeLaunch_EnvAndFleetConflict(t *testing.T) { } } +// A bare `spinloop harness --env ` — 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) { diff --git a/cmd/spinloop/main.go b/cmd/spinloop/main.go index 5c44e40e..d2d919a1 100644 --- a/cmd/spinloop/main.go +++ b/cmd/spinloop/main.go @@ -1139,14 +1139,83 @@ func overlayLocalEnv(base []string, sel spinloop.Selection, dir string) []string return out } -// flagsTerminated reports whether flag parsing stopped at an explicit bare -// `--`. The flag package consumes that terminator without reporting it, so the -// only reliable trace is the last argument it swallowed — scanning args for a -// `--` before the first non-flag token would misread a detached flag value -// (`spinloop harness -H pi -- run`). -func flagsTerminated(args, rest []string) bool { - n := len(args) - len(rest) - return n > 0 && args[n-1] == "--" +// splitHarnessArgs scans args for `harness`'s own flags and, at most once, a +// leading positional naming a Spinloop, wherever either appears among the +// arguments meant for spinloop itself; everything from the first argument +// that is neither forwards to the harness untouched. This lets --env (or any +// other of the command's flags) sit before or after the Spinloop name — +// `spinloop harness dev-3 --env prod ...` and `spinloop harness --env prod +// dev-3 ...` parse alike — which fs.Parse cannot do on its own: with +// interspersed flags disabled it stops at the first positional, and with them +// enabled it errors out on the first argument meant for the harness that +// happens to look like a flag (e.g. --prompt). +// +// An explicit `--` stops the scan and is itself dropped, whether it is the +// very first argument (opting out of Spinloop-naming entirely) or follows +// some of spinloop's own flags or a consumed Spinloop name — the harness gets +// everything after it, verbatim. Once a Spinloop name has been consumed +// (spinloopPath.set), or once one is offered that does not name a Spinloop or +// a registered alias, the scan stops there and that argument is the first one +// forwarded — it is never silently dropped. +func splitHarnessArgs(fs *pflag.FlagSet, spinloopPath *spinloopPathFlag, args []string) ([]string, error) { + i := 0 + for i < len(args) { + tok := args[i] + if tok == "--" { + return args[i+1:], nil + } + if len(tok) > 1 && tok[0] == '-' { + flag, attached, hasAttached := lookupHarnessFlag(fs, tok) + if flag == nil { + return args[i:], nil + } + value := attached + if !hasAttached { + if flag.NoOptDefVal != "" { + value = flag.NoOptDefVal + } else if i+1 < len(args) { + i++ + value = args[i] + } else { + return nil, fmt.Errorf("flag needs an argument: %s", tok) + } + } + if err := flag.Value.Set(value); err != nil { + return nil, fmt.Errorf("invalid argument %q for %s: %w", value, tok, err) + } + flag.Changed = true + i++ + continue + } + if spinloopPath.set || !namesAnSpinloopOrAlias(tok) { + return args[i:], nil + } + spinloopPath.set, spinloopPath.path = true, tok + i++ + } + return nil, nil +} + +// lookupHarnessFlag resolves one argument token to a flag registered on fs, +// splitting off any attached value: `--name=value`, `-xvalue`, or `-x=value`. +// It reports a nil flag for anything unregistered, so the caller can treat it +// as the start of the harness's own arguments rather than fail on it. +func lookupHarnessFlag(fs *pflag.FlagSet, tok string) (flag *pflag.Flag, attached string, hasAttached bool) { + if strings.HasPrefix(tok, "--") { + name := tok[2:] + if eq := strings.IndexByte(name, '='); eq >= 0 { + name, attached, hasAttached = name[:eq], name[eq+1:], true + } + return fs.Lookup(name), attached, hasAttached + } + flag = fs.ShorthandLookup(tok[1:2]) + if flag == nil { + return nil, "", false + } + if len(tok) > 2 { + attached, hasAttached = strings.TrimPrefix(tok[2:], "="), true + } + return flag, attached, hasAttached } // namesAnSpinloopOrAlias reports whether arg is a way of naming a Spinloop: a path @@ -1196,20 +1265,38 @@ func applyBeforeLaunch(f spinloopPathFlag, providers string, h harness.Harness, if err != nil { return spinloop.Selection{}, "", nil, nil, err } - sel, envDir, remoteResp, choice, err := applyRoutedSpinloop(sel, path, providers, h, route) + sel, envDir, remoteResp, choice, err := applyRoutedSpinloop(sel, path, providers, h, route, false) if err != nil { return spinloop.Selection{}, "", nil, nil, err } return sel, envDir, remoteResp, choice, nil } +// applyFromEnvironment configures the harness with no Spinloop at all: a bare +// `spinloop harness --env ` reaches here. It is applyBeforeLaunch's +// counterpart for that case — same return shape, same launch continuation — +// except there is no Spinloop to read, so routing and the provider selection +// come entirely from the named environment's live deploy-config. +func applyFromEnvironment(providers string, h harness.Harness, route routeOptions) (spinloop.Selection, string, *remote.Response, *fleet.Choice, error) { + return applyRoutedSpinloop(spinloop.Selection{}, "", providers, h, route, true) +} + // applyRoutedSpinloop routes an already-read Spinloop and applies it to the // harness that is about to be launched: routing first, so a launch that cannot // find a node leaves the harness config exactly as it was, then the remote // fetch and the apply themselves. `spinloop harness` reads its Spinloop on the // way in; `spinloop fleet harness` reads its own, because with none it fails on // its own terms. Both then run this one path. -func applyRoutedSpinloop(sel spinloop.Selection, path string, providers string, h harness.Harness, route routeOptions) (spinloop.Selection, string, *remote.Response, *fleet.Choice, error) { +// +// autoConfigure is set only by a bare `spinloop harness --env `: no +// Spinloop was read (sel is empty and path is ""), so once the environment's +// live response is in hand, its deploy-config — what is actually deployed +// there — supplies the provider selection instead, exactly as if a Spinloop +// had stated the same PROVIDER/ALIAS/CONTEXT. An environment reporting +// nothing deployed (or an env Lambda predating this) fails the launch rather +// than reaching applySelection's generic "needs a model or an alias" error, +// so the message names the actual cause and how to fix it. +func applyRoutedSpinloop(sel spinloop.Selection, path string, providers string, h harness.Harness, route routeOptions, autoConfigure bool) (spinloop.Selection, string, *remote.Response, *fleet.Choice, error) { // As for apply, --providers overrides the catalogue the selection resolves // against (a Spinloop never names one). sel.Providers = providers @@ -1238,13 +1325,34 @@ func applyRoutedSpinloop(sel spinloop.Selection, path string, providers string, // remote endpoint's address is written to. sel.BaseURL = choice.BaseURL } - fmt.Printf("Applying %s\n\n", path) + if !autoConfigure { + fmt.Printf("Applying %s\n\n", path) + } // Before the apply, so a launch that cannot authenticate stops without // having rewritten the harness config. remoteResp, err := fetchRemoteEnv(sel, route.envName, localResolve) if err != nil { return spinloop.Selection{}, "", nil, nil, err } + if autoConfigure { + if remoteResp == nil || !remoteResp.Deployed || remoteResp.Runner == "" || remoteResp.ServedName == "" { + return spinloop.Selection{}, "", nil, nil, fmt.Errorf( + "nothing is deployed to environment %q to configure the harness with: "+ + "run `spinloop remote deploy --env %s` to deploy one, "+ + "or `spinloop remote bootstrap` to update the control plane if %s already has something deployed", + route.envName, route.envName, route.envName) + } + provider, err := providerForRunner(remoteResp.Runner) + if err != nil { + return spinloop.Selection{}, "", nil, nil, err + } + sel.Provider = provider + sel.Alias = remoteResp.ServedName + if remoteResp.ContextSize > 0 { + sel.Context = strconv.Itoa(remoteResp.ContextSize) + } + fmt.Printf("Configuring from what is deployed to %s.\n\n", route.envName) + } resolve := remoteLaunchResolver(localResolve, remoteResp) if choice != nil && choice.APIKey != "" { resolve = fleetLaunchResolver(resolve, choice.APIKey) diff --git a/cmd/spinloop/remote.go b/cmd/spinloop/remote.go index 41ec788f..3f93dda2 100644 --- a/cmd/spinloop/remote.go +++ b/cmd/spinloop/remote.go @@ -1022,6 +1022,22 @@ func runnerFor(provider string) (string, error) { } } +// providerForRunner is runnerFor's reverse: it maps a deployed environment's +// runner back to the catalogue provider that engine kind is configured under, +// so a launch with no Spinloop can key a provider selection off what an +// environment reports it is running. Since runnerFor's mapping is an identity +// for every runner it accepts, this only has to reject anything else — no +// runner value that isn't llamacpp or vllm can ever reach a deploy-config, +// because runnerFor already refused it at deploy time. +func providerForRunner(runner string) (string, error) { + switch runner { + case "llamacpp", "vllm": + return runner, nil + default: + return "", fmt.Errorf("environment reports an unrecognised runner %q", runner) + } +} + // nodeRunnerFor is the runner resolver for the node path — waking a fleet node // that already exists. It accepts every engine `serve` can run and a daemon can // supervise: llamacpp, vllm, and mtplx. MTPLX is Apple-Silicon-only and has no diff --git a/cmd/spinloop/remote_deploy_test.go b/cmd/spinloop/remote_deploy_test.go index 417085f7..45b53a03 100644 --- a/cmd/spinloop/remote_deploy_test.go +++ b/cmd/spinloop/remote_deploy_test.go @@ -818,6 +818,24 @@ func TestRunnerFor(t *testing.T) { } } +// providerForRunner is runnerFor's reverse, for a launch that auto-configures +// from a deployed environment's reported runner rather than a Spinloop's +// PROVIDER. Since runnerFor only ever accepts llamacpp/vllm, no other runner +// value can ever reach a deploy-config — but the reverse mapping still has to +// reject one, rather than trust an unrecognised string as a provider name. +func TestProviderForRunner(t *testing.T) { + for _, runner := range []string{"llamacpp", "vllm"} { + if got, err := providerForRunner(runner); err != nil || got != runner { + t.Errorf("providerForRunner(%q) = %q, %v", runner, got, err) + } + } + for _, runner := range []string{"mtplx", "openrouter", ""} { + if _, err := providerForRunner(runner); err == nil { + t.Errorf("providerForRunner(%q) should error", runner) + } + } +} + // A cold start blocks in one request for minutes, so the command must say what // it is doing rather than sit silent — and must say it on stderr, so piping the // exports still works. diff --git a/docs/commands/harness.md b/docs/commands/harness.md index 0cc5c6f7..d32a84d6 100644 --- a/docs/commands/harness.md +++ b/docs/commands/harness.md @@ -37,28 +37,67 @@ spinloop harness --spinloop=https://example.com/Spinloop # ...or a URL, fetched Given bare, `--spinloop` defaults to `./Spinloop` like `apply` does; when you name a path, attach it to the flag, because anything positional is forwarded to the agent (`spinloop harness -O run --model x` passes `run --model x` on). The one -exception is a *leading* argument that names a Spinloop — a path, a directory -holding one, or a [registered alias](alias.md) — which is applied rather than -forwarded: +exception is an argument that names a Spinloop — a path, a directory holding +one, or a [registered alias](alias.md) — which is applied rather than +forwarded. It can sit anywhere among the command's own flags (`--env`, `-H`, +`--fleet`, ...), in any order; the first argument that is neither one of those +flags nor a Spinloop name starts the agent's own arguments: ```sh -spinloop harness qwen3.6-27b # apply the aliased Spinloop, launch -spinloop harness qwen3.6-27b -- --agent-arg # ...forwarding --agent-arg -spinloop harness -- qwen3.6-27b # leading -- opts out: forward it +spinloop harness qwen3.6-27b # apply the aliased Spinloop, launch +spinloop harness qwen3.6-27b --env prod # the alias and --env in either order +spinloop harness --env prod qwen3.6-27b # ...parse the same way +spinloop harness qwen3.6-27b -- --agent-arg # ...forwarding --agent-arg +spinloop harness -- qwen3.6-27b # leading -- opts out: forward it ``` +Put `--` before the agent's own arguments if one of them would otherwise be +mistaken for a Spinloop name. + `SPINLOOP_ALIAS` decides what "the default Spinloop" means, so `spinloop harness -O` applies the alias it names. A bare `spinloop harness` still applies nothing: the variable chooses which Spinloop, never whether you are configured. See [`spinloop alias`](alias.md#naming-one-for-the-whole-shell). +## Launching with no Spinloop at all + +`--env ` on its own — no leading alias or path, no `--spinloop`/`-O` — +configures the harness from what is actually deployed to that +[environment](remote.md), rather than doing nothing with the flag: + +```sh +spinloop harness --env dev-3 --prompt "..." # configured from dev-3's deployment, then launched +``` + +The environment's runner becomes the provider, its served model name becomes +the model, and its context size (when set) becomes the context window — the +same result a Spinloop stating the matching `PROVIDER`/`ALIAS`/`CONTEXT` with +`--env dev-3` would produce, without writing one. This is what makes the +two-machine flow work: deploy from one machine +(`spinloop remote deploy --env dev-3`), then on any machine that +can reach the same environment — one that has its `remote.json` in the +registry, however it got there — run `spinloop harness --env dev-3` with no +Spinloop and get the same configuration, live, so a later redeploy is picked +up automatically rather than requiring anyone to re-copy anything. + +Applying a Spinloop alongside `--env` (a leading alias/path, or `-O`) is +unaffected: the Spinloop's own `PROVIDER`, `ALIAS`, `MODEL` and `CONTEXT` win, +exactly as a hand-written `BASEURL` already wins over the environment's +registered address. + +A bare `--env` against an environment with nothing deployed — or a control +plane too old to report what is deployed — fails before launching, naming the +environment and how to fix it: `spinloop remote deploy --env +` to deploy something, or `spinloop remote bootstrap` to update the +control plane. + ## Flags | Flag | Meaning | | ---- | ------- | | `-H`, `--harness` | Which harness to launch (or set `SPINLOOP_HARNESS`) | | `-O`, `--spinloop` | Apply this Spinloop before launching (bare: `./Spinloop`) | -| `-e`, `--env` | The registered [environment](remote.md) the applied Spinloop points at — mutually exclusive with fleet routing, since each names where the model is served from | +| `-e`, `--env` | The registered [environment](remote.md) to launch against; with no Spinloop applied, configures the harness from what is deployed there — mutually exclusive with fleet routing, since each names where the model is served from | | `--set` | Store the default harness and exit | | `--get` | Print the active harness instead of launching | | `--providers` | Path to a custom catalogue, for the applied Spinloop | diff --git a/docs/commands/remote.md b/docs/commands/remote.md index cfa160cb..b056ed04 100644 --- a/docs/commands/remote.md +++ b/docs/commands/remote.md @@ -142,6 +142,16 @@ before the command signs its AWS calls, so credentials, region and `SPINLOOP_REMOTE_*` overrides can travel with the Spinloop. The Spinloop does not select the environment — that is the flag's job alone. +`spinloop remote env --env ` fetches a running endpoint's credentials +(`export OPENAI_BASE_URL`/`export OPENAI_API_KEY`, safe to `eval`) without +booting it. It also reports what is deployed to the environment — runner, +served model, context size — whenever something is: this is what lets +[`spinloop harness --env `](harness.md#launching-with-no-spinloop-at-all) +configure the harness from a deployed environment with no Spinloop at all. It +appears once the control plane has been redeployed with `spinloop remote +bootstrap`; an older control plane simply omits it, and `spinloop harness +--env` with no Spinloop fails naming that as the fix. + ## Listing environments ```sh diff --git a/docs/spinloop-file.md b/docs/spinloop-file.md index 331e0cf1..ba27e4ae 100644 --- a/docs/spinloop-file.md +++ b/docs/spinloop-file.md @@ -123,6 +123,12 @@ A launch may not state both `--env` and a fleet (the `--fleet` flag, or the `./fleet.yaml` in force when the Spinloop is not named): each names where the model is served from, so spinloop fails naming both. +`spinloop harness --env qwen3.6-27b-prod` also works with **no Spinloop at +all**: the environment already knows what it is serving, so the harness +configures itself from that — the same result as the Spinloop above, without +needing a copy of it on the machine doing the launching. See +[`spinloop harness`](commands/harness.md#launching-with-no-spinloop-at-all). + Because `PROVIDER` names the engine, this is the same file that would run the model locally with [`spinloop serve`](commands/serve.md) — pointed at a bigger machine. diff --git a/openspec/changes/harness-env-autoconfig/.openspec.yaml b/openspec/changes/harness-env-autoconfig/.openspec.yaml new file mode 100644 index 00000000..c2384154 --- /dev/null +++ b/openspec/changes/harness-env-autoconfig/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-13 diff --git a/openspec/changes/harness-env-autoconfig/design.md b/openspec/changes/harness-env-autoconfig/design.md new file mode 100644 index 00000000..bf73ad01 --- /dev/null +++ b/openspec/changes/harness-env-autoconfig/design.md @@ -0,0 +1,175 @@ +## Context + +See proposal.md for the motivation. The implementation-relevant current state: + +- `remote/lambda/env/index.ts` (the `EnvFn` in `remote/lib/llm-stack.ts`, part + of the account-level control-plane stack `spinloop remote bootstrap` stands + up and updates) reads the Elastic IP and the Secrets Manager key, and + replies with only `base_url`/`api_key`. It does not touch the deploy-config. +- The deploy-config lives in SSM at the path `deployConfigParam(env)` + (`remote/lambda/shared/environments.ts`), written by the `deploy` Lambda and + already read by `start` (`readDeployConfig`, `remote/lambda/start/index.ts`) + and by `stats`, both of which relay `runner`, `modelId`, `servedName` and + `contextSize` in their own replies. `parseDeployConfig` already tolerates a + missing/invalid parameter value on the read side used elsewhere (`start` + falls back to reporting `deployed: false`). +- `internal/remote.Response` (`internal/remote/remote.go`) already declares + `Deployed`, `Runner`, `ModelID`, `ServedName`, `ContextSize` — added for the + `start`/`stats` replies. `Env()` unmarshals into the same struct, so once the + Lambda sends the fields, the Go client already parses them; no struct change + needed there. +- `cmd/spinloop/main.go`'s `fetchRemoteEnv` calls `remote.Env` and returns + the raw `*remote.Response` up to `applyRoutedSpinloop`, which today only + reads `BaseURL`/`APIKey` off it. `applyRoutedSpinloop` (and the + `applyBeforeLaunch` that calls it) only run when a Spinloop was read — see + `harnessCmd`'s `RunE` in `cmd/spinloop/commands.go`, which calls + `applyBeforeLaunch` only `if spinloopPath.set`. With no Spinloop, `--env` + is parsed into `route.envName` and then never read. +- `cmd/spinloop/remote.go`'s `runnerFor(provider string)` maps a Spinloop's + `PROVIDER` to a deploy `Runner` with an identity mapping for `llamacpp` and + `vllm` (the only deployable engines) — reversible without ambiguity. + `deriveDeployTarget` sets `dc.ServedModelName = sel.Alias`, falling back to + the model id when the Spinloop states no `ALIAS`. +- `applySelection` (`cmd/spinloop/main.go`) is the single place that writes a + harness config from a `spinloop.Selection`; it requires `sel.Model != "" || + sel.Alias != ""`, looks up `cat.Providers[sel.Provider]` for the engine + definition, then (when `envName != ""`) relabels the provider key to the + environment name and takes the base URL from the environment's registered + `remote.json` when the selection states none. This is the exact path that + must run for an auto-configured launch too, unchanged, so the resulting + config is indistinguishable from one a Spinloop produced. + +## Goals / Non-Goals + +**Goals:** + +- `spinloop harness --env ` with no Spinloop applied configures and + launches the harness entirely from what is live at the environment: no file + has to travel between the machine that deployed and the machine that + launches, beyond the registry entry (`remote.json`) needed to reach the + control plane at all. +- The auto-configured path and the Spinloop-driven path converge on the same + `applySelection` call, so there is exactly one place that writes a harness + config from a provider selection, and one set of tests for it. +- A clear, fatal error distinguishes "nothing to auto-configure from" from + every other `--env` failure already specified (unregistered environment, + AWS credentials, no key available) — it must not be mistaken for one of + those. + +**Non-Goals:** + +- No change to what `remote deploy` stores or how `--api-key-env`/rotation + work; this only adds a *read* of state that already exists. +- No attempt to keep an old, un-bootstrapped `env` Lambda working with the new + behaviour — the fix is `spinloop remote bootstrap`, not a compatibility + shim in the CLI. +- No local caching of a fetched deploy-config; every bare `--env` launch is a + fresh live query, which is the point (a redeploy is picked up automatically, + never goes stale). +- No change to the `remote-environments` registry format (`remote.json`'s + schema is untouched) — the deploy-config lives only in SSM and only travels + over the wire, never to disk on the caller's machine. + +## Decisions + +**D1: Enrich the `env` Lambda, not `remote.json`.** The alternative — writing +runner/model/context into the registered environment's `remote.json` at +deploy time — was the shape the user first proposed (copy `remote.json` +between machines). Rejected: `remote.json` already means "how to reach the +control plane," and baking model facts into it creates a second source of +truth that goes stale the moment someone redeploys a different model without +every machine re-copying the file. Reading `env`'s live reply instead means +the fact is asked for fresh on every launch and can never disagree with what +is actually running. + +**D2: Reuse `Response`, add no new wire shape.** `start`/`stats` already +return `runner`/`modelId`/`servedName`/`contextSize`/`deployed` in this exact +JSON shape, and `internal/remote.Response` already parses them. Giving `env` +a different shape for the same facts would mean two parsers for one concept. +The `env` Lambda's TypeScript reply gains the same field names; the Go client +needs no change to `Response`, only a new caller that reads it from an `env` +result instead of a `start`/`stats` one. + +**D3: Best-effort read, never fail the whole `env` call.** `parseDeployConfig` +already has to tolerate absence (an environment can be registered with +nothing deployed) on the `start` path, which reports `deployed: false` rather +than erroring. `env` follows the same rule: a missing or unparsable +deploy-config omits the fields; `base_url`/`api_key` are still useful on +their own (the existing credential-injection behaviour must not regress). + +**D4: The CLI decides whether "no deploy-config" is fatal, not the Lambda.** +Whether an absent deploy-config is fine (a Spinloop is supplying the model +info) or fatal (nothing else can) depends on whether a Spinloop was applied — +information the Lambda doesn't have and shouldn't need. So `env` always +degrades gracefully per D3, and `cmd/spinloop` is where the two cases +(Spinloop present vs. absent) diverge. + +**D5: One synthesis point, immediately before the existing `applySelection` +call.** `applyRoutedSpinloop` already has the fetched `*remote.Response` in +scope (as `remoteResp`) at the point it calls `applySelection`. When no +Spinloop was applied, a small step ahead of that call builds a +`spinloop.Selection{Provider: , Alias: resp.ServedName, +Context: resp.ContextSize}` from `remoteResp` and hands it to the *same* +`applySelection`, rather than adding a parallel write path. Alternatives +considered: a separate `applyFromEnvironment` function duplicating +`applySelection`'s provider-lookup and relabelling logic (rejected — the +whole point of D5 is that a config built this way must be indistinguishable +from one a Spinloop produced, which a duplicate risks drifting from over +time). + +**D6: `harnessCmd`'s gate moves from "a Spinloop is set" to "a Spinloop is +set, or `--env` is given."** Today `RunE` only calls `applyBeforeLaunch` `if +spinloopPath.set`; a bare `--env` with nothing else currently launches the +harness completely unconfigured, silently ignoring the flag. That gate widens +to also enter the apply path when `route.envName != ""`, and +`applyRoutedSpinloop` picks which selection to build depending on whether a +Spinloop was actually read. This is a widening of when the apply path runs, +not a new flag or a new command surface — the flag already means the same +thing conceptually, it just used to require a Spinloop to be honoured at all. + +**D7: Runner → catalogue-provider mapping is shared, not re-derived.** +`runnerFor` already encodes the one-directional (provider → runner) mapping +at deploy time; a fetched `Runner` needs the reverse. Since the mapping is +currently an identity function for both deployable engines (`llamacpp`, +`vllm`), the reverse direction is extracted as a small shared helper next to +`runnerFor` rather than hand-rolled again at the call site — so the day a +non-identity runner is added, one place has to change, not two. + +## Risks / Trade-offs + +- **[Risk]** Existing environments' `env` Lambda predates this change and + will keep replying without the new fields until the account re-runs + `spinloop remote bootstrap`. → Mitigation: this is D4's fatal-error path + already, and the error names `spinloop remote bootstrap` as the fix (see + the `harness-remote-env` delta's "env Lambda predating this behaviour" + scenario) — the same remediation an operator already needs for any other + control-plane upgrade. +- **[Risk]** A live fetch on every bare `--env` launch adds one network round + trip (already happens today for credential injection whenever `--env` is + given with a Spinloop, so this is not a new cost — it is the same fetch, + now also read for two more fields, on a path that previously skipped it + entirely). +- **[Risk]** Someone deploys a hosted (non-self-hosted) provider's Spinloop + in the future in a way that produces a `Runner` value with no catalogue + provider counterpart. → Mitigation: `runnerFor` already rejects any + `PROVIDER` that isn't `llamacpp`/`vllm` at deploy time, so no other runner + value can ever reach SSM; the reverse mapping only ever has to handle the + same two names. + +## Migration Plan + +1. Add the deploy-config read to `remote/lambda/env/index.ts`, reusing the + `readDeployConfig`/`deployConfigParam` helpers `start` already imports. +2. Extend `internal/remote` only if a gap is found in what `Response` already + captures for `start`/`stats` (expected: none — see D2). +3. Add the runner→provider reverse mapping next to `runnerFor` in + `cmd/spinloop/remote.go`. +4. Wire the synthesis step into `applyRoutedSpinloop` per D5, and widen + `harnessCmd`'s gate per D6. +5. Update `docs/commands/harness.md` and `docs/commands/remote.md`. +6. No code rollback concern beyond a normal revert: the Lambda change is + additive (new optional fields in a JSON reply) and the CLI change only + takes a new path when `--env` is given with no Spinloop, which today does + nothing — there is no existing behaviour to regress for that input. + Existing accounts opt in by running `spinloop remote bootstrap`, at their + own pace; a CLI upgrade alone changes nothing until they do. diff --git a/openspec/changes/harness-env-autoconfig/proposal.md b/openspec/changes/harness-env-autoconfig/proposal.md new file mode 100644 index 00000000..47abde55 --- /dev/null +++ b/openspec/changes/harness-env-autoconfig/proposal.md @@ -0,0 +1,81 @@ +## Why + +`spinloop harness --env ` only injects a remote endpoint's credentials +into the launched agent's environment when a Spinloop is also applied — the +provider kind and model key still have to come from a local `Spinloop` file, +even though the environment already knows what it is serving. An operator on +a second machine who only has (or only wants) the registered environment has +no way to launch against it: they either write a throwaway Spinloop that +duplicates what `remote deploy` already recorded, or go without. The control +plane already stores exactly this information — the runner, the served model +name, and the context size are written to SSM at deploy time and already +relayed by the `start` and `stats` Lambdas — the `env` Lambda `spinloop +harness --env` calls just does not read it yet. + +## What Changes + +- The `env` Lambda additionally reads the environment's deploy-config from SSM + (best-effort: an environment can be registered with nothing deployed to it, + or predate this change) and returns `deployed`, `runner`, `modelId`, + `servedName`, and `contextSize` alongside the existing `base_url` and + `api_key`, mirroring the fields `start`/`stats` already relay. +- `spinloop harness --env ` SHALL configure and launch the harness from + that response when no Spinloop is applied: the runner becomes the catalogue + provider (the same identity mapping `remote deploy` already uses in + reverse), the served name becomes the model key, and the context size sets + the window — going through the same `applySelection` path a Spinloop-driven + apply already uses, so the written config is indistinguishable from one a + Spinloop produced. +- A bare `spinloop harness --env ` against an environment with nothing + deployed, or whose `env` Lambda predates this change (the response simply + omits `deployed`/`runner`/etc.), SHALL fail with an actionable error rather + than launch an unconfigured or stale harness — naming the environment and + saying to deploy it, or to `spinloop remote bootstrap` to pick up the + updated Lambda. +- Applying a Spinloop alongside `--env` is unaffected: an explicit `PROVIDER`, + `ALIAS`, `MODEL` or `CONTEXT` in the Spinloop continues to win, exactly as a + hand-written `BASEURL` already wins over the environment's registered + address. + +## Capabilities + +### New Capabilities + +(None — every behaviour change lands in an existing capability.) + +### Modified Capabilities + +- `remote-env`: the `env` Lambda additionally returns the environment's + deploy-config (`deployed`, `runner`, `modelId`, `servedName`, + `contextSize`) when one is registered and parses; the "no boot" requirement + is reworded to cover the added SSM read, which is still boot-free. +- `harness-remote-env`: `spinloop harness --env ` with no Spinloop + applied SHALL synthesise a provider selection from the environment's + deploy-config instead of doing nothing with the flag; an environment with + nothing deployed, or an `env` Lambda that predates this change, SHALL fail + the launch naming the cause and the fix, rather than launching unconfigured. + +## Impact + +- `remote/lambda/env/index.ts`: reads `deployConfigParam(env)` via the shared + `readDeployConfig` helper (already used by `start`/`stats`) and adds the + fields to its JSON reply; a missing or unparsable deploy-config degrades to + omitting them rather than failing the whole response, since `base_url` and + `api_key` remain valid without it. +- `internal/remote/remote.go`: `Response` already carries `Deployed`, + `Runner`, `ModelID`, `ServedName`, `ContextSize` — no struct change, just a + new caller reading them from an `env` reply instead of only a `start`/ + `stats` one. +- `cmd/spinloop/commands.go` (`harnessCmd`) and `cmd/spinloop/main.go` + (`applyBeforeLaunch`/`applyRoutedSpinloop`/`fetchRemoteEnv`): the apply path + currently only runs when a Spinloop is worn; it gains a route that + synthesises a `spinloop.Selection` from a fetched environment's deploy-config + when `--env` is given and no Spinloop is applied. +- `cmd/spinloop/remote.go`: the `runnerFor`/catalogue-provider identity + mapping used at deploy time is reused (or a shared helper extracted) to map + a fetched `Runner` back to a catalogue provider name. +- Docs: `docs/commands/harness.md`, `docs/commands/remote.md`. +- Existing deployed accounts need `spinloop remote bootstrap` re-run (a CDK + stack update) before their `env` Lambda returns the new fields; until then, + `spinloop harness --env ` with no Spinloop continues to fail with the + same actionable error as an undeployed environment. diff --git a/openspec/changes/harness-env-autoconfig/specs/harness-remote-env/spec.md b/openspec/changes/harness-env-autoconfig/specs/harness-remote-env/spec.md new file mode 100644 index 00000000..6facd397 --- /dev/null +++ b/openspec/changes/harness-env-autoconfig/specs/harness-remote-env/spec.md @@ -0,0 +1,33 @@ +## ADDED Requirements + +### Requirement: harness auto-configures from a deployed environment +When `spinloop harness --env ` is run with no Spinloop applied — no leading alias or path, and no `--spinloop`/`-O` — the command SHALL fetch the named environment's live environment response (the same fetch that supplies `OPENAI_BASE_URL`/`OPENAI_API_KEY`) and, when it carries a deploy-config, synthesise a provider selection from it rather than doing nothing with the flag. + +The deploy-config's runner SHALL become the catalogue provider, by the same mapping `spinloop remote deploy` uses in reverse (a runner is a catalogue provider's engine kind). The deploy-config's served model name SHALL become the model key. The deploy-config's context size, when present, SHALL set the context window. The harness SHALL then be configured and launched exactly as it would be for a Spinloop stating the same `PROVIDER`, `ALIAS` and `CONTEXT` with the same `--env ` — the same environment labelling, base URL, and injected credentials as the existing `--env` behaviour. + +A Spinloop applied alongside `--env` — a leading alias or path, or `--spinloop`/`-O` — SHALL continue to use its own `PROVIDER`, `ALIAS`, `MODEL` and `CONTEXT` exactly as today; the deploy-config's fields SHALL NOT override a value the Spinloop states. + +#### Scenario: bare --env configures and launches the harness +- **WHEN** the user runs `spinloop harness --env dev-3` with no Spinloop applied, and a model is deployed to `dev-3` +- **THEN** the harness is configured with `dev-3` as the provider, the deployed served model name as the model, the deployed context size as the window, and is launched with the fetched base URL and API key in its environment + +#### Scenario: an applied Spinloop still wins +- **WHEN** the user runs `spinloop harness some-alias --env dev-3` (or `--spinloop= --env dev-3`) and the Spinloop states its own `PROVIDER` and `ALIAS` +- **THEN** the Spinloop's values configure the harness; the environment's deploy-config is not consulted for them + +#### Scenario: trailing args are still forwarded +- **WHEN** the user runs `spinloop harness --env dev-3 --prompt "hello"` with no Spinloop applied, and a model is deployed to `dev-3` +- **THEN** the harness is auto-configured from `dev-3` and launched with `--prompt hello` forwarded to it + +### Requirement: harness fails clearly with nothing to auto-configure from +When `spinloop harness --env ` is run with no Spinloop applied, and the environment's fetched response carries no deploy-config — because nothing has been deployed to it, or because its `env` Lambda predates this behaviour and the reply simply omits the fields — the command SHALL fail before launching, rather than launch an unconfigured or misconfigured harness. + +The error SHALL name the environment and say what to do: deploy a model to it (`spinloop remote deploy --env `), or, when a redeployed model is plausible but the reply still lacks the fields, run `spinloop remote bootstrap` to update the control plane to a version whose `env` Lambda reports what is deployed. + +#### Scenario: nothing deployed to the environment +- **WHEN** the user runs `spinloop harness --env dev-3` with no Spinloop applied, and nothing has been deployed to `dev-3` +- **THEN** the command fails saying nothing is deployed to `dev-3` and how to deploy one, and the harness is not launched + +#### Scenario: an env Lambda predating this behaviour +- **WHEN** the user runs `spinloop harness --env dev-3` with no Spinloop applied, and `dev-3`'s `env` Lambda reply carries no deploy-config fields +- **THEN** the command fails the same way as when nothing is deployed, naming `spinloop remote bootstrap` as a way to update the control plane, and the harness is not launched diff --git a/openspec/changes/harness-env-autoconfig/specs/remote-env/spec.md b/openspec/changes/harness-env-autoconfig/specs/remote-env/spec.md new file mode 100644 index 00000000..ba70d320 --- /dev/null +++ b/openspec/changes/harness-env-autoconfig/specs/remote-env/spec.md @@ -0,0 +1,23 @@ +## MODIFIED Requirements + +### Requirement: env Lambda is fast (no boot) +The `spinloop remote env` command SHALL NOT trigger an instance boot. It reads the API key from Secrets Manager, the base URL from the environment's Elastic IP, and — best-effort — the environment's deploy-config from SSM; none of these require the instance to be running or booting. + +#### Scenario: env does not start a stopped instance +- **WHEN** the user runs `spinloop remote env` and the instance is stopped +- **THEN** the command returns quickly with an error (not after minutes of booting) + +## ADDED Requirements + +### Requirement: env Lambda reports what is deployed +The `env` Lambda SHALL read the named environment's deploy-config from SSM (the same state `deploy` writes and `start`/`stats` already relay) and, when one is present and parses, include it in its reply alongside `base_url` and `api_key`: a `deployed` flag, the `runner`, the `modelId`, the `servedName` (the name the engine answers to — an `ALIAS` at deploy time, falling back to the model id), and the `contextSize`. + +When no deploy-config is registered for the environment, or the stored value fails to parse, the reply SHALL omit these fields rather than fail the request: `base_url` and `api_key` remain valid and useful without them, exactly as they were before this requirement existed. + +#### Scenario: env reports the deployed model +- **WHEN** the user runs `spinloop remote env --env dev-3` and a model has been deployed to `dev-3` +- **THEN** the reply includes `deployed: true`, the `runner`, `modelId`, `servedName` and `contextSize` the deploy recorded, alongside `base_url` and `api_key` + +#### Scenario: env degrades gracefully with nothing deployed +- **WHEN** the user runs `spinloop remote env --env dev-3` and `dev-3` is registered but nothing has been deployed to it +- **THEN** the reply carries `base_url` and `api_key` as it always has, with no `deployed`, `runner`, `modelId`, `servedName` or `contextSize` fields, and the command does not fail because of their absence diff --git a/openspec/changes/harness-env-autoconfig/tasks.md b/openspec/changes/harness-env-autoconfig/tasks.md new file mode 100644 index 00000000..0c5f127e --- /dev/null +++ b/openspec/changes/harness-env-autoconfig/tasks.md @@ -0,0 +1,27 @@ +## 1. env Lambda reports the deploy-config + +- [x] 1.1 In `remote/lambda/env/index.ts`, read the deploy-config via `readDeployConfig(deployConfigParam(env))` (the same helpers `remote/lambda/start/index.ts` already imports), guarding the read so a missing or unparsable parameter degrades to omitting the fields rather than failing the response — verify with a new `remote/test/env-deploy-config.test.ts` (mirroring `remote/test/stats-relay.test.ts`'s mocking of the SSM read) covering both the present and the missing/unparsable case. +- [x] 1.2 Add `deployed`, `runner`, `modelId`, `servedName`, `contextSize` to the Lambda's JSON reply when the deploy-config is present, using the same field names `start`/`stats` already emit — verify the existing `remote/test/env-api-key.test.ts` still passes unchanged (the new fields are additive) and the new test from 1.1 asserts their presence/absence. +- [x] 1.3 Run `pnpm test` (or the configured `vitest run`) in `remote/` and confirm the whole suite passes. + +## 2. Go client: runner → provider mapping + +- [x] 2.1 In `cmd/spinloop/remote.go`, add a small helper next to `runnerFor` that maps a `Runner` string back to a catalogue provider name (the reverse of `runnerFor`'s identity mapping for `llamacpp`/`vllm`), returning an error for anything else — verify with a table-driven unit test covering both known runners and an unrecognised one. + +## 3. CLI: auto-configure from a fetched environment + +- [x] 3.1 In `cmd/spinloop/main.go`, extend `applyRoutedSpinloop` (or its caller) so that when no Spinloop was read and `route.envName != ""`, it fetches the environment's response first and, when the response carries a deploy-config (`Deployed` true with `Runner`/`ServedName` set), synthesises a `spinloop.Selection{Provider: , Alias: resp.ServedName, Context: resp.ContextSize}` and passes it through the existing `applySelection` call — verify with a unit test exercising this path directly (in the style of the existing `applyBeforeLaunch`/`applyRoutedSpinloop` tests in `cmd/spinloop/harness_remote_test.go`), asserting the written harness config matches what an equivalent Spinloop would produce. +- [x] 3.2 In the same path, when no Spinloop was read, `route.envName != ""`, and the fetched response carries no deploy-config (nothing deployed, or an `env` Lambda predating this change), fail before writing any harness config with an error naming the environment and both remediations (`spinloop remote deploy --env `, and `spinloop remote bootstrap` for a stale control plane) — verify with a unit test asserting the error text and that no config file is written. +- [x] 3.3 In `cmd/spinloop/commands.go`'s `harnessCmd` `RunE`, widen the gate that currently calls `applyBeforeLaunch` only `if spinloopPath.set` to also enter it when `route.envName != ""`, so a bare `spinloop harness --env ` reaches the new path from 3.1/3.2 instead of silently launching unconfigured — verify with an end-to-end `cmdHarness` test (stubbing the harness binary and the env Lambda's HTTP response, following the pattern in `cmd/spinloop/harness_remote_test.go`) that a bare `--env` launch writes the expected config and forwards trailing args, and that a Spinloop applied alongside `--env` still wins over the deploy-config (regression test for the existing precedence). +- [x] 3.4 Confirm every existing scenario in `openspec/specs/harness-remote-env/spec.md` and `openspec/specs/remote-env/spec.md` (unmodified by this change) still passes — run `go test ./cmd/spinloop/... ./internal/remote/...` and confirm no regressions. + +## 4. Docs + +- [x] 4.1 Update `docs/commands/harness.md` to document the bare `--env ` auto-configure flow, its precedence against an applied Spinloop, and the "nothing deployed" / "update the control plane" errors. +- [x] 4.2 Update `docs/commands/remote.md` to note that `spinloop remote env` (and the `env` Lambda generally) now reports what is deployed, when anything is. + +## 5. End-to-end verification + +- [x] 5.1 Run `go build ./...`, `go vet ./...`, `gofmt -l` over changed Go files, and `go test ./...` (target ≥80% coverage per project convention) — all green. +- [x] 5.2 Manually verify against a real (or locally stubbed) deployed environment: `spinloop remote deploy --env dev-test` from one config, then `spinloop harness --env dev-test ` with no Spinloop applied, confirming the harness launches configured and the trailing args reach it. +- [x] 5.3 Manually verify the failure path: `spinloop harness --env ` with no Spinloop fails with the documented error and writes no config. diff --git a/remote/lambda/env/index.ts b/remote/lambda/env/index.ts index 7a5fd1c6..6d91b31c 100644 --- a/remote/lambda/env/index.ts +++ b/remote/lambda/env/index.ts @@ -1,21 +1,27 @@ /** - * Env Lambda — returns the API key and base URL for an environment. - * Does NOT start the instance: the API key lives in Secrets Manager and the - * EIP is allocated at deploy, so both are available regardless of instance - * state. + * Env Lambda — returns the API key and base URL for an environment, plus what + * is currently deployed to it, if anything. + * Does NOT start the instance: the API key lives in Secrets Manager, the EIP + * is allocated at deploy, and the deploy-config lives in SSM, so all three are + * available regardless of instance state. * - * The caller (spinloop harness) uses this to inject OPENAI_API_KEY and - * OPENAI_BASE_URL into the agent's environment, so the user never has to - * export anything manually. + * The caller (spinloop harness) uses base_url/api_key to inject + * OPENAI_API_KEY and OPENAI_BASE_URL into the agent's environment, so the + * user never has to export anything manually. With no Spinloop applied, it + * also uses the deploy-config fields (when present) to configure the harness + * itself — the provider, model and context window — from what is actually + * running, rather than requiring a local Spinloop to restate it. */ import type { LambdaFunctionURLEvent, LambdaFunctionURLResult } from 'aws-lambda'; import { errorName, + readDeployConfig, requireEnv, } from '../shared/aws'; import { baseUrlFor, + deployConfigParam, environmentFrom, findEnvEip, readEnvApiKey, @@ -24,6 +30,36 @@ import { jsonResponse } from '../shared/http'; const ENGINE_PORT = requireEnv('ENGINE_PORT'); +/** + * Read the environment's deploy-config for the facts that name what it is + * serving, the same facts `start` and `stats` already read from the same + * source. Absent or unparsable degrades to an empty object rather than + * failing the call: base_url/api_key are still useful on their own for an + * environment with nothing deployed yet, or one deployed before this field + * existed. + */ +async function readDeployFacts(env: string): Promise<{ + deployed?: true; + runner?: string; + modelId?: string; + servedName?: string; + contextSize?: number; +}> { + try { + const cfg = await readDeployConfig(deployConfigParam(env)); + return { + deployed: true, + runner: cfg.runner, + modelId: cfg.modelId, + servedName: cfg.servedModelName, + contextSize: cfg.contextSize, + }; + } catch (err) { + console.log(JSON.stringify({ phase: 'deploy-facts', environment: env, error: errorName(err) })); + return {}; + } +} + export async function handler(event: LambdaFunctionURLEvent): Promise { let env: string; try { @@ -48,10 +84,12 @@ export async function handler(event: LambdaFunctionURLEvent): Promise ({ + ...(await importOriginal()), + findEnvEip: (...args: unknown[]) => findEnvEip(...args), + readEnvApiKey: (...args: unknown[]) => readEnvApiKey(...args), +})); + +vi.mock('../lambda/shared/aws', async (importOriginal) => ({ + ...(await importOriginal()), + readDeployConfig: (...args: unknown[]) => readDeployConfig(...args), +})); + +let handler: (event: LambdaFunctionURLEvent) => Promise; + +beforeAll(async () => { + Object.assign(process.env, LAMBDA_ENV); + ({ handler } = await import('../lambda/env/index')); +}); + +const envEvent = { queryStringParameters: { env: 'dev-3' } } as unknown as LambdaFunctionURLEvent; + +function bodyOf(result: LambdaFunctionURLResult): Record { + return JSON.parse((result as { statusCode: number; body: string }).body); +} + +beforeEach(() => { + vi.clearAllMocks(); + findEnvEip.mockResolvedValue({ publicIp: '198.51.100.1' }); + readEnvApiKey.mockResolvedValue('sk-remote'); +}); + +describe('env reports what is deployed', () => { + it('includes the deploy-config fields alongside base_url/api_key', async () => { + readDeployConfig.mockResolvedValue({ + runner: 'llamacpp', + modelId: 'org/model', + servedModelName: 'q3', + contextSize: 32768, + }); + + const body = bodyOf(await handler(envEvent)); + expect(body).toMatchObject({ + base_url: 'http://198.51.100.1:8080/v1', + api_key: 'sk-remote', + deployed: true, + runner: 'llamacpp', + modelId: 'org/model', + servedName: 'q3', + contextSize: 32768, + }); + }); + + it('omits the deploy-config fields, without failing, when nothing is deployed', async () => { + readDeployConfig.mockRejectedValue(new Error('deploy-config is not set')); + + const body = bodyOf(await handler(envEvent)); + expect(body).toEqual({ base_url: 'http://198.51.100.1:8080/v1', api_key: 'sk-remote' }); + expect(body).not.toHaveProperty('deployed'); + expect(body).not.toHaveProperty('runner'); + }); + + it('omits the deploy-config fields, without failing, when it fails to parse', async () => { + readDeployConfig.mockRejectedValue(new Error('deploy-config is not valid JSON')); + + const body = bodyOf(await handler(envEvent)); + expect(body).toEqual({ base_url: 'http://198.51.100.1:8080/v1', api_key: 'sk-remote' }); + }); +}); From 41903c51bdfe536b42c7d1a38facddf5d364fdcf Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Sun, 13 Sep 2026 16:21:44 +0100 Subject: [PATCH 2/3] fix(remote): grant the env Lambda read access to the deploy-config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The env Lambda's role was never given ssm:GetParameter on the deploy-config parameter, so its new deploy-config read (added for spinloop harness --env's auto-configure) always threw AccessDenied. That was caught by the read's own best-effort handling — the same path that covers "nothing deployed yet" — so a running, deployed environment was silently reported as having nothing deployed to configure the harness with. Found via a live run against a deployed fleet node. --- .../changes/harness-env-autoconfig/design.md | 7 ++++- .../changes/harness-env-autoconfig/tasks.md | 1 + remote/lib/llm-stack.ts | 10 ++++--- remote/test/stack.test.ts | 26 +++++++++++++++++++ 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/openspec/changes/harness-env-autoconfig/design.md b/openspec/changes/harness-env-autoconfig/design.md index bf73ad01..79d7ad16 100644 --- a/openspec/changes/harness-env-autoconfig/design.md +++ b/openspec/changes/harness-env-autoconfig/design.md @@ -159,7 +159,12 @@ non-identity runner is added, one place has to change, not two. ## Migration Plan 1. Add the deploy-config read to `remote/lambda/env/index.ts`, reusing the - `readDeployConfig`/`deployConfigParam` helpers `start` already imports. + `readDeployConfig`/`deployConfigParam` helpers `start` already imports, and + grant the env Lambda's role read-only `ssm:GetParameter` on the + deploy-config parameter in `remote/lib/llm-stack.ts` — easy to miss, since + the read still "succeeds" from the CLI's point of view: an `AccessDenied` + is caught by the same best-effort handling that covers "nothing deployed + yet," so the two look identical without the CDK-level test added for it. 2. Extend `internal/remote` only if a gap is found in what `Response` already captures for `start`/`stats` (expected: none — see D2). 3. Add the runner→provider reverse mapping next to `runnerFor` in diff --git a/openspec/changes/harness-env-autoconfig/tasks.md b/openspec/changes/harness-env-autoconfig/tasks.md index 0c5f127e..4520d08f 100644 --- a/openspec/changes/harness-env-autoconfig/tasks.md +++ b/openspec/changes/harness-env-autoconfig/tasks.md @@ -3,6 +3,7 @@ - [x] 1.1 In `remote/lambda/env/index.ts`, read the deploy-config via `readDeployConfig(deployConfigParam(env))` (the same helpers `remote/lambda/start/index.ts` already imports), guarding the read so a missing or unparsable parameter degrades to omitting the fields rather than failing the response — verify with a new `remote/test/env-deploy-config.test.ts` (mirroring `remote/test/stats-relay.test.ts`'s mocking of the SSM read) covering both the present and the missing/unparsable case. - [x] 1.2 Add `deployed`, `runner`, `modelId`, `servedName`, `contextSize` to the Lambda's JSON reply when the deploy-config is present, using the same field names `start`/`stats` already emit — verify the existing `remote/test/env-api-key.test.ts` still passes unchanged (the new fields are additive) and the new test from 1.1 asserts their presence/absence. - [x] 1.3 Run `pnpm test` (or the configured `vitest run`) in `remote/` and confirm the whole suite passes. +- [x] 1.4 Grant the env Lambda's role `ssm:GetParameter` on the deploy-config parameter in `remote/lib/llm-stack.ts` (`readEnvParamsStatement`, the same read-only grant `stopFn` already has) — found missing during live testing: without it, `readDeployConfig` throws `AccessDenied`, caught by 1.1's best-effort try/catch and silently reported as "nothing deployed" on an environment that is actually running. Verify with a new `remote/test/stack.test.ts` assertion that the env Lambda's policy carries a read-only `ssm:GetParameter` grant on the `cloud-vm-llm` parameter path. ## 2. Go client: runner → provider mapping diff --git a/remote/lib/llm-stack.ts b/remote/lib/llm-stack.ts index 234f0a62..90c4016f 100644 --- a/remote/lib/llm-stack.ts +++ b/remote/lib/llm-stack.ts @@ -613,10 +613,12 @@ export class LlmStack extends cdk.Stack { const statsUrl = statsFn.addFunctionUrl({ authType: lambda.FunctionUrlAuthType.AWS_IAM }); const seedUrl = seedFn.addFunctionUrl({ authType: lambda.FunctionUrlAuthType.AWS_IAM }); - // Env Lambda — returns the API key and base URL for a running endpoint. - // Minimal perms: read the environment's EIP and API key; no EC2 write. + // Env Lambda — returns the API key and base URL for a running endpoint, + // plus what is deployed to it (from the deploy-config), when there is one. + // Minimal perms: read the environment's EIP, API key and deploy-config; + // no EC2 write. const envFn = new nodejs.NodejsFunction(this, 'EnvFn', { - description: 'Returns base URL and API key for a running environment instance', + description: 'Returns base URL, API key and deploy-config for a running environment instance', entry: path.join(__dirname, '..', 'lambda', 'env', 'index.ts'), handler: 'handler', runtime: lambda.Runtime.NODEJS_22_X, @@ -643,6 +645,8 @@ export class LlmStack extends cdk.Stack { resources: [envSecretArn], }), ); + // Read-only: env never writes the deploy-config, only start/deploy/stats do. + envFn.addToRolePolicy(readEnvParamsStatement); const envUrl = envFn.addFunctionUrl({ authType: lambda.FunctionUrlAuthType.AWS_IAM }); diff --git a/remote/test/stack.test.ts b/remote/test/stack.test.ts index efff4474..12b4226e 100644 --- a/remote/test/stack.test.ts +++ b/remote/test/stack.test.ts @@ -395,6 +395,32 @@ describe('LlmStack (control plane)', () => { expect(weightsRead).toBeDefined(); }); + it('grants the env Lambda read-only access to the deploy-config', () => { + // Regression guard: the env Lambda reads the deploy-config to report what + // is deployed (spinloop harness --env auto-configure), but must never be + // able to write it — only deploy/start do that. + const fns = template.findResources('AWS::Lambda::Function'); + const env = Object.values(fns).find((f) => + String(f.Properties.Description).includes('Returns base URL, API key and deploy-config'), + ); + expect(env).toBeDefined(); + + const policies = template.findResources('AWS::IAM::Policy'); + const envPolicy = Object.values(policies).find((p) => + String(p.Properties.PolicyName).startsWith('EnvFnServiceRoleDefaultPolicy'), + ); + expect(envPolicy).toBeDefined(); + const statements = envPolicy!.Properties.PolicyDocument.Statement as { + Action: string | string[]; + Resource?: unknown; + }[]; + const ssmRead = statements.find( + (s) => [s.Action].flat().includes('ssm:GetParameter') && JSON.stringify(s.Resource).includes('cloud-vm-llm'), + ); + expect(ssmRead).toBeDefined(); + expect([ssmRead!.Action].flat()).not.toContain('ssm:PutParameter'); + }); + it('scopes per-environment SSM and secret access to the cloud-vm-llm prefix', () => { const statements = allPolicyStatements(template); const ssmStatement = statements.find((s) => [s.Action].flat().includes('ssm:PutParameter')); From 3e235443fa628293bdd5d67587480e7f5f6031bd Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Sun, 13 Sep 2026 16:29:31 +0100 Subject: [PATCH 3/3] docs(openspec): archive harness-env-autoconfig and sync its specs Merges the harness-remote-env and remote-env deltas into the main specs, and moves the change to archive/2026-09-13-harness-env-autoconfig. --- .../.openspec.yaml | 0 .../design.md | 0 .../proposal.md | 0 .../specs/harness-remote-env/spec.md | 0 .../specs/remote-env/spec.md | 0 .../tasks.md | 0 openspec/specs/harness-remote-env/spec.md | 32 +++++++++++++++++++ openspec/specs/remote-env/spec.md | 15 ++++++++- 8 files changed, 46 insertions(+), 1 deletion(-) rename openspec/changes/{harness-env-autoconfig => archive/2026-09-13-harness-env-autoconfig}/.openspec.yaml (100%) rename openspec/changes/{harness-env-autoconfig => archive/2026-09-13-harness-env-autoconfig}/design.md (100%) rename openspec/changes/{harness-env-autoconfig => archive/2026-09-13-harness-env-autoconfig}/proposal.md (100%) rename openspec/changes/{harness-env-autoconfig => archive/2026-09-13-harness-env-autoconfig}/specs/harness-remote-env/spec.md (100%) rename openspec/changes/{harness-env-autoconfig => archive/2026-09-13-harness-env-autoconfig}/specs/remote-env/spec.md (100%) rename openspec/changes/{harness-env-autoconfig => archive/2026-09-13-harness-env-autoconfig}/tasks.md (100%) diff --git a/openspec/changes/harness-env-autoconfig/.openspec.yaml b/openspec/changes/archive/2026-09-13-harness-env-autoconfig/.openspec.yaml similarity index 100% rename from openspec/changes/harness-env-autoconfig/.openspec.yaml rename to openspec/changes/archive/2026-09-13-harness-env-autoconfig/.openspec.yaml diff --git a/openspec/changes/harness-env-autoconfig/design.md b/openspec/changes/archive/2026-09-13-harness-env-autoconfig/design.md similarity index 100% rename from openspec/changes/harness-env-autoconfig/design.md rename to openspec/changes/archive/2026-09-13-harness-env-autoconfig/design.md diff --git a/openspec/changes/harness-env-autoconfig/proposal.md b/openspec/changes/archive/2026-09-13-harness-env-autoconfig/proposal.md similarity index 100% rename from openspec/changes/harness-env-autoconfig/proposal.md rename to openspec/changes/archive/2026-09-13-harness-env-autoconfig/proposal.md diff --git a/openspec/changes/harness-env-autoconfig/specs/harness-remote-env/spec.md b/openspec/changes/archive/2026-09-13-harness-env-autoconfig/specs/harness-remote-env/spec.md similarity index 100% rename from openspec/changes/harness-env-autoconfig/specs/harness-remote-env/spec.md rename to openspec/changes/archive/2026-09-13-harness-env-autoconfig/specs/harness-remote-env/spec.md diff --git a/openspec/changes/harness-env-autoconfig/specs/remote-env/spec.md b/openspec/changes/archive/2026-09-13-harness-env-autoconfig/specs/remote-env/spec.md similarity index 100% rename from openspec/changes/harness-env-autoconfig/specs/remote-env/spec.md rename to openspec/changes/archive/2026-09-13-harness-env-autoconfig/specs/remote-env/spec.md diff --git a/openspec/changes/harness-env-autoconfig/tasks.md b/openspec/changes/archive/2026-09-13-harness-env-autoconfig/tasks.md similarity index 100% rename from openspec/changes/harness-env-autoconfig/tasks.md rename to openspec/changes/archive/2026-09-13-harness-env-autoconfig/tasks.md diff --git a/openspec/specs/harness-remote-env/spec.md b/openspec/specs/harness-remote-env/spec.md index d6f8fcc9..aa975f7c 100644 --- a/openspec/specs/harness-remote-env/spec.md +++ b/openspec/specs/harness-remote-env/spec.md @@ -89,3 +89,35 @@ When an API key is already available — exported, in the `.env` beside the Spin #### Scenario: an available key downgrades the failure to a warning - **WHEN** the fetch fails but `OPENAI_API_KEY` is already set in the environment, in the `.env` beside the Spinloop, or by an `ENV` instruction - **THEN** the failure is reported on stderr and the harness is launched with the key that is available + +### Requirement: harness auto-configures from a deployed environment +When `spinloop harness --env ` is run with no Spinloop applied — no leading alias or path, and no `--spinloop`/`-O` — the command SHALL fetch the named environment's live environment response (the same fetch that supplies `OPENAI_BASE_URL`/`OPENAI_API_KEY`) and, when it carries a deploy-config, synthesise a provider selection from it rather than doing nothing with the flag. + +The deploy-config's runner SHALL become the catalogue provider, by the same mapping `spinloop remote deploy` uses in reverse (a runner is a catalogue provider's engine kind). The deploy-config's served model name SHALL become the model key. The deploy-config's context size, when present, SHALL set the context window. The harness SHALL then be configured and launched exactly as it would be for a Spinloop stating the same `PROVIDER`, `ALIAS` and `CONTEXT` with the same `--env ` — the same environment labelling, base URL, and injected credentials as the existing `--env` behaviour. + +A Spinloop applied alongside `--env` — a leading alias or path, or `--spinloop`/`-O` — SHALL continue to use its own `PROVIDER`, `ALIAS`, `MODEL` and `CONTEXT` exactly as today; the deploy-config's fields SHALL NOT override a value the Spinloop states. + +#### Scenario: bare --env configures and launches the harness +- **WHEN** the user runs `spinloop harness --env dev-3` with no Spinloop applied, and a model is deployed to `dev-3` +- **THEN** the harness is configured with `dev-3` as the provider, the deployed served model name as the model, the deployed context size as the window, and is launched with the fetched base URL and API key in its environment + +#### Scenario: an applied Spinloop still wins +- **WHEN** the user runs `spinloop harness some-alias --env dev-3` (or `--spinloop= --env dev-3`) and the Spinloop states its own `PROVIDER` and `ALIAS` +- **THEN** the Spinloop's values configure the harness; the environment's deploy-config is not consulted for them + +#### Scenario: trailing args are still forwarded +- **WHEN** the user runs `spinloop harness --env dev-3 --prompt "hello"` with no Spinloop applied, and a model is deployed to `dev-3` +- **THEN** the harness is auto-configured from `dev-3` and launched with `--prompt hello` forwarded to it + +### Requirement: harness fails clearly with nothing to auto-configure from +When `spinloop harness --env ` is run with no Spinloop applied, and the environment's fetched response carries no deploy-config — because nothing has been deployed to it, or because its `env` Lambda predates this behaviour and the reply simply omits the fields — the command SHALL fail before launching, rather than launch an unconfigured or misconfigured harness. + +The error SHALL name the environment and say what to do: deploy a model to it (`spinloop remote deploy --env `), or, when a redeployed model is plausible but the reply still lacks the fields, run `spinloop remote bootstrap` to update the control plane to a version whose `env` Lambda reports what is deployed. + +#### Scenario: nothing deployed to the environment +- **WHEN** the user runs `spinloop harness --env dev-3` with no Spinloop applied, and nothing has been deployed to `dev-3` +- **THEN** the command fails saying nothing is deployed to `dev-3` and how to deploy one, and the harness is not launched + +#### Scenario: an env Lambda predating this behaviour +- **WHEN** the user runs `spinloop harness --env dev-3` with no Spinloop applied, and `dev-3`'s `env` Lambda reply carries no deploy-config fields +- **THEN** the command fails the same way as when nothing is deployed, naming `spinloop remote bootstrap` as a way to update the control plane, and the harness is not launched diff --git a/openspec/specs/remote-env/spec.md b/openspec/specs/remote-env/spec.md index 138d376e..bac240f2 100644 --- a/openspec/specs/remote-env/spec.md +++ b/openspec/specs/remote-env/spec.md @@ -49,12 +49,25 @@ Its stdout SHALL carry nothing but the `export` lines, so `eval "$(spinloop remo - **THEN** every line on stdout is an `export` line, the alias note having gone to stderr, and the shell evaluates it without error ### Requirement: env Lambda is fast (no boot) -The `spinloop remote env` command SHALL NOT trigger an instance boot. It only reads the API key from Secrets Manager and the base URL from the environment's Elastic IP. +The `spinloop remote env` command SHALL NOT trigger an instance boot. It reads the API key from Secrets Manager, the base URL from the environment's Elastic IP, and — best-effort — the environment's deploy-config from SSM; none of these require the instance to be running or booting. #### Scenario: env does not start a stopped instance - **WHEN** the user runs `spinloop remote env` and the instance is stopped - **THEN** the command returns quickly with an error (not after minutes of booting) +### Requirement: env Lambda reports what is deployed +The `env` Lambda SHALL read the named environment's deploy-config from SSM (the same state `deploy` writes and `start`/`stats` already relay) and, when one is present and parses, include it in its reply alongside `base_url` and `api_key`: a `deployed` flag, the `runner`, the `modelId`, the `servedName` (the name the engine answers to — an `ALIAS` at deploy time, falling back to the model id), and the `contextSize`. + +When no deploy-config is registered for the environment, or the stored value fails to parse, the reply SHALL omit these fields rather than fail the request: `base_url` and `api_key` remain valid and useful without them, exactly as they were before this requirement existed. + +#### Scenario: env reports the deployed model +- **WHEN** the user runs `spinloop remote env --env dev-3` and a model has been deployed to `dev-3` +- **THEN** the reply includes `deployed: true`, the `runner`, `modelId`, `servedName` and `contextSize` the deploy recorded, alongside `base_url` and `api_key` + +#### Scenario: env degrades gracefully with nothing deployed +- **WHEN** the user runs `spinloop remote env --env dev-3` and `dev-3` is registered but nothing has been deployed to it +- **THEN** the reply carries `base_url` and `api_key` as it always has, with no `deployed`, `runner`, `modelId`, `servedName` or `contextSize` fields, and the command does not fail because of their absence + ### Requirement: start --print-env flag prints exports The `spinloop remote start` command SHALL accept a `--print-env` flag, with no