Skip to content
Open
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
5 changes: 3 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,9 @@ AUTH_SESSION_TTL=24h
# explicit and easy to audit.
# ---------------------------------------------------------------------------
HTTP_LISTEN=0.0.0.0:7410
# Optional shared bearer token for daemon HTTP(S) control-plane requests.
# Leave empty to disable daemon authentication. Unix socket access, health,
# Optional environment-managed default-admin token for daemon HTTP(S) requests.
# Once initialized, removing it revokes this token but does not reopen anonymous
# access. Unix socket access, health,
# runtime LLM facade, Jupyter proxy, and webhook ingestion use separate trust
# boundaries and do not require this token.
AGENT_COMPOSE_AUTH_TOKEN=
Expand Down
7 changes: 7 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,13 @@ tasks:
- ./scripts/tests/test-binary-ci-contract.sh
- ./scripts/tests/test-image-ci-contract.sh

test:rbac:
desc: Build a local daemon and run the CLI RBAC and audit workflow
dir: '{{.TASKFILE_DIR}}'
cmds:
- task: build:agent-compose
- ./scripts/test-agent-compose-rbac.sh --binary ./build/agent-compose

test:deploy:
desc: Run deterministic installer, Compose, and release-asset deployment checks
dir: '{{.TASKFILE_DIR}}'
Expand Down
38 changes: 35 additions & 3 deletions cmd/agent-compose/cli_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import (
"github.com/spf13/cobra"

"agent-compose/pkg/clientconfig"
agentcomposev2 "agent-compose/proto/agentcompose/v2"
"agent-compose/proto/agentcompose/v2/agentcomposev2connect"
"connectrpc.com/connect"
)

type cliAuthLoginOptions struct {
Expand Down Expand Up @@ -51,7 +54,15 @@ func newCLIAuthCommand(options *cliOptions) *cobra.Command {
Args: cobra.NoArgs,
RunE: runCLIAuthList,
}
authCmd.AddCommand(loginCmd, logoutCmd, listCmd)
whoAmICmd := &cobra.Command{
Use: "whoami",
Short: "Show the current daemon token identity",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, _ []string) error {
return runCLIAuthWhoAmI(cmd, *options)
},
}
authCmd.AddCommand(loginCmd, logoutCmd, listCmd, whoAmICmd, newCLIAuthRoleCommand(options), newCLIAuthTokenCommand(options))
return authCmd
}

Expand All @@ -68,11 +79,16 @@ func runCLIAuthLogin(cmd *cobra.Command, hostFlag string, options cliAuthLoginOp
return commandExitError{Code: exitCodeUsage, Err: fmt.Errorf("auth login requires --host or AGENT_COMPOSE_HOST")}
}
clientConfig.AuthToken = token
if _, err := fetchDaemonVersion(cmd.Context(), clientConfig); err != nil {
client := agentcomposev2connect.NewAuthServiceClient(newDaemonHTTPClient(clientConfig), clientConfig.BaseURL)
identity, err := client.WhoAmI(cmd.Context(), connect.NewRequest(&agentcomposev2.WhoAmIRequest{}))
if err != nil {
var statusErr daemonHTTPStatusError
if errors.As(err, &statusErr) && statusErr.StatusCode == http.StatusUnauthorized {
return fmt.Errorf("authentication failed for %s: token was rejected (HTTP %d)", clientConfig.BaseURL, statusErr.StatusCode)
}
if connect.CodeOf(err) == connect.CodeUnauthenticated {
return fmt.Errorf("authentication failed for %s: token was rejected", clientConfig.BaseURL)
}
return fmt.Errorf("authenticate daemon %s: %w", clientConfig.BaseURL, err)
}
path, err := clientconfig.DefaultPath()
Expand All @@ -82,7 +98,23 @@ func runCLIAuthLogin(cmd *cobra.Command, hostFlag string, options cliAuthLoginOp
if err := clientconfig.SaveToken(path, clientConfig.BaseURL, token); err != nil {
return err
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "Authenticated %s\n", clientConfig.BaseURL)
_, err = fmt.Fprintf(cmd.OutOrStdout(), "Authenticated %s as %s (%s)\n", clientConfig.BaseURL, identity.Msg.GetToken().GetName(), identity.Msg.GetRole())
return err
}

func runCLIAuthWhoAmI(cmd *cobra.Command, options cliOptions) error {
clients, err := newCLIServiceClients(options)
if err != nil {
return err
}
response, err := clients.auth.WhoAmI(cmd.Context(), connect.NewRequest(&agentcomposev2.WhoAmIRequest{}))
if err != nil {
return fmt.Errorf("query daemon identity: %w", err)
}
if options.JSON {
return writeProtoJSON(cmd.OutOrStdout(), response.Msg)
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "TOKEN\tROLE\tORIGIN\n%s\t%s\t%s\n", firstNonEmptyString(response.Msg.GetToken().GetName(), "-"), response.Msg.GetRole(), response.Msg.GetOrigin())
return err
}

Expand Down
17 changes: 16 additions & 1 deletion cmd/agent-compose/cli_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import (
"path/filepath"
"strings"
"testing"

agentcomposev2 "agent-compose/proto/agentcompose/v2"
"agent-compose/proto/agentcompose/v2/agentcomposev2connect"
"google.golang.org/protobuf/proto"
)

func TestCLIAuthLoginPersistsAndReusesToken(t *testing.T) {
Expand All @@ -18,6 +22,17 @@ func TestCLIAuthLoginPersistsAndReusesToken(t *testing.T) {
w.WriteHeader(http.StatusUnauthorized)
return
}
if r.URL.Path == agentcomposev2connect.AuthServiceWhoAmIProcedure {
body, err := proto.Marshal(&agentcomposev2.WhoAmIResponse{
Token: &agentcomposev2.AuthToken{Name: "test-admin"}, Role: "admin", Origin: "issued", AuthenticationInitialized: true,
})
if err != nil {
t.Fatal(err)
}
w.Header().Set("Content-Type", "application/proto")
_, _ = w.Write(body)
return
}
_, _ = io.WriteString(w, `{"err":null,"msg":"OK","data":{"timestamp":1783501631,"version":"test"}}`)
}))
defer server.Close()
Expand Down Expand Up @@ -74,7 +89,7 @@ func TestCLIAuthLoginFailureDoesNotPersistToken(t *testing.T) {
if exitCode == 0 {
t.Fatal("login exit code = 0, want failure")
}
wantError := "authentication failed for " + server.URL + ": token was rejected (HTTP 401)\n"
wantError := "authentication failed for " + server.URL + ": token was rejected\n"
if stderr != wantError {
t.Fatalf("login stderr = %q, want %q", stderr, wantError)
}
Expand Down
Loading
Loading