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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ jobs:
# changelog, matching the real release.
fetch-depth: 0

# This repository is public, so a committed account id, ARN, endpoint
# host, address or resource id would be a disclosure. Checked before
# anything else so it fails fast.
- name: No cloud identifiers
run: scripts/check-no-cloud-identifiers.sh

- uses: actions/setup-go@v7
with:
go-version-file: go.mod
Expand Down
61 changes: 61 additions & 0 deletions .github/workflows/remote.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
name: Remote deployment

# The CDK project under remote/ that `outfit remote` drives. It is TypeScript
# rather than Go, so it has its own workflow; the paths filter keeps it off
# pull requests that do not touch it.
on:
push:
branches: [main]
paths:
- "remote/**"
- ".github/workflows/remote.yml"
pull_request:
paths:
- "remote/**"
- ".github/workflows/remote.yml"
workflow_dispatch:

permissions:
contents: read

defaults:
run:
working-directory: remote

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7

- uses: pnpm/action-setup@v4
with:
version: 10

- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
cache-dependency-path: remote/pnpm-lock.yaml

- name: Install
run: pnpm install --frozen-lockfile

- name: Typecheck
run: pnpm build

# The stack assertions: they synthesize each stack and assert over the
# resulting template (lambdas, IAM scoping, the deploy-config parameter),
# alongside the pure unit tests for the Lambdas' own logic.
- name: Test
run: pnpm test

# Synthesizing the app itself covers what the assertions cannot: bin/app.ts
# wiring, and esbuild bundling every Lambda. No AWS credentials are needed
# — the stacks take their AZs from config rather than looking them up — so
# the context values here are placeholders.
# Invoked directly rather than through `pnpm synth`: pnpm appends extra
# arguments to the end of the script's command line, so the context values
# never reach cdk and the synth fails on the required `runner`.
- name: Synthesize
run: npx cdk synth -c allowedCidr=203.0.113.7/32 -c runner=llamacpp --quiet
27 changes: 21 additions & 6 deletions AGENTS.md

Large diffs are not rendered by default.

35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ outfit harness [<outfit>] [-H <name>] [--outfit[=<path>]] [args...]
# launch the harness (a leading Outfit or alias is
# applied first; --get shows it; --set stores it)
outfit completion <shell> # tab completion (bash, zsh, powershell)
outfit remote <start|stop|status|deploy> [path]
# control the remote GPU inference instance
# (deploy sets what it serves, from the Outfit)
```

Short flags: `-p` (provider), `-f` (model-family), `-m` (model), `-a` (alias), `-c` (context), `-o` (output), `-u` (base-url), `-H` (harness), `-O` (outfit), and under `alias`: `-n` (name), `-l` (list), `-F` (force).
Expand Down Expand Up @@ -284,10 +287,40 @@ the chosen section into the command instead — with anything the `Outfit` state
cover: launching a *single* model. Details in
[`docs/commands/serve.md`](docs/commands/serve.md).

## Remote inference instance

Running a model on your own cloud GPU box? [`remote/`](remote/) deploys one.
`outfit remote` drives its scale-to-zero lifecycle: the instance only exists
while you are using it, and stops itself after a period of idleness.

```sh
outfit remote start # boot the instance, wait for the model to load,
# then print OPENAI_BASE_URL / OPENAI_API_KEY exports
outfit remote status # instance state and endpoint health
outfit remote stop # stop now instead of waiting for the idle timer
```

Configuration is found in one of two places. A project's `Outfit` file can
name it with a `REMOTE` instruction (`REMOTE remote.json`, resolved relative
to the Outfit — like `PRESET`, so the pair travel together); otherwise the
per-user `${XDG_CONFIG_HOME:-~/.config}/outfit/remote.json` is used. Either
way, paste the `OutfitRemoteConfig` output of the `remote/` deployment there:

```json
{"start_url": "https://...lambda-url...on.aws/", "stop_url": "https://...", "region": "eu-west-1"}
```

Each field can be overridden with `OUTFIT_REMOTE_START_URL`,
`OUTFIT_REMOTE_STOP_URL` and `OUTFIT_REMOTE_REGION`. Requests are SigV4-signed
with your AWS credentials (env, profile or SSO — the standard chain), which
must be allowed `lambda:InvokeFunctionUrl`. A cold `start` takes a few
minutes while the instance boots and loads the model; `--timeout` (default
15m) caps the wait.

## Keys and endpoints

Each provider declares which environment variable holds its key (`outfit
list` shows them). Values are looked up in `.env` next to the tool first, then
list` shows them). Values are looked up in a `.env` beside the `Outfit` first, then
your shell environment. Local providers like Ollama and llama.cpp need no key;
Bedrock authenticates through your AWS credentials.

Expand Down
18 changes: 17 additions & 1 deletion cmd/outfit/complete.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,10 @@ type command struct {
// positionals how many arguments that applies to.
positional candidateKind
positionals int
// subcommands is the fixed set a nested command dispatches on. When set,
// the first positional completes to one of these and any later one falls
// through to positional.
subcommands []string
}

// selectionFlags are the flags add and remove share (see parseSelection).
Expand Down Expand Up @@ -162,6 +166,14 @@ var commands = map[string]command{
// harness's own, and outfit has nothing to say about them.
positional: kindAlias, positionals: 1,
},
"remote": {
flags: []string{"--timeout", "--dry-run", "-n"},
values: map[string]candidateKind{"--timeout": kindNone},
// `outfit remote <sub> [outfit]`: the subcommand, then optionally the
// Outfit naming the endpoint.
subcommands: []string{"start", "stop", "status", "deploy"},
positional: kindAlias, positionals: 2,
},
"completion": {positional: kindShell, positionals: 1},
"version": {},
"help": {},
Expand Down Expand Up @@ -233,7 +245,11 @@ func completions(args []string) ([]string, string) {
if strings.HasPrefix(cur, "-") {
return cmd.flags, directiveNoFile
}
if positionalsUsed(cmd, words[1:]) >= cmd.positionals {
used := positionalsUsed(cmd, words[1:])
if len(cmd.subcommands) > 0 && used == 0 {
return cmd.subcommands, directiveNoFile
}
if used >= cmd.positionals {
return nil, directiveNoFile
}
return candidatesFor(cmd.positional, words)
Expand Down
35 changes: 35 additions & 0 deletions cmd/outfit/harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -649,3 +649,38 @@ func TestCmdExportRemove_PiRoundTrip(t *testing.T) {
t.Error("ollama should have been removed from the Pi config")
}
}

// Neither harness stores the secret — opencode substitutes {env:VAR}, Pi
// resolves $VAR — so a key kept only in outfit's .env has to reach the agent
// outfit launches, or the user would have to export it by hand.
func TestHarnessEnv_CarriesResolvableKeys(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "")
fromDotEnv := func(name string) string {
if name == "OPENAI_API_KEY" {
return "sk-from-dotenv"
}
return ""
}

var found string
for _, kv := range harnessEnv("", fromDotEnv) {
if strings.HasPrefix(kv, "OPENAI_API_KEY=") {
found = kv
}
}
if found != "OPENAI_API_KEY=sk-from-dotenv" {
t.Errorf("launched agent's env has %q, want the key from .env", found)
}
}

// An explicit export is the user's own decision and must win.
func TestHarnessEnv_DoesNotOverrideTheEnvironment(t *testing.T) {
t.Setenv("OPENAI_API_KEY", "sk-exported")
fromDotEnv := func(string) string { return "sk-from-dotenv" }

for _, kv := range harnessEnv("", fromDotEnv) {
if kv == "OPENAI_API_KEY=sk-from-dotenv" {
t.Error("the .env value overrode an exported one")
}
}
}
Loading
Loading