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
1 change: 1 addition & 0 deletions .agents/skills/amika-annotations/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ trample what they protect.
| `review-change` | XML | The wrapped content was changed by the author and is flagged for review. Evaluate the change against the surrounding context. If it's an improvement, accept it (unwrap). If it should be reverted, note it as a conflict and leave the tags for the user to handle — you cannot recover the original automatically. | unwrap if accepted; leave tagged if rejected |
| `todo` | EDN or XML | Perform the described task on/around the target. | delete |
| `note` | EDN | A standing label or informational marker. Incorporate its content into the surrounding prose or action context, then delete. If it marks something that should remain (e.g. a label the user explicitly wants to keep), leave it — use judgment. | delete |
| `annotate` | EDN or XML | A free-form question or request left for the agent (e.g. "is this still right?" or "can you make X configurable?"). Investigate the question or make the requested change, then record the result at the target. | delete |

> The imperative set above is a **starter**. It's safe to extend this table as new types
> appear; until then, unknown types are handled by inference (below).
Expand Down
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ The repo also ships `amikalog`, a separate, separately-installed CLI that captur
This repository is an OSS monorepo. Go sources live under `go/`. Other
language SDKs live under `sdk/` (e.g. `sdk/typescript/`).

Comments and docstrings here sometimes point at `amika-mono`, Amika's private
control-plane monorepo, for protocol details owned by that side (e.g. "the
ssh-relay repo's specs/019-sandbox-ssh-stream-relay.d/...",
`devdocs/sandbox-secret-scrubbing.md`). This repo's own `specs/` directory is
numbered separately and covers only OSS-specific specs. Look for `amika-mono`
checked out as a sibling worktree rather than searching for those paths in this repo.

## Runtime Dependencies

- **Docker** is required for `materialize`, `sandbox`, and `volume` commands. Preset images (`coder`, `claude`) are auto-built on first use from Dockerfiles in `go/internal/sandbox/presets/`.
Expand Down
7 changes: 3 additions & 4 deletions go/cmd/amika/plumbing.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,9 @@ var plumbingCmd = &cobra.Command{
}

var sshStdioProxyCmd = &cobra.Command{
Use: "ssh-stdio-proxy <host>",
Short: "Proxy standard IO to one SSH transport",
Hidden: true,
Args: cobra.ExactArgs(1),
Use: "ssh-stdio-proxy <host>",
Short: "Proxy standard IO to one SSH transport",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return ssh.ProxySession(
cmd.Context(),
Expand Down
17 changes: 9 additions & 8 deletions go/cmd/amika/scp/scpv2.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ package scpcmd
// The transport difference is entirely in how a sandbox reference becomes an
// scp operand. v1 resolves a sandbox to a concrete host/port/user and spells
// out connection options on the command line; v2 resolves it to its
// `<name>.<id>.amika` alias, whose User, IdentityFile, host-key policy, and
// ProxyCommand all come from the managed `Host *.amika` block in
// ~/.ssh/amika.conf. So there are no connection options to prepend here — the
// operands are rewritten and system scp is handed the rest unchanged.
// `<name>.<id>.<environment>.amika` alias (ssh.BuildSessionAlias), whose
// User, IdentityFile, host-key policy, and ProxyCommand all come from the
// managed `Host *.amika` block in ~/.ssh/amika.conf. So there are no
// connection options to prepend here — the operands are rewritten and system
// scp is handed the rest unchanged.

import (
"fmt"
Expand Down Expand Up @@ -160,10 +161,10 @@ Operands take the same forms as "amika scp":
"/" in NAME must be percent-encoded as %2F
scp://[user@]host[:port][/path] a path on an arbitrary SSH host

Each sandbox resolves to its "<name>.<id>.amika" alias, and the connection
settings come from the managed block in ~/.ssh/amika.conf, so a fresh session
is created and the host key re-pinned per dial. Requires an SSH identity from
"amika secret ssh-keygen".
Each sandbox resolves to its "<name>.<id>.<environment>.amika" alias, and the
connection settings come from the managed block in ~/.ssh/amika.conf, so a
fresh session is created and the host key re-pinned per dial. Requires an SSH
identity from "amika secret ssh-keygen".

Examples:
# Upload a file into the sandbox home
Expand Down
9 changes: 8 additions & 1 deletion go/cmd/amika/scp/scpv2_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ import (
)

// stubResolver resolves any sandbox name to a deterministic v2 alias and
// records the names it was asked for.
// records the names it was asked for. The fixture alias below is a stand-in
// for exercising buildSCPV2Invocation's operand rewriting in isolation, not a
// copy of the real format: production aliases are
// "<name>.<id>.<environment>.amika" (ssh.BuildSessionAlias), where
// <environment> is the AMIKA_API_URL host reformatted to [a-z0-9-]
// (config.EnvironmentSlug). That third segment landed after this test was
// written; it doesn't need to be reflected here since nothing in this file
// depends on the resolver's output shape, only on where it gets substituted.
func stubResolver(seen *[]string) aliasResolver {
return func(name string) (string, error) {
if name == "missing" {
Expand Down
3 changes: 3 additions & 0 deletions go/cmd/amika/secrets.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package main

// TODO(KAPRO-747): this file is too big; split it up under go/cmd/amika/secrets/...
// as a standalone commit unrelated to other in-flight work.

import (
"bufio"
"encoding/json"
Expand Down
10 changes: 10 additions & 0 deletions go/internal/amikad/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"errors"
"io"

"github.com/gofixpoint/amika/go/internal/amikad/norelay"
"github.com/gofixpoint/amika/go/internal/buildmeta"
"github.com/spf13/cobra"
)
Expand All @@ -22,6 +23,9 @@ type ServeOptions struct {
Port int
BetaNoRelay bool
Background bool
// MaxConnections bounds concurrent no-relay bridge sessions. Values <= 0
// fall back to norelay.DefaultMaxConnections (see norelay.NewHandler).
MaxConnections int
}

// SetupSSHDOptions controls whether managed setup may replace an existing
Expand Down Expand Up @@ -169,6 +173,12 @@ func NewCommand(operations Operations) *cobra.Command {
false,
"start in the background and wait until healthy",
)
serve.Flags().IntVar(
&serveOptions.MaxConnections,
"max-connections",
norelay.DefaultMaxConnections,
"maximum concurrent no-relay bridge sessions",
Comment on lines +177 to +180

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Forward the connection limit to the background daemon

When serve is invoked with both --bg and a non-default --max-connections, Serve branches to serveBackground before constructing the handler, and that function launches the child with only --port and --beta-no-relay. The child therefore silently uses the default limit of 64, so this newly advertised flag has no effect in background mode; include the selected limit in the child arguments.

Useful? React with 👍 / 👎.

)

root.AddCommand(setup, hostKey, authorizedKeys, connectToken, serve)
return root
Expand Down
6 changes: 5 additions & 1 deletion go/internal/amikad/norelay/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,10 +71,14 @@ type Handler struct {
streams map[Stream]struct{}
}

// DefaultMaxConnections is the connection cap used when Config.MaxConnections
// is left unset.
const DefaultMaxConnections = 64

// NewHandler creates a no-relay handler without opening any listener.
func NewHandler(config Config, deps Dependencies) *Handler {
if config.MaxConnections <= 0 {
config.MaxConnections = 64
config.MaxConnections = DefaultMaxConnections
}
if config.SSHDAddress == "" {
config.SSHDAddress = "127.0.0.1:22"
Expand Down
23 changes: 23 additions & 0 deletions go/internal/amikad/norelay/token.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,28 @@
package norelay

// This file implements only the sandbox-side verification half of the
// no-relay connect-token system. The full round trip:
//
// 1. The control plane (js/coding-agents in the ssh-relay repo) generates a
// random 32-byte token per rotation, base64url-encodes it, and delivers
// the plaintext to this sandbox over the provider's exec/stdin channel by
// invoking `amikad connect-token set` — never a CLI argument, so it never
// appears in `ps` or shell history. See operations.go's SetConnectToken
// for the write side.
// 2. The control plane keeps only a SHA-256 hash of the token (plus a
// Vault-encrypted copy) in its own database, and hands the plaintext back
// to an authorized CLI caller as a bearer credential scoped to one SSH
// session.
// 3. FileTokenVerifier below re-reads the on-disk token on every WebSocket
// upgrade (see handler.go), so a control-plane rotation takes effect
// immediately without restarting `amikad serve`. It fails closed on
// anything but an owner-only, exact-size, canonically-encoded token file,
// and never returns the stored value to callers — only whether a
// candidate matches.
//
// This file has no knowledge of Vault, orgs, or rotation history; it only
// answers "does this candidate match what's currently on disk."

import (
"crypto/sha256"
"crypto/subtle"
Expand Down
32 changes: 31 additions & 1 deletion go/internal/amikad/operations.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
package amikad

// SetConnectToken (below) is the sandbox-side write half of the no-relay
// connect-token system that norelay.FileTokenVerifier (norelay/token.go)
// verifies. Round trip, briefly:
//
// - The control plane generates a fresh 32-byte token on every
// start/resume, keeps a SHA-256 hash of it (plus a Vault-encrypted copy)
// in its own database, and delivers the plaintext here over the
// provider's exec/stdin channel by running `amikad connect-token set`.
// - SetConnectToken validates the token is canonical (see
// norelay.IsCanonicalToken) and writes it at 0600 through the
// scrub-registered SensitiveStore, so it is both fail-closed on bad input
// and covered by the snapshot-scrubbing manifest.
// - Serve refuses to start the bridge at all unless a valid token is
// already on disk (verifier.Ready()); once serving, every WebSocket
// upgrade re-reads the file, so a later rotation takes effect without a
// restart.
//
// See the ssh-relay repo's specs/019-sandbox-ssh-stream-relay.d/
// no-relay-websocket-ssh.md ("Connection flow" / "Resolved decisions") for
// the control-plane side: generation, hashing, and per-start rotation.

import (
"context"
"encoding/json"
Expand Down Expand Up @@ -125,6 +146,11 @@ func (o *DaemonOperations) SetConnectToken(ctx context.Context, input io.Reader)
if !norelay.IsCanonicalToken(token) {
return ErrUnsafeConnectToken
}
// Registration here is the "find" half of scrubbing: the control plane's
// snapshot-capture scrub reads the manifest this call appends to, removes
// every absolute path it lists, and fails snapshot capture closed if it
// can't confirm a path is gone. See package state's doc comment for the
// manifest format and the full mechanism.
return o.store.WriteAndRegister(ctx, o.tokenPath, strings.NewReader(token), 0o600)
}

Expand All @@ -145,7 +171,7 @@ func (o *DaemonOperations) Serve(ctx context.Context, options ServeOptions) erro
}

handler := norelay.NewHandler(
norelay.Config{MaxConnections: 64, SSHDAddress: o.sshd.LoopbackAddress()},
norelay.Config{MaxConnections: options.MaxConnections, SSHDAddress: o.sshd.LoopbackAddress()},
norelay.Dependencies{
Verifier: o.verifier,
Upgrader: norelay.WebSocketUpgrader{},
Expand All @@ -155,6 +181,10 @@ func (o *DaemonOperations) Serve(ctx context.Context, options ServeOptions) erro
)
mux := http.NewServeMux()
mux.Handle(norelay.SSHSessionsPath, handler)
// "healthz" follows the Kubernetes/Google convention for a liveness
// endpoint (distinct from an app-level "/health" that might carry richer
// status); the control plane's own e2e checks already probe this exact
// path, so renaming it would be a breaking change for no functional gain.
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json")
Expand Down
18 changes: 15 additions & 3 deletions go/internal/amikad/sshd/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,18 +49,30 @@ type Paths struct {
Port int
}

// DefaultPaths returns the production paths owned by amikad.
// EnvManagedUser overrides the account whose UID/GID own the managed sshd's
// authorized_keys file, and whose home directory it lives under. Empty (the
// default) uses "amika". This is a single-user override, not multi-user
// support: everything under DefaultPaths still assumes exactly one managed
// account.
const EnvManagedUser = "AMIKA_SSHD_USER"

// DefaultPaths returns the production paths owned by amikad, for the managed
// user named by EnvManagedUser (default "amika").
func DefaultPaths() Paths {
username := os.Getenv(EnvManagedUser)
if username == "" {
username = "amika"
}
uid, gid := -1, -1
if account, err := user.Lookup("amika"); err == nil {
if account, err := user.Lookup(username); err == nil {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the managed-user override to the sshd policy

When AMIKA_SSHD_USER is set to anything other than amika, this lookup and the authorized-keys ownership switch to the custom account, but RenderConfig still emits AllowUsers amika. The managed daemon consequently denies SSH logins as the selected account, making the override unusable; carry the managed username into the rendered policy instead of retaining the hard-coded allowlist.

Useful? React with 👍 / 👎.

uid, _ = strconv.Atoi(account.Uid)
gid, _ = strconv.Atoi(account.Gid)
}
return Paths{
Config: "/var/lib/amikad/sshd_config",
HostPrivateKey: "/var/lib/amikad/ssh_host_ed25519_key",
HostPublicKey: "/var/lib/amikad/ssh_host_ed25519_key.pub",
AuthorizedKeys: "/home/amika/.ssh/authorized_keys",
AuthorizedKeys: "/home/" + username + "/.ssh/authorized_keys",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use the selected account's actual home directory

For a managed account whose home is not /home/<username> (for example a service account with a home under /var/lib), user.Lookup succeeds but this constructs an unrelated authorized-keys path. prepareAuthorizedKeysDirectory then either fails because that directory is absent or installs keys outside the account's home; build the path from the looked-up account's HomeDir.

Useful? React with 👍 / 👎.

PID: "/var/lib/amikad/sshd.pid",
RuntimeDirectory: "/run/sshd",
AuthorizedKeysUID: uid,
Expand Down
26 changes: 26 additions & 0 deletions go/internal/amikad/state/contracts.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,30 @@
// Package state owns amikad's sensitive files and scrub manifest.
//
// The contract with the amika-mono control plane is register-before-write:
// WriteAndRegister and WriteAndRegisterOwned durably append a sensitive
// file's absolute path to the manifest before installing the file itself, so
// a consumer that reads the manifest and removes every path it lists can
// never miss a file this package has fully written. Worst case the consumer
// deletes a path that turned out not to be needed; it can never skip one
// that is.
//
// The manifest is a JSON array of absolute file path strings, e.g.
// `["/home/amika/.ssh/authorized_keys", "/var/lib/amikad/connect-token"]`,
// at a fixed path (production: /var/lib/amikad/injected-paths.json — under
// /var/lib per the Filesystem Hierarchy Standard, since this is variable
// runtime state rather than config). Entries must be clean, absolute,
// non-duplicate, and never equal to the manifest path itself; readManifest
// rejects a manifest violating any of those (ErrInvalidManifest).
//
// Before capturing a sandbox into a snapshot, the amika-mono control plane
// scrubs every path the manifest lists — running as root, since some
// registered paths (e.g. the SSH host key) are root-owned — then scrubs the
// manifest file itself last. Scrub verification fails closed: if the control
// plane cannot confirm a listed path is gone, snapshot capture aborts rather
// than let a credential ride along into the snapshot. See the ssh-relay
// repo's specs/019-sandbox-ssh-stream-relay.d/no-relay-websocket-ssh.md
// ("Injection must register its own cleanup") and amika-mono's
// devdocs/sandbox-secret-scrubbing.md for the full mechanism.
package state

import (
Expand Down
Loading