diff --git a/.github/workflows/components-build-deploy.yml b/.github/workflows/components-build-deploy.yml old mode 100755 new mode 100644 index 66257c54e..51a634896 --- a/.github/workflows/components-build-deploy.yml +++ b/.github/workflows/components-build-deploy.yml @@ -15,6 +15,7 @@ on: - 'components/ambient-control-plane/**' - 'components/ambient-mcp/**' - 'components/ambient-ui/**' + - 'components/credential-sidecars/**' pull_request: branches: [main, alpha] paths: @@ -29,10 +30,11 @@ on: - 'components/ambient-control-plane/**' - 'components/ambient-mcp/**' - 'components/ambient-ui/**' + - 'components/credential-sidecars/**' workflow_dispatch: inputs: components: - description: 'Components to build (comma-separated: frontend,backend,operator,ambient-runner,state-sync,public-api,ambient-api-server,ambient-control-plane,ambient-mcp,ambient-ui) - leave empty for all' + description: 'Components to build (comma-separated: frontend,backend,operator,ambient-runner,state-sync,public-api,ambient-api-server,ambient-control-plane,ambient-mcp,ambient-ui,credential-github,credential-jira,credential-k8s,credential-google) - leave empty for all' required: false type: string default: '' @@ -63,7 +65,11 @@ jobs: {"name":"ambient-api-server","context":"./components/ambient-api-server","image":"quay.io/ambient_code/vteam_api_server","dockerfile":"./components/ambient-api-server/Dockerfile"}, {"name":"ambient-control-plane","context":"./components","image":"quay.io/ambient_code/vteam_control_plane","dockerfile":"./components/ambient-control-plane/Dockerfile"}, {"name":"ambient-mcp","context":"./components/ambient-mcp","image":"quay.io/ambient_code/vteam_mcp","dockerfile":"./components/ambient-mcp/Dockerfile"}, - {"name":"ambient-ui","context":"./components","image":"quay.io/ambient_code/vteam_ambient_ui","dockerfile":"./components/ambient-ui/Dockerfile"} + {"name":"ambient-ui","context":"./components","image":"quay.io/ambient_code/vteam_ambient_ui","dockerfile":"./components/ambient-ui/Dockerfile"}, + {"name":"credential-github","context":"./components","image":"quay.io/ambient_code/vteam_credential_github","dockerfile":"./components/credential-sidecars/github/Dockerfile"}, + {"name":"credential-jira","context":"./components","image":"quay.io/ambient_code/vteam_credential_jira","dockerfile":"./components/credential-sidecars/jira/Dockerfile"}, + {"name":"credential-k8s","context":"./components","image":"quay.io/ambient_code/vteam_credential_k8s","dockerfile":"./components/credential-sidecars/k8s/Dockerfile"}, + {"name":"credential-google","context":"./components","image":"quay.io/ambient_code/vteam_credential_google","dockerfile":"./components/credential-sidecars/google/Dockerfile"} ]' SELECTED="${{ github.event.inputs.components }}" @@ -413,6 +419,14 @@ jobs: AMBIENT_CODE_RUNNER_IMAGE="quay.io/ambient_code/vteam_claude_runner:${{ github.sha }}" \ STATE_SYNC_IMAGE="quay.io/ambient_code/vteam_state_sync:${{ github.sha }}" + - name: Update credential sidecar image tags on control plane + run: | + oc set env deployment/ambient-control-plane -n ambient-code \ + GITHUB_MCP_IMAGE="quay.io/ambient_code/vteam_credential_github:${{ github.sha }}" \ + JIRA_MCP_IMAGE="quay.io/ambient_code/vteam_credential_jira:${{ github.sha }}" \ + K8S_MCP_IMAGE="quay.io/ambient_code/vteam_credential_k8s:${{ github.sha }}" \ + GOOGLE_MCP_IMAGE="quay.io/ambient_code/vteam_credential_google:${{ github.sha }}" + - name: Pin OPERATOR_IMAGE in operator-config ConfigMap run: | oc patch configmap operator-config -n ambient-code --type=merge \ @@ -492,6 +506,14 @@ jobs: AMBIENT_CODE_RUNNER_IMAGE="quay.io/ambient_code/vteam_claude_runner:${{ github.sha }}" \ STATE_SYNC_IMAGE="quay.io/ambient_code/vteam_state_sync:${{ github.sha }}" + - name: Update credential sidecar image tags on control plane + run: | + oc set env deployment/ambient-control-plane -n ambient-code \ + GITHUB_MCP_IMAGE="quay.io/ambient_code/vteam_credential_github:${{ github.sha }}" \ + JIRA_MCP_IMAGE="quay.io/ambient_code/vteam_credential_jira:${{ github.sha }}" \ + K8S_MCP_IMAGE="quay.io/ambient_code/vteam_credential_k8s:${{ github.sha }}" \ + GOOGLE_MCP_IMAGE="quay.io/ambient_code/vteam_credential_google:${{ github.sha }}" + - name: Pin OPERATOR_IMAGE in operator-config ConfigMap run: | oc patch configmap operator-config -n ambient-code --type=merge \ diff --git a/.github/workflows/prod-release-deploy.yaml b/.github/workflows/prod-release-deploy.yaml old mode 100755 new mode 100644 index 4948b387d..d45f97d4a --- a/.github/workflows/prod-release-deploy.yaml +++ b/.github/workflows/prod-release-deploy.yaml @@ -18,7 +18,7 @@ on: type: boolean default: true components: - description: 'Components to build (comma-separated: frontend,backend,operator,ambient-runner,state-sync,public-api,ambient-api-server,ambient-control-plane,ambient-mcp,ambient-ui) - leave empty for all' + description: 'Components to build (comma-separated: frontend,backend,operator,ambient-runner,state-sync,public-api,ambient-api-server,ambient-control-plane,ambient-mcp,ambient-ui,credential-github,credential-jira,credential-k8s,credential-google) - leave empty for all' required: false type: string default: '' @@ -239,7 +239,11 @@ jobs: {"name":"ambient-api-server","context":"./components/ambient-api-server","image":"quay.io/ambient_code/vteam_api_server","dockerfile":"./components/ambient-api-server/Dockerfile"}, {"name":"ambient-control-plane","context":"./components","image":"quay.io/ambient_code/vteam_control_plane","dockerfile":"./components/ambient-control-plane/Dockerfile"}, {"name":"ambient-mcp","context":"./components/ambient-mcp","image":"quay.io/ambient_code/vteam_mcp","dockerfile":"./components/ambient-mcp/Dockerfile"}, - {"name":"ambient-ui","context":"./components","image":"quay.io/ambient_code/vteam_ambient_ui","dockerfile":"./components/ambient-ui/Dockerfile"} + {"name":"ambient-ui","context":"./components","image":"quay.io/ambient_code/vteam_ambient_ui","dockerfile":"./components/ambient-ui/Dockerfile"}, + {"name":"credential-github","context":"./components","image":"quay.io/ambient_code/vteam_credential_github","dockerfile":"./components/credential-sidecars/github/Dockerfile"}, + {"name":"credential-jira","context":"./components","image":"quay.io/ambient_code/vteam_credential_jira","dockerfile":"./components/credential-sidecars/jira/Dockerfile"}, + {"name":"credential-k8s","context":"./components","image":"quay.io/ambient_code/vteam_credential_k8s","dockerfile":"./components/credential-sidecars/k8s/Dockerfile"}, + {"name":"credential-google","context":"./components","image":"quay.io/ambient_code/vteam_credential_google","dockerfile":"./components/credential-sidecars/google/Dockerfile"} ]' FORCE_ALL="${{ github.event.inputs.force_build_all }}" @@ -710,6 +714,28 @@ jobs: oc set env deployment/agentic-operator -n ambient-code -c agentic-operator $ARGS fi + + - name: Update credential sidecar image tags on control plane + run: | + RELEASE_TAG="${{ needs.release.outputs.new_tag }}" + BUILT="${{ steps.built.outputs.names }}" + ARGS="" + if echo ",$BUILT," | grep -q ",credential-github,"; then + ARGS="$ARGS GITHUB_MCP_IMAGE=quay.io/ambient_code/vteam_credential_github:${RELEASE_TAG}" + fi + if echo ",$BUILT," | grep -q ",credential-jira,"; then + ARGS="$ARGS JIRA_MCP_IMAGE=quay.io/ambient_code/vteam_credential_jira:${RELEASE_TAG}" + fi + if echo ",$BUILT," | grep -q ",credential-k8s,"; then + ARGS="$ARGS K8S_MCP_IMAGE=quay.io/ambient_code/vteam_credential_k8s:${RELEASE_TAG}" + fi + if echo ",$BUILT," | grep -q ",credential-google,"; then + ARGS="$ARGS GOOGLE_MCP_IMAGE=quay.io/ambient_code/vteam_credential_google:${RELEASE_TAG}" + fi + if [ -n "$ARGS" ]; then + oc set env deployment/ambient-control-plane -n ambient-code $ARGS + fi + - name: Pin OPERATOR_IMAGE in operator-config ConfigMap run: | RELEASE_TAG="${{ needs.release.outputs.new_tag }}" diff --git a/components/ambient-api-server/plugins/credentials/migration.go b/components/ambient-api-server/plugins/credentials/migration.go index 8f8adc6a0..c8cd0d4f6 100644 --- a/components/ambient-api-server/plugins/credentials/migration.go +++ b/components/ambient-api-server/plugins/credentials/migration.go @@ -108,7 +108,7 @@ func rolesMigration() *gormigrate.Migration { func dropProjectIDMigration() *gormigrate.Migration { return &gormigrate.Migration{ - ID: "202505120001", + ID: "202605060003", Migrate: func(tx *gorm.DB) error { return tx.Exec(`ALTER TABLE IF EXISTS credentials DROP COLUMN IF EXISTS project_id`).Error }, diff --git a/components/ambient-api-server/plugins/projects/migration.go b/components/ambient-api-server/plugins/projects/migration.go index dc5e08b29..b6b7051d1 100644 --- a/components/ambient-api-server/plugins/projects/migration.go +++ b/components/ambient-api-server/plugins/projects/migration.go @@ -42,7 +42,7 @@ func promptMigration() *gormigrate.Migration { func dropDisplayNameMigration() *gormigrate.Migration { return &gormigrate.Migration{ - ID: "202505090001", + ID: "202605060002", Migrate: func(tx *gorm.DB) error { return tx.Exec(`ALTER TABLE IF EXISTS projects DROP COLUMN IF EXISTS display_name`).Error }, diff --git a/components/ambient-api-server/plugins/roleBindings/migration.go b/components/ambient-api-server/plugins/roleBindings/migration.go index c0f5999dd..05b45e71f 100644 --- a/components/ambient-api-server/plugins/roleBindings/migration.go +++ b/components/ambient-api-server/plugins/roleBindings/migration.go @@ -34,15 +34,19 @@ func typedFKMigration() *gormigrate.Migration { return &gormigrate.Migration{ ID: "202603100139", Migrate: func(tx *gorm.DB) error { - // Drop the old unique index that depends on scope_id before altering columns + var exists bool + if err := tx.Raw(`SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'role_bindings')`).Scan(&exists).Error; err != nil { + return err + } + if !exists { + return nil + } if err := tx.Exec(`DROP INDEX IF EXISTS idx_binding_lookup`).Error; err != nil { return err } - // Make user_id nullable if err := tx.Exec(`ALTER TABLE role_bindings ALTER COLUMN user_id DROP NOT NULL`).Error; err != nil { return err } - // Drop scope_id column (replaced by typed FKs) if err := tx.Exec(`ALTER TABLE role_bindings DROP COLUMN IF EXISTS scope_id`).Error; err != nil { return err } diff --git a/components/ambient-cli/cmd/acpctl/agent/cmd_test.go b/components/ambient-cli/cmd/acpctl/agent/cmd_test.go new file mode 100644 index 000000000..4a2f300b3 --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/agent/cmd_test.go @@ -0,0 +1,577 @@ +package agent + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/ambient-code/platform/components/ambient-cli/internal/testhelper" + "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" +) + +var testTime = time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) + +func sampleAgent(id, name, projectID string) types.Agent { + return types.Agent{ + ObjectReference: types.ObjectReference{ID: id, CreatedAt: &testTime, UpdatedAt: &testTime}, + Name: name, + ProjectID: projectID, + } +} + +func handleAgentLookup(t *testing.T, srv *testhelper.Server, projectID string, agent types.Agent) { + t.Helper() + srv.Handle("/api/ambient/v1/projects/"+projectID+"/agents", func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + srv.RespondJSON(t, w, http.StatusOK, &types.AgentList{ + ListMeta: types.ListMeta{Total: 1}, + Items: []types.Agent{agent}, + }) + return + } + if r.Method == http.MethodPost { + srv.RespondJSON(t, w, http.StatusCreated, agent) + return + } + w.WriteHeader(http.StatusMethodNotAllowed) + }) +} + +func TestListAgents_Success(t *testing.T) { + srv := testhelper.NewServer(t) + agents := []types.Agent{ + sampleAgent("a1", "lead", testhelper.TestProject), + sampleAgent("a2", "worker", testhelper.TestProject), + } + srv.Handle("/api/ambient/v1/projects/"+testhelper.TestProject+"/agents", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + srv.RespondJSON(t, w, http.StatusOK, &types.AgentList{ + ListMeta: types.ListMeta{Total: 2}, + Items: agents, + }) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "list") + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stdout, "lead") { + t.Errorf("expected 'lead' in output, got: %s", result.Stdout) + } + if !strings.Contains(result.Stdout, "worker") { + t.Errorf("expected 'worker' in output, got: %s", result.Stdout) + } +} + +func TestListAgents_JSON(t *testing.T) { + srv := testhelper.NewServer(t) + srv.Handle("/api/ambient/v1/projects/"+testhelper.TestProject+"/agents", func(w http.ResponseWriter, r *http.Request) { + srv.RespondJSON(t, w, http.StatusOK, &types.AgentList{ + Items: []types.Agent{sampleAgent("a1", "json-agent", testhelper.TestProject)}, + }) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "list", "-o", "json") + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if !strings.Contains(result.Stdout, `"json-agent"`) { + t.Errorf("expected JSON with 'json-agent', got: %s", result.Stdout) + } +} + +func TestGetAgent_Success(t *testing.T) { + srv := testhelper.NewServer(t) + agent := sampleAgent("a-get", "my-agent", testhelper.TestProject) + handleAgentLookup(t, srv, testhelper.TestProject, agent) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "get", "my-agent") + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stdout, "my-agent") { + t.Errorf("expected 'my-agent' in output, got: %s", result.Stdout) + } +} + +func TestGetAgent_JSON(t *testing.T) { + srv := testhelper.NewServer(t) + agent := sampleAgent("a-gj", "json-get", testhelper.TestProject) + handleAgentLookup(t, srv, testhelper.TestProject, agent) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "get", "json-get", "-o", "json") + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if !strings.Contains(result.Stdout, `"json-get"`) { + t.Errorf("expected JSON with 'json-get', got: %s", result.Stdout) + } +} + +func TestGetAgent_MissingArg(t *testing.T) { + srv := testhelper.NewServer(t) + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "get") + if result.Err == nil { + t.Fatal("expected error for missing agent argument") + } +} + +func TestCreateAgent_Success(t *testing.T) { + srv := testhelper.NewServer(t) + srv.Handle("/api/ambient/v1/projects/"+testhelper.TestProject+"/agents", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + body, _ := io.ReadAll(r.Body) + var agent types.Agent + if err := json.Unmarshal(body, &agent); err != nil { + t.Fatalf("unmarshal request: %v", err) + } + if agent.Prompt != "You are the lead" { + t.Errorf("expected prompt 'You are the lead', got %q", agent.Prompt) + } + srv.RespondJSON(t, w, http.StatusCreated, &types.Agent{ + ObjectReference: types.ObjectReference{ID: "a-new"}, + Name: "lead", + ProjectID: testhelper.TestProject, + }) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "create", "--name", "lead", "--prompt", "You are the lead") + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stdout, "agent/lead created") { + t.Errorf("expected 'agent/lead created', got: %s", result.Stdout) + } +} + +func TestCreateAgent_MissingName(t *testing.T) { + srv := testhelper.NewServer(t) + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "create") + if result.Err == nil { + t.Fatal("expected error for missing --name") + } + if !strings.Contains(result.Err.Error(), "--name is required") { + t.Errorf("expected '--name is required', got: %v", result.Err) + } +} + +func TestCreateAgent_JSON(t *testing.T) { + srv := testhelper.NewServer(t) + srv.Handle("/api/ambient/v1/projects/"+testhelper.TestProject+"/agents", func(w http.ResponseWriter, r *http.Request) { + srv.RespondJSON(t, w, http.StatusCreated, &types.Agent{ + ObjectReference: types.ObjectReference{ID: "a-json"}, + Name: "json-agent", + ProjectID: testhelper.TestProject, + }) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "create", "--name", "json-agent", "-o", "json") + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if !strings.Contains(result.Stdout, `"json-agent"`) { + t.Errorf("expected JSON with 'json-agent', got: %s", result.Stdout) + } +} + +func TestUpdateAgent_Success(t *testing.T) { + srv := testhelper.NewServer(t) + agent := sampleAgent("a-u1", "update-me", testhelper.TestProject) + handleAgentLookup(t, srv, testhelper.TestProject, agent) + srv.Handle("/api/ambient/v1/projects/"+testhelper.TestProject+"/agents/a-u1", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + body, _ := io.ReadAll(r.Body) + var patch map[string]interface{} + if err := json.Unmarshal(body, &patch); err != nil { + t.Fatalf("unmarshal patch: %v", err) + } + if patch["prompt"] != "new instructions" { + t.Errorf("expected prompt 'new instructions', got %v", patch["prompt"]) + } + agent.Prompt = "new instructions" + srv.RespondJSON(t, w, http.StatusOK, agent) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "update", "update-me", "--prompt", "new instructions") + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stdout, "agent/update-me updated") { + t.Errorf("expected 'agent/update-me updated', got: %s", result.Stdout) + } +} + +func TestUpdateAgent_MissingArg(t *testing.T) { + srv := testhelper.NewServer(t) + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "update") + if result.Err == nil { + t.Fatal("expected error for missing agent argument") + } +} + +func TestDeleteAgent_Success(t *testing.T) { + srv := testhelper.NewServer(t) + agent := sampleAgent("a-d1", "delete-me", testhelper.TestProject) + handleAgentLookup(t, srv, testhelper.TestProject, agent) + srv.Handle("/api/ambient/v1/projects/"+testhelper.TestProject+"/agents/a-d1", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + w.WriteHeader(http.StatusNoContent) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "delete", "delete-me", "--confirm") + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stdout, "agent/delete-me deleted") { + t.Errorf("expected 'agent/delete-me deleted', got: %s", result.Stdout) + } +} + +func TestDeleteAgent_MissingConfirm(t *testing.T) { + srv := testhelper.NewServer(t) + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "delete", "some-agent") + if result.Err == nil { + t.Fatal("expected error for missing --confirm") + } + if !strings.Contains(result.Err.Error(), "--confirm") { + t.Errorf("expected '--confirm' in error, got: %v", result.Err) + } +} + +func TestDeleteAgent_MissingArg(t *testing.T) { + srv := testhelper.NewServer(t) + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "delete", "--confirm") + if result.Err == nil { + t.Fatal("expected error for missing agent argument") + } +} + +func TestStartAgent_Success(t *testing.T) { + srv := testhelper.NewServer(t) + agent := sampleAgent("a-s1", "start-me", testhelper.TestProject) + handleAgentLookup(t, srv, testhelper.TestProject, agent) + srv.Handle("/api/ambient/v1/projects/"+testhelper.TestProject+"/agents/a-s1/start", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + srv.RespondJSON(t, w, http.StatusCreated, &types.StartResponse{ + Session: &types.Session{ + ObjectReference: types.ObjectReference{ID: "sess-1"}, + Phase: "Pending", + }, + }) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "start", "start-me") + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stdout, "session/sess-1 started") { + t.Errorf("expected 'session/sess-1 started', got: %s", result.Stdout) + } +} + +func TestStartAgent_WithPrompt(t *testing.T) { + srv := testhelper.NewServer(t) + agent := sampleAgent("a-sp", "prompt-agent", testhelper.TestProject) + handleAgentLookup(t, srv, testhelper.TestProject, agent) + srv.Handle("/api/ambient/v1/projects/"+testhelper.TestProject+"/agents/a-sp/start", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var req types.StartRequest + if err := json.Unmarshal(body, &req); err != nil { + t.Fatalf("unmarshal start request: %v", err) + } + if req.Prompt != "fix the bug" { + t.Errorf("expected prompt 'fix the bug', got %q", req.Prompt) + } + srv.RespondJSON(t, w, http.StatusCreated, &types.StartResponse{ + Session: &types.Session{ + ObjectReference: types.ObjectReference{ID: "sess-p"}, + Phase: "Pending", + }, + }) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "start", "prompt-agent", "--prompt", "fix the bug") + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } +} + +func TestStartAgent_JSON(t *testing.T) { + srv := testhelper.NewServer(t) + agent := sampleAgent("a-sj", "json-start", testhelper.TestProject) + handleAgentLookup(t, srv, testhelper.TestProject, agent) + srv.Handle("/api/ambient/v1/projects/"+testhelper.TestProject+"/agents/a-sj/start", func(w http.ResponseWriter, r *http.Request) { + srv.RespondJSON(t, w, http.StatusCreated, &types.StartResponse{ + Session: &types.Session{ + ObjectReference: types.ObjectReference{ID: "sess-json"}, + Phase: "Pending", + }, + }) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "start", "json-start", "-o", "json") + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if !strings.Contains(result.Stdout, `"sess-json"`) { + t.Errorf("expected JSON with 'sess-json', got: %s", result.Stdout) + } +} + +func TestStartAgent_MissingArg(t *testing.T) { + srv := testhelper.NewServer(t) + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "start") + if result.Err == nil { + t.Fatal("expected error for missing agent argument") + } +} + +func TestStartAgent_AllFlag(t *testing.T) { + srv := testhelper.NewServer(t) + agents := []types.Agent{ + sampleAgent("a-all1", "agent-1", testhelper.TestProject), + sampleAgent("a-all2", "agent-2", testhelper.TestProject), + } + srv.Handle("/api/ambient/v1/projects/"+testhelper.TestProject+"/agents", func(w http.ResponseWriter, r *http.Request) { + srv.RespondJSON(t, w, http.StatusOK, &types.AgentList{ + ListMeta: types.ListMeta{Total: 2}, + Items: agents, + }) + }) + srv.Handle("/api/ambient/v1/projects/"+testhelper.TestProject+"/agents/a-all1/start", func(w http.ResponseWriter, r *http.Request) { + srv.RespondJSON(t, w, http.StatusCreated, &types.StartResponse{ + Session: &types.Session{ + ObjectReference: types.ObjectReference{ID: "s-1"}, + Phase: "Pending", + }, + }) + }) + srv.Handle("/api/ambient/v1/projects/"+testhelper.TestProject+"/agents/a-all2/start", func(w http.ResponseWriter, r *http.Request) { + srv.RespondJSON(t, w, http.StatusCreated, &types.StartResponse{ + Session: &types.Session{ + ObjectReference: types.ObjectReference{ID: "s-2"}, + Phase: "Pending", + }, + }) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "start", "--all") + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stdout, "s-1") || !strings.Contains(result.Stdout, "s-2") { + t.Errorf("expected both sessions in output, got: %s", result.Stdout) + } +} + +func TestStartAgent_AllWithNameConflict(t *testing.T) { + srv := testhelper.NewServer(t) + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "start", "some-agent", "--all") + if result.Err == nil { + t.Fatal("expected error for --all with agent name") + } + if !strings.Contains(result.Err.Error(), "cannot specify agent name with --all") { + t.Errorf("expected conflict error, got: %v", result.Err) + } +} + +func TestStopAgent_Success(t *testing.T) { + srv := testhelper.NewServer(t) + agent := sampleAgent("a-stop", "stop-me", testhelper.TestProject) + agent.CurrentSessionID = "sess-active" + handleAgentLookup(t, srv, testhelper.TestProject, agent) + srv.Handle("/api/ambient/v1/sessions/sess-active", func(w http.ResponseWriter, r *http.Request) { + srv.RespondJSON(t, w, http.StatusOK, &types.Session{ + ObjectReference: types.ObjectReference{ID: "sess-active"}, + Phase: "Running", + }) + }) + srv.Handle("/api/ambient/v1/sessions/sess-active/stop", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + srv.RespondJSON(t, w, http.StatusOK, &types.Session{ + ObjectReference: types.ObjectReference{ID: "sess-active"}, + Phase: "Stopping", + }) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "stop", "stop-me") + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stdout, "agent/stop-me session/sess-active stopped") { + t.Errorf("expected stop confirmation, got: %s", result.Stdout) + } +} + +func TestStopAgent_NoActiveSession(t *testing.T) { + srv := testhelper.NewServer(t) + agent := sampleAgent("a-noss", "idle-agent", testhelper.TestProject) + handleAgentLookup(t, srv, testhelper.TestProject, agent) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "stop", "idle-agent") + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stdout, "no active session") { + t.Errorf("expected 'no active session' message, got: %s", result.Stdout) + } +} + +func TestStopAgent_MissingArg(t *testing.T) { + srv := testhelper.NewServer(t) + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "stop") + if result.Err == nil { + t.Fatal("expected error for missing agent argument") + } +} + +func TestStopAgent_AllFlag(t *testing.T) { + srv := testhelper.NewServer(t) + agents := []types.Agent{ + {ObjectReference: types.ObjectReference{ID: "a-sa1", CreatedAt: &testTime}, Name: "agent-1", ProjectID: testhelper.TestProject, CurrentSessionID: "sess-a1"}, + {ObjectReference: types.ObjectReference{ID: "a-sa2", CreatedAt: &testTime}, Name: "agent-2", ProjectID: testhelper.TestProject}, + } + srv.Handle("/api/ambient/v1/projects/"+testhelper.TestProject+"/agents", func(w http.ResponseWriter, r *http.Request) { + srv.RespondJSON(t, w, http.StatusOK, &types.AgentList{ + ListMeta: types.ListMeta{Total: 2}, + Items: agents, + }) + }) + srv.Handle("/api/ambient/v1/sessions/sess-a1", func(w http.ResponseWriter, r *http.Request) { + srv.RespondJSON(t, w, http.StatusOK, &types.Session{ + ObjectReference: types.ObjectReference{ID: "sess-a1"}, + Phase: "Running", + }) + }) + srv.Handle("/api/ambient/v1/sessions/sess-a1/stop", func(w http.ResponseWriter, r *http.Request) { + srv.RespondJSON(t, w, http.StatusOK, &types.Session{ + ObjectReference: types.ObjectReference{ID: "sess-a1"}, + Phase: "Stopping", + }) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "stop", "--all") + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stdout, "sess-a1 stopped") { + t.Errorf("expected stop confirmation for sess-a1, got: %s", result.Stdout) + } + if !strings.Contains(result.Stdout, "no active session") { + t.Errorf("expected 'no active session' for agent-2, got: %s", result.Stdout) + } +} + +func TestStartPreview_Success(t *testing.T) { + srv := testhelper.NewServer(t) + agent := sampleAgent("a-prev", "preview-agent", testhelper.TestProject) + handleAgentLookup(t, srv, testhelper.TestProject, agent) + srv.Handle("/api/ambient/v1/projects/"+testhelper.TestProject+"/agents/a-prev/start", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + srv.RespondJSON(t, w, http.StatusOK, &types.StartResponse{ + StartingPrompt: "You are the lead agent. Your task is...", + }) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "start-preview", "preview-agent") + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stdout, "You are the lead agent") { + t.Errorf("expected preview prompt, got: %s", result.Stdout) + } +} + +func TestSessions_Success(t *testing.T) { + srv := testhelper.NewServer(t) + agent := sampleAgent("a-ses", "session-agent", testhelper.TestProject) + handleAgentLookup(t, srv, testhelper.TestProject, agent) + srv.Handle("/api/ambient/v1/projects/"+testhelper.TestProject+"/agents/a-ses/sessions", func(w http.ResponseWriter, r *http.Request) { + srv.RespondJSON(t, w, http.StatusOK, &types.SessionList{ + ListMeta: types.ListMeta{Total: 1}, + Items: []types.Session{ + { + ObjectReference: types.ObjectReference{ID: "sess-hist", CreatedAt: &testTime}, + Name: "run-1", + Phase: "Completed", + }, + }, + }) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "sessions", "session-agent") + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stdout, "run-1") { + t.Errorf("expected 'run-1' in output, got: %s", result.Stdout) + } +} + +func TestSessions_JSON(t *testing.T) { + srv := testhelper.NewServer(t) + agent := sampleAgent("a-sj2", "json-ses", testhelper.TestProject) + handleAgentLookup(t, srv, testhelper.TestProject, agent) + srv.Handle("/api/ambient/v1/projects/"+testhelper.TestProject+"/agents/a-sj2/sessions", func(w http.ResponseWriter, r *http.Request) { + srv.RespondJSON(t, w, http.StatusOK, &types.SessionList{ + Items: []types.Session{ + { + ObjectReference: types.ObjectReference{ID: "sess-j"}, + Name: "json-run", + Phase: "Running", + }, + }, + }) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "sessions", "json-ses", "-o", "json") + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if !strings.Contains(result.Stdout, `"json-run"`) { + t.Errorf("expected JSON with 'json-run', got: %s", result.Stdout) + } +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go index bb7549afd..b461843d6 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/client.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/client.go @@ -1,6 +1,7 @@ package tui import ( + "bufio" "context" "crypto/tls" "encoding/json" @@ -178,6 +179,18 @@ type SessionMessagesMsg struct { Err error } +// SSEEventMsg carries a single parsed AG-UI event from the SSE stream. +type SSEEventMsg struct { + EventType string + Payload string + Err error +} + +// SSEStreamDoneMsg signals the SSE stream has ended (server closed or error). +type SSEStreamDoneMsg struct { + Err error +} + // --------------------------------------------------------------------------- // TUIClient wraps connection.ClientFactory and provides clean data-fetching // methods that return tea.Cmd functions for asynchronous execution inside the @@ -1037,3 +1050,90 @@ func (tc *TUIClient) FetchSessionMessages(projectID, sessionID string, afterSeq return SessionMessagesMsg{Messages: msgs} } } + +// OpenSSEStream opens an SSE connection to GET /sessions/{id}/events and +// starts a background goroutine that parses AG-UI events and sends them to the +// returned channel. The caller reads from the channel via waitForSSEEvent(). +// Cancel the context to tear down the stream. +func (tc *TUIClient) OpenSSEStream(ctx context.Context, projectID, sessionID string) (<-chan tea.Msg, error) { + client, err := tc.factory.ForProject(projectID) + if err != nil { + return nil, err + } + + stream, err := client.Sessions().StreamEvents(ctx, sessionID) + if err != nil { + return nil, err + } + + ch := make(chan tea.Msg, 64) + + go func() { + defer stream.Close() + defer close(ch) + + scanner := bufio.NewScanner(stream) + scanner.Buffer(make([]byte, 1<<20), 1<<20) + + var dataBuf strings.Builder + + for scanner.Scan() { + if ctx.Err() != nil { + return + } + + line := scanner.Text() + + switch { + case strings.HasPrefix(line, "data: "): + if dataBuf.Len() > 0 { + dataBuf.WriteByte('\n') + } + dataBuf.WriteString(line[6:]) + + case line == "": + if dataBuf.Len() == 0 { + continue + } + data := dataBuf.String() + dataBuf.Reset() + + var evt struct { + Type string `json:"type"` + } + if json.Unmarshal([]byte(data), &evt) != nil || evt.Type == "" { + continue + } + + select { + case ch <- SSEEventMsg{EventType: evt.Type, Payload: data}: + case <-ctx.Done(): + return + } + } + } + + if ctx.Err() != nil { + return + } + select { + case ch <- SSEStreamDoneMsg{Err: scanner.Err()}: + case <-ctx.Done(): + } + }() + + return ch, nil +} + +// waitForSSEEvent returns a tea.Cmd that blocks until the next event arrives +// on the SSE channel. This is the Bubbletea-idiomatic way to pump a background +// stream: each received message triggers the next wait in the Update loop. +func waitForSSEEvent(ch <-chan tea.Msg) tea.Cmd { + return func() tea.Msg { + msg, ok := <-ch + if !ok { + return SSEStreamDoneMsg{} + } + return msg + } +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go index 573880f68..de1cb3d93 100755 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/model_new.go @@ -1,6 +1,7 @@ package tui import ( + "context" "encoding/json" "fmt" "os" @@ -81,6 +82,9 @@ type messagePollTickMsg struct{ t time.Time } // infoExpiredMsg signals the ephemeral info line should be cleared. type infoExpiredMsg struct{} +// sseReconnectMsg fires after a delay to reconnect the SSE stream. +type sseReconnectMsg struct{} + // editCompleteMsg is sent when the user's $EDITOR exits after editing a // resource as JSON. The handler reads the temp file, diffs against the // original, and PATCHes any changed fields. @@ -183,6 +187,13 @@ type AppModel struct { // Message polling state. messagePollActive bool // true when message poll tick is running + // SSE stream state for live AG-UI event streaming. + sseEventChan <-chan tea.Msg // channel of SSEEventMsg from background goroutine + sseCancel context.CancelFunc // cancels the SSE stream context + sseActive bool // true while SSE stream is connected + sseSeqCounter int // synthetic sequence counter for SSE events + sseTextBuf strings.Builder // accumulates TEXT_MESSAGE_CONTENT deltas for conversation pane mirroring + // Errors lastError string authExpired bool // set on 401 — renders logo red + "Session Expired" badge @@ -367,6 +378,37 @@ func (m *AppModel) messagePollTickCmd() tea.Cmd { }) } +// startSSEStream opens an SSE connection for live AG-UI events and returns +// a tea.Cmd that begins pumping events into the Bubbletea runtime. +func (m *AppModel) startSSEStream(projectID, sessionID string) tea.Cmd { + m.stopSSEStream() + + ctx, cancel := context.WithCancel(context.Background()) + ch, err := m.client.OpenSSEStream(ctx, projectID, sessionID) + if err != nil { + cancel() + return m.setInfo("SSE stream failed: " + err.Error()) + } + + m.sseEventChan = ch + m.sseCancel = cancel + m.sseActive = true + m.sseSeqCounter = 0 + m.sseTextBuf.Reset() + + return waitForSSEEvent(ch) +} + +// stopSSEStream cancels the SSE stream context and resets state. +func (m *AppModel) stopSSEStream() { + if m.sseCancel != nil { + m.sseCancel() + m.sseCancel = nil + } + m.sseActive = false + m.sseEventChan = nil +} + // infoExpireCmd returns a tea.Cmd that clears the info line after infoTimeout. func (m *AppModel) infoExpireCmd() tea.Cmd { return tea.Tick(infoTimeout, func(_ time.Time) tea.Msg { @@ -425,10 +467,10 @@ func (m *AppModel) popView() tea.Cmd { if len(m.navStack) <= 1 { return nil } - // If we're leaving the messages view, stop polling. poppedKind := m.navStack[len(m.navStack)-1].Kind if poppedKind == "messages" { m.messagePollActive = false + m.stopSSEStream() } m.navStack = m.navStack[:len(m.navStack)-1] @@ -492,7 +534,6 @@ func (m *AppModel) fetchActiveView() tea.Cmd { } return nil case "messages": - // Message stream uses SSE, not polling. No fetch command needed yet. return nil default: return nil @@ -869,17 +910,100 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, tea.Batch(m.fetchActiveView(), m.setInfo("Inbox message deleted")) + case SSEEventMsg: + if m.activeView != "messages" || !m.sseActive { + return m, nil + } + m.sseSeqCounter++ + now := time.Now() + if msg.EventType == "MESSAGES_SNAPSHOT" { + extracted := views.ExtractAssistantFromSnapshot(msg.Payload, 10000+m.sseSeqCounter, now) + for _, e := range extracted { + m.messageStream.AddSSEMessage(e) + } + return m, waitForSSEEvent(m.sseEventChan) + } + if msg.EventType == "TEXT_MESSAGE_CONTENT" { + delta := extractSSETextDelta(msg.Payload) + if delta != "" { + m.sseTextBuf.WriteString(delta) + } + } + if msg.EventType == "TEXT_MESSAGE_END" { + if m.sseTextBuf.Len() > 0 { + text := strings.TrimSpace(m.sseTextBuf.String()) + m.sseTextBuf.Reset() + if text != "" { + m.messageStream.AddSSEMessage(views.MessageEntry{ + Seq: 10000 + m.sseSeqCounter, + EventType: "assistant", + Payload: text, + Timestamp: now, + }) + } + } + } + if msg.EventType == "TEXT_MESSAGE_START" { + m.sseTextBuf.Reset() + } + entry := views.MessageEntry{ + Seq: 10000 + m.sseSeqCounter, + EventType: msg.EventType, + Payload: msg.Payload, + Timestamp: now, + } + if views.IsActivityEvent(msg.EventType) { + m.messageStream.AddActivityEvent(entry) + } else if views.IsConversationEvent(msg.EventType) { + m.messageStream.AddSSEMessage(entry) + } + return m, waitForSSEEvent(m.sseEventChan) + + case SSEStreamDoneMsg: + m.sseActive = false + if m.activeView != "messages" || m.currentSession == "" { + return m, nil + } + projectID := m.currentProject + if projectID == "" { + if s := m.findSessionByShortID(m.currentSession); s != nil { + projectID = s.ProjectID + } + } + if projectID == "" { + return m, m.setInfo("SSE stream ended") + } + reconnectCmd := tea.Tick(3*time.Second, func(_ time.Time) tea.Msg { + return sseReconnectMsg{} + }) + if msg.Err != nil { + return m, tea.Batch(reconnectCmd, m.setInfo("SSE reconnecting…")) + } + return m, reconnectCmd + + case sseReconnectMsg: + if m.activeView != "messages" || m.currentSession == "" { + return m, nil + } + projectID := m.currentProject + if projectID == "" { + if s := m.findSessionByShortID(m.currentSession); s != nil { + projectID = s.ProjectID + } + } + if projectID != "" { + return m, m.startSSEStream(projectID, m.currentSession) + } + return m, nil + case SessionMessagesMsg: - // Polling: batch of messages from REST ListMessages. if msg.Err != nil { - // Non-fatal — polling will retry on next tick, but inform user. return m, m.setInfo("Message poll error: " + msg.Err.Error()) } if m.activeView != "messages" { return m, nil } for _, sm := range msg.Messages { - // Simple seq-based dedup. if sm.Seq <= m.messageStream.LastSeq() { continue } @@ -887,12 +1011,24 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if sm.CreatedAt != nil { ts = *sm.CreatedAt } - m.messageStream.AddMessage(views.MessageEntry{ + if sm.EventType == "MESSAGES_SNAPSHOT" { + extracted := views.ExtractAssistantFromSnapshot(sm.Payload, sm.Seq, ts) + for _, e := range extracted { + m.messageStream.AddMessage(e) + } + continue + } + entry := views.MessageEntry{ Seq: sm.Seq, EventType: sm.EventType, Payload: sm.Payload, Timestamp: ts, - }) + } + if views.IsActivityEvent(sm.EventType) { + m.messageStream.AddActivityEvent(entry) + } else { + m.messageStream.AddMessage(entry) + } } m.lastFetch = time.Now() return m, nil @@ -1692,10 +1828,10 @@ func (m *AppModel) handleEnter() (tea.Model, tea.Cmd) { } if projectID != "" { - // Fetch initial messages and start 1-second polling. cmds = append(cmds, m.client.FetchSessionMessages(projectID, fullSessionID, 0)) m.messagePollActive = true cmds = append(cmds, m.messagePollTickCmd()) + cmds = append(cmds, m.startSSEStream(projectID, fullSessionID)) } return m, tea.Batch(cmds...) @@ -1963,6 +2099,7 @@ func (m *AppModel) handleAgentsRune(key string) (tea.Model, tea.Cmd) { cmds = append(cmds, m.client.FetchSessionMessages(m.currentProject, sessionID, 0)) m.messagePollActive = true cmds = append(cmds, m.messagePollTickCmd()) + cmds = append(cmds, m.startSSEStream(m.currentProject, sessionID)) } return m, tea.Batch(cmds...) @@ -3208,6 +3345,17 @@ func (m *AppModel) handleEditComplete(msg editCompleteMsg) (tea.Model, tea.Cmd) } } +func extractSSETextDelta(payload string) string { + var obj map[string]any + if err := json.Unmarshal([]byte(payload), &obj); err != nil { + return "" + } + if d, ok := obj["delta"].(string); ok { + return d + } + return "" +} + // stripJSONComments removes lines starting with // from the input. func stripJSONComments(s string) string { var lines []string diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/activity.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/activity.go new file mode 100644 index 000000000..b08451a1c --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/activity.go @@ -0,0 +1,380 @@ +package views + +import ( + "fmt" + "strings" + "time" + + "github.com/charmbracelet/lipgloss" +) + +const activityMaxMessages = 2000 + +var ( + activityTitleStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("36")).Bold(true) +) + +type ActivityPane struct { + messages []MessageEntry + maxMessages int + + scrollOffset int + autoScroll bool + + cachedLines []string + cachedDirty bool + cachedMsgCount int + + focused bool + + textBuf strings.Builder + reasoningBuf strings.Builder + toolArgsBuf strings.Builder + accumType string + accumSeq int + + width, height int +} + +func NewActivityPane() ActivityPane { + return ActivityPane{ + messages: make([]MessageEntry, 0, 256), + maxMessages: activityMaxMessages, + autoScroll: true, + } +} + +func IsActivityEvent(eventType string) bool { + switch eventType { + case "REASONING_START", "REASONING_MESSAGE_START", + "REASONING_MESSAGE_CONTENT", "REASONING_MESSAGE_END", + "REASONING_END", + "reasoning": + return true + case "TOOL_CALL_START", "TOOL_CALL_ARGS", "TOOL_CALL_END", "TOOL_CALL_RESULT": + return true + case "tool_use", "tool_result": + return true + case "TEXT_MESSAGE_START", "TEXT_MESSAGE_CONTENT", "TEXT_MESSAGE_END": + return true + case "STEP_STARTED", "STEP_FINISHED": + return true + case "RUN_STARTED", "RUN_FINISHED", "RUN_ERROR": + return true + case "STATE_SNAPSHOT", "STATE_DELTA", + "ACTIVITY_SNAPSHOT", "ACTIVITY_DELTA": + return true + default: + return false + } +} + +func (ap *ActivityPane) AddMessage(entry MessageEntry) { + switch entry.EventType { + case "TEXT_MESSAGE_CONTENT": + delta := extractJSONField(entry.Payload, "delta") + if delta != "" { + ap.textBuf.WriteString(delta) + ap.accumType = "TEXT_MESSAGE_CONTENT" + ap.accumSeq = entry.Seq + } + return + + case "TEXT_MESSAGE_END": + ap.flushTextBuf(entry.Seq) + return + + case "TEXT_MESSAGE_START": + ap.flushTextBuf(entry.Seq) + return + + case "REASONING_MESSAGE_CONTENT": + delta := extractJSONField(entry.Payload, "delta") + if delta != "" { + ap.reasoningBuf.WriteString(delta) + ap.accumType = "REASONING_MESSAGE_CONTENT" + ap.accumSeq = entry.Seq + } + return + + case "REASONING_END": + ap.flushReasoningBuf(entry.Seq) + return + + case "REASONING_START", "REASONING_MESSAGE_START", "REASONING_MESSAGE_END": + return + + case "TOOL_CALL_ARGS": + delta := extractJSONField(entry.Payload, "delta") + if delta != "" { + ap.toolArgsBuf.WriteString(delta) + ap.accumType = "TOOL_CALL_ARGS" + ap.accumSeq = entry.Seq + } + return + + case "TOOL_CALL_END": + ap.flushToolArgsBuf(entry.Seq) + return + } + + ap.flushAll(entry.Seq) + ap.addRaw(entry) +} + +func (ap *ActivityPane) flushTextBuf(seq int) { + if ap.textBuf.Len() == 0 { + return + } + text := strings.TrimSpace(ap.textBuf.String()) + ap.textBuf.Reset() + if text == "" { + return + } + ap.addRaw(MessageEntry{ + Seq: seq, + EventType: "text", + Payload: text, + Timestamp: time.Now(), + }) +} + +func (ap *ActivityPane) flushReasoningBuf(seq int) { + if ap.reasoningBuf.Len() == 0 { + return + } + text := strings.TrimSpace(ap.reasoningBuf.String()) + ap.reasoningBuf.Reset() + if text == "" { + return + } + ap.addRaw(MessageEntry{ + Seq: seq, + EventType: "reasoning", + Payload: text, + Timestamp: time.Now(), + }) +} + +func (ap *ActivityPane) flushToolArgsBuf(seq int) { + if ap.toolArgsBuf.Len() == 0 { + return + } + text := strings.TrimSpace(ap.toolArgsBuf.String()) + ap.toolArgsBuf.Reset() + if text == "" { + return + } + ap.addRaw(MessageEntry{ + Seq: seq, + EventType: "tool_args", + Payload: text, + Timestamp: time.Now(), + }) +} + +func (ap *ActivityPane) flushAll(seq int) { + ap.flushTextBuf(seq) + ap.flushReasoningBuf(seq) + ap.flushToolArgsBuf(seq) +} + +func (ap *ActivityPane) addRaw(entry MessageEntry) { + ap.messages = append(ap.messages, entry) + if len(ap.messages) > ap.maxMessages { + excess := len(ap.messages) - ap.maxMessages + ap.messages = ap.messages[excess:] + } + ap.cachedDirty = true + if ap.autoScroll { + ap.scrollToBottom() + } +} + +func (ap *ActivityPane) SetSize(w, h int) { + if w != ap.width { + ap.cachedDirty = true + } + ap.width = w + ap.height = h +} + +func (ap *ActivityPane) SetFocused(f bool) { + ap.focused = f +} + +func (ap *ActivityPane) IsFocused() bool { + return ap.focused +} + +func (ap *ActivityPane) ScrollUp(n int) { + ap.autoScroll = false + ap.scrollOffset -= n + if ap.scrollOffset < 0 { + ap.scrollOffset = 0 + } +} + +func (ap *ActivityPane) ScrollDown(n int) { + ap.autoScroll = false + ap.scrollOffset += n +} + +func (ap *ActivityPane) ScrollToBottom() { + ap.scrollToBottom() + ap.autoScroll = true +} + +func (ap *ActivityPane) scrollToBottom() { + ap.scrollOffset = len(ap.messages) * 10 +} + +func (ap *ActivityPane) ContentHeight() int { + h := ap.height - 2 + if h < 1 { + h = 1 + } + return h +} + +func (ap *ActivityPane) View() string { + if ap.width == 0 { + return "" + } + + borderColor := lipgloss.Color("240") + if ap.focused { + borderColor = lipgloss.Color("36") + } + borderStyle := lipgloss.NewStyle().Foreground(borderColor) + + titleRendered := " " + + activityTitleStyle.Render("activity") + + msgDimStyle.Render("[") + + lipgloss.NewStyle().Foreground(lipgloss.Color("255")).Bold(true).Render(fmt.Sprintf("%d", len(ap.messages))) + + msgDimStyle.Render("]") + + " " + titleWidth := lipgloss.Width(titleRendered) + remaining := max(ap.width-titleWidth-2, 2) + leftDashes := remaining / 2 + rightDashes := remaining - leftDashes + titleBar := borderStyle.Render("┌"+strings.Repeat("─", leftDashes)) + + titleRendered + + borderStyle.Render(strings.Repeat("─", rightDashes)+"┐") + + contentH := ap.ContentHeight() + contentLines := ap.renderContent(contentH) + + rendered := make([]string, contentH) + for i := range contentH { + line := "" + if i < len(contentLines) { + line = contentLines[i] + } + rendered[i] = borderStyle.Render("│") + + padToWidth(" "+line, ap.width-2) + + borderStyle.Render("│") + } + + bottomBorder := borderStyle.Render("└" + strings.Repeat("─", max(ap.width-2, 0)) + "┘") + + var sb strings.Builder + sb.WriteString(titleBar) + sb.WriteByte('\n') + sb.WriteString(strings.Join(rendered, "\n")) + sb.WriteByte('\n') + sb.WriteString(bottomBorder) + + return sb.String() +} + +func (ap *ActivityPane) renderContent(height int) []string { + if len(ap.messages) == 0 { + return []string{msgDimStyle.Render("Waiting for agent activity…")} + } + + allLines := ap.buildDisplayLines() + + total := len(allLines) + if ap.scrollOffset > total-height { + ap.scrollOffset = total - height + } + if ap.scrollOffset < 0 { + ap.scrollOffset = 0 + } + + start := ap.scrollOffset + end := min(start+height, total) + if start >= total { + return nil + } + + return allLines[start:end] +} + +func (ap *ActivityPane) buildDisplayLines() []string { + totalCount := len(ap.messages) + if !ap.cachedDirty && ap.cachedMsgCount == totalCount { + return ap.cachedLines + } + + maxLineWidth := max(ap.width-4, 20) + lines := make([]string, 0, totalCount) + + for _, entry := range ap.messages { + entryLines := ap.renderActivityEntry(entry, maxLineWidth) + lines = append(lines, entryLines...) + } + + ap.cachedLines = lines + ap.cachedDirty = false + ap.cachedMsgCount = totalCount + return lines +} + +func (ap *ActivityPane) renderActivityEntry(entry MessageEntry, maxWidth int) []string { + color := eventColor(entry.EventType) + typeStyle := lipgloss.NewStyle().Foreground(color).Bold(true) + textStyle := lipgloss.NewStyle().Foreground(color) + + var displayText string + switch entry.EventType { + case "text", "reasoning", "tool_args": + displayText = entry.Payload + default: + sanitizedPayload := SanitizePayload(entry.Payload) + displayText = eventSummary(entry.EventType, sanitizedPayload) + } + if displayText == "" { + return nil + } + + const tagPadWidth = 14 + rawTag := "[" + entry.EventType + "]" + padded := rawTag + strings.Repeat(" ", max(tagPadWidth-len(rawTag), 1)) + tag := typeStyle.Render(padded) + tagWidth := tagPadWidth + + availWidth := max(maxWidth-tagWidth, 10) + + wrapped := wrapText(displayText, availWidth) + if len(wrapped) == 0 { + return []string{tag} + } + + indent := strings.Repeat(" ", tagWidth) + result := make([]string, 0, len(wrapped)) + for i, line := range wrapped { + if i == 0 { + result = append(result, tag+" "+textStyle.Render(line)) + } else { + result = append(result, indent+textStyle.Render(line)) + } + } + + return result +} + +func (ap *ActivityPane) MessageCount() int { + return len(ap.messages) +} diff --git a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go index 42ba27f29..c60e10c23 100644 --- a/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go +++ b/components/ambient-cli/cmd/acpctl/ambient/tui/views/messages.go @@ -61,6 +61,75 @@ var ( msgSepStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("236")) ) +func IsConversationEvent(eventType string) bool { + switch eventType { + case "user", "assistant", "system", "error": + return true + case "MESSAGES_SNAPSHOT": + return true + default: + return false + } +} + +func ExtractAssistantFromSnapshot(payload string, baseSeq int, ts time.Time) []MessageEntry { + var raw string + if err := json.Unmarshal([]byte(payload), &raw); err == nil { + payload = raw + } + + var msgs []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } + if err := json.Unmarshal([]byte(payload), &msgs); err != nil { + return nil + } + + var entries []MessageEntry + for _, msg := range msgs { + if msg.Role != "assistant" || len(msg.Content) == 0 { + continue + } + var contentStr string + if err := json.Unmarshal(msg.Content, &contentStr); err == nil { + if t := strings.TrimSpace(contentStr); t != "" { + entries = append(entries, MessageEntry{ + Seq: baseSeq, + EventType: "assistant", + Payload: t, + Timestamp: ts, + }) + } + continue + } + var blocks []struct { + Type string `json:"type"` + Text string `json:"text"` + } + if err := json.Unmarshal(msg.Content, &blocks); err != nil { + continue + } + var textParts []string + for _, b := range blocks { + if b.Type == "text" { + if t := strings.TrimSpace(b.Text); t != "" { + textParts = append(textParts, t) + } + } + } + if len(textParts) > 0 { + entries = append(entries, MessageEntry{ + Seq: baseSeq, + EventType: "assistant", + Payload: strings.Join(textParts, "\n\n"), + Timestamp: ts, + }) + } + } + return entries +} + // eventColor returns the lipgloss color for a semantic event type. // This duplicates the 6-entry mapping from the parent tui.EventColor to avoid // a circular import. @@ -91,6 +160,10 @@ func eventColor(eventType string) lipgloss.Color { return msgColorDim case "STEP_STARTED", "STEP_FINISHED": return msgColorYellow + case "text": + return msgColorBlue + case "tool_args": + return msgColorCyan default: return msgColorDim } @@ -390,7 +463,7 @@ const defaultMaxMessages = 2000 // It renders messages in conversation or raw mode, supports scrolling, // autoscroll, compose input, and search. // -// Messages arrive via 1-second REST polling of /messages. +// Messages arrive via live SSE streaming of /events with REST polling fallback. type MessageStream struct { sessionID string agentName string @@ -435,6 +508,12 @@ type MessageStream struct { searchInput textinput.Model searchPattern *regexp.Regexp + // Split mode: when true, only conversation events are shown (activity + // events go to the companion ActivityPane rendered below). + splitMode bool + activityPane ActivityPane + focusTop bool // true = conversation pane focused, false = activity pane + // Dimensions width, height int } @@ -460,6 +539,9 @@ func NewMessageStream(sessionID, agentName, phase string) MessageStream { autoScroll: true, composeInput: ci, searchInput: si, + splitMode: true, + activityPane: NewActivityPane(), + focusTop: true, } } @@ -496,6 +578,37 @@ func (ms *MessageStream) AddMessage(entry MessageEntry) { } } +// AddSSEMessage appends a conversation message from the SSE stream without +// updating lastSeq. SSE events use synthetic seq values (10000+) that would +// corrupt the REST poll dedup counter if tracked. +func (ms *MessageStream) AddSSEMessage(entry MessageEntry) { + ms.messages = append(ms.messages, entry) + if len(ms.messages) > ms.maxMessages { + excess := len(ms.messages) - ms.maxMessages + if ms.glamourCache != nil { + for _, evicted := range ms.messages[:excess] { + delete(ms.glamourCache, evicted.Seq) + } + } + ms.messages = ms.messages[excess:] + } + ms.cachedDirty = true + if ms.autoScroll { + ms.scrollToBottom() + } +} + +// AddActivityEvent routes an event to the activity pane without updating +// lastSeq (SSE events use synthetic seq values). +func (ms *MessageStream) AddActivityEvent(entry MessageEntry) { + ms.activityPane.AddMessage(entry) +} + +// ActivityPane returns a pointer to the embedded activity pane. +func (ms *MessageStream) ActivityPane() *ActivityPane { + return &ms.activityPane +} + // LastSeq returns the highest seq in the buffer. Used by the polling path // for dedup. func (ms *MessageStream) LastSeq() int { @@ -593,10 +706,18 @@ func (ms *MessageStream) Update(msg tea.Msg) (MessageStream, tea.Cmd) { case tea.MouseMsg: switch msg.Button { case tea.MouseButtonWheelUp: - ms.scrollUp(3) + if ms.splitMode && !ms.focusTop { + ms.activityPane.ScrollUp(3) + } else { + ms.scrollUp(3) + } return *ms, nil case tea.MouseButtonWheelDown: - ms.scrollDown(3) + if ms.splitMode && !ms.focusTop { + ms.activityPane.ScrollDown(3) + } else { + ms.scrollDown(3) + } return *ms, nil } } @@ -607,30 +728,52 @@ func (ms *MessageStream) Update(msg tea.Msg) (MessageStream, tea.Cmd) { func (ms *MessageStream) updateNormal(msg tea.KeyMsg) (MessageStream, tea.Cmd) { switch msg.Type { case tea.KeyEsc: - // If search filter is active, clear it first instead of backing out. if ms.searchPattern != nil { ms.searchPattern = nil return *ms, nil } return *ms, func() tea.Msg { return MsgStreamBackMsg{} } + case tea.KeyTab: + if ms.splitMode { + ms.focusTop = !ms.focusTop + ms.activityPane.SetFocused(!ms.focusTop) + } + return *ms, nil + case tea.KeyEnter: ms.enterComposeMode() return *ms, nil case tea.KeyUp: + if ms.splitMode && !ms.focusTop { + ms.activityPane.ScrollUp(1) + return *ms, nil + } ms.scrollUp(1) return *ms, nil case tea.KeyDown: + if ms.splitMode && !ms.focusTop { + ms.activityPane.ScrollDown(1) + return *ms, nil + } ms.scrollDown(1) return *ms, nil case tea.KeyPgUp: + if ms.splitMode && !ms.focusTop { + ms.activityPane.ScrollUp(ms.activityPane.ContentHeight()) + return *ms, nil + } ms.scrollUp(ms.contentHeight()) return *ms, nil case tea.KeyPgDown: + if ms.splitMode && !ms.focusTop { + ms.activityPane.ScrollDown(ms.activityPane.ContentHeight()) + return *ms, nil + } ms.scrollDown(ms.contentHeight()) return *ms, nil @@ -661,17 +804,33 @@ func (ms *MessageStream) updateNormal(msg tea.KeyMsg) (MessageStream, tea.Cmd) { ms.enterComposeMode() return *ms, nil case "G": + if ms.splitMode && !ms.focusTop { + ms.activityPane.ScrollToBottom() + return *ms, nil + } ms.scrollToBottom() ms.autoScroll = true return *ms, nil case "g": + if ms.splitMode && !ms.focusTop { + ms.activityPane.ScrollUp(len(ms.activityPane.messages) * 10) + return *ms, nil + } ms.scrollOffset = 0 ms.autoScroll = false return *ms, nil case "j": + if ms.splitMode && !ms.focusTop { + ms.activityPane.ScrollDown(1) + return *ms, nil + } ms.scrollDown(1) return *ms, nil case "k": + if ms.splitMode && !ms.focusTop { + ms.activityPane.ScrollUp(1) + return *ms, nil + } ms.scrollUp(1) return *ms, nil case "c": @@ -889,13 +1048,59 @@ func (ms *MessageStream) View() string { } // -- Content area -- - // 3 = header bar + header line + header separator - topLines := 3 - contentH := max(ms.height-topLines-len(bottomLines), 1) + // 3 = header bar + indicator line + header separator + topChrome := 3 + totalAvailable := max(ms.height-topChrome-len(bottomLines), 1) + + if ms.splitMode { + focusBorderColor := lipgloss.Color("240") + if ms.focusTop { + focusBorderColor = lipgloss.Color("69") + } + convBorderStyle := lipgloss.NewStyle().Foreground(focusBorderColor) + _ = convBorderStyle + + activityH := totalAvailable / 2 + convH := totalAvailable - activityH + + convLines := ms.renderContent(convH) + rendered := make([]string, convH) + convBorder := borderStyle + if ms.focusTop { + convBorder = lipgloss.NewStyle().Foreground(lipgloss.Color("69")) + } + for i := range convH { + line := "" + if i < len(convLines) { + line = convLines[i] + } + rendered[i] = convBorder.Render("│") + + padToWidth(" "+line, ms.width-2) + + convBorder.Render("│") + } + + ms.activityPane.SetSize(ms.width, activityH) + activityView := ms.activityPane.View() + + var sb strings.Builder + sb.WriteString(titleBar) + sb.WriteByte('\n') + sb.WriteString(indicatorLine) + sb.WriteByte('\n') + sb.WriteString(headerSep) + sb.WriteByte('\n') + sb.WriteString(strings.Join(rendered, "\n")) + sb.WriteByte('\n') + sb.WriteString(activityView) + sb.WriteByte('\n') + sb.WriteString(strings.Join(bottomLines, "\n")) + return sb.String() + } + + contentH := totalAvailable contentLines := ms.renderContent(contentH) - // Pad/truncate content to fill the viewport. rendered := make([]string, contentH) for i := range contentH { line := "" @@ -907,7 +1112,6 @@ func (ms *MessageStream) View() string { borderStyle.Render("│") } - // Assemble. var sb strings.Builder sb.WriteString(titleBar) sb.WriteByte('\n') @@ -980,6 +1184,9 @@ func (ms *MessageStream) buildDisplayLines() []string { prevWasUserOrAssistant := false for _, entry := range ms.messages { + if ms.splitMode && !IsConversationEvent(entry.EventType) { + continue + } entryLines := ms.renderEntry(entry, maxLineWidth, now) if len(entryLines) == 0 { continue @@ -1212,14 +1419,16 @@ func (ms *MessageStream) scrollToBottom() { // contentHeight returns the usable content height given the current dimensions. // This must match the calculation in View() to avoid scroll/display mismatches. func (ms *MessageStream) contentHeight() int { - // Top: title bar + indicator line + header separator = 3 lines. topLines := 3 - // Bottom: bottom border = 1 line. bottomLines := 1 if ms.composeMode { - bottomLines += 2 // compose separator + compose line + bottomLines += 2 } h := ms.height - topLines - bottomLines + if ms.splitMode { + activityH := h / 2 + h = h - activityH + } if h < 1 { h = 1 } diff --git a/components/ambient-cli/cmd/acpctl/apply/cmd.go b/components/ambient-cli/cmd/acpctl/apply/cmd.go index ff9696c29..94b514838 100644 --- a/components/ambient-cli/cmd/acpctl/apply/cmd.go +++ b/components/ambient-cli/cmd/acpctl/apply/cmd.go @@ -6,6 +6,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" "os" @@ -22,13 +23,13 @@ import ( var Cmd = &cobra.Command{ Use: "apply", - Short: "Apply declarative Project, Agent, and Credential manifests", - Long: `Apply Projects, Agents, and Credentials from YAML files or a Kustomize directory. + Short: "Apply declarative Project, Agent, Credential, and RoleBinding manifests", + Long: `Apply Projects, Agents, Credentials, and RoleBindings from YAML files or a Kustomize directory. Mirrors kubectl apply semantics: resources are created if they do not exist, or patched if they do. Output reports created / configured / unchanged per resource. -Supported kinds: Project, Agent, Credential +Supported kinds: Project, Agent, Credential, RoleBinding File format (one or more documents separated by ---): @@ -74,6 +75,14 @@ Credential example: url: https://gitlab.myco.com labels: team: platform + +RoleBinding example: + + kind: RoleBinding + role: credential:token-reader + scope: credential + scope_id: my-gitlab-pat + user_id: lead `, RunE: run, } @@ -107,6 +116,10 @@ type resource struct { Token string `yaml:"token"` URL string `yaml:"url"` Email string `yaml:"email"` + Role string `yaml:"role"` + Scope string `yaml:"scope"` + ScopeID string `yaml:"scope_id"` + UserID string `yaml:"user_id"` } type inboxSeed struct { @@ -177,18 +190,24 @@ func run(cmd *cobra.Command, _ []string) error { result, err = applyAgent(ctx, client, doc, projectName, factory) case "credential": result, err = applyCredential(ctx, client, doc) + case "rolebinding": + result, err = applyRoleBinding(ctx, client, doc) default: fmt.Fprintf(cmd.ErrOrStderr(), "warning: unknown kind %q — skipping\n", doc.Kind) continue } if err != nil { - return fmt.Errorf("apply %s/%s: %w", strings.ToLower(doc.Kind), doc.Name, err) + return fmt.Errorf("apply %s/%s: %w", strings.ToLower(doc.Kind), docDisplayName(doc), err) } results = append(results, result) if applyArgs.outputFormat != "json" { + displayName := result.Name + if displayName == "" { + displayName = result.Kind + } fmt.Fprintf(cmd.OutOrStdout(), "%s/%s %s\n", - strings.ToLower(result.Kind), result.Name, result.Status) + strings.ToLower(result.Kind), displayName, result.Status) } } @@ -318,6 +337,221 @@ func buildCredentialPatch(existing *sdktypes.Credential, doc resource) (map[stri return patch.Build(), changed } +// ── RoleBinding ────────────────────────────────────────────────────────────── + +func applyRoleBinding(ctx context.Context, client *sdkclient.Client, doc resource) (applyResult, error) { + displayName := roleBindingDisplayName(doc) + + if doc.Role == "" { + return applyResult{}, fmt.Errorf("role is required") + } + if doc.Scope == "" { + return applyResult{}, fmt.Errorf("scope is required") + } + if doc.ScopeID == "" { + return applyResult{}, fmt.Errorf("scope_id is required") + } + if doc.UserID == "" { + return applyResult{}, fmt.Errorf("user_id is required") + } + + roleID, err := resolveRoleID(ctx, client, doc.Role) + if err != nil { + return applyResult{}, err + } + + scopeFK, err := resolveScopeFK(ctx, client, doc.Scope, doc.ScopeID) + if err != nil { + return applyResult{}, err + } + + opts := sdktypes.NewListOptions().Size(100).Build() + existing, err := client.RoleBindings().List(ctx, opts) + if err != nil { + return applyResult{}, fmt.Errorf("list role-bindings: %w", err) + } + + for _, rb := range existing.Items { + if rb.RoleID == roleID && + rb.Scope == doc.Scope && + ptrEquals(rb.UserID, doc.UserID) && + scopeFKMatches(rb, doc.Scope, scopeFK) { + return applyResult{Kind: "RoleBinding", Name: displayName, Status: "unchanged"}, nil + } + } + + builder := sdktypes.NewRoleBindingBuilder(). + RoleID(roleID). + Scope(doc.Scope). + UserID(doc.UserID) + + switch doc.Scope { + case "credential": + builder = builder.CredentialID(scopeFK) + case "project": + builder = builder.ProjectID(scopeFK) + case "agent": + builder = builder.AgentID(scopeFK) + case "session": + builder = builder.SessionID(scopeFK) + } + + rb, buildErr := builder.Build() + if buildErr != nil { + return applyResult{}, buildErr + } + if _, createErr := client.RoleBindings().Create(ctx, rb); createErr != nil { + return applyResult{}, fmt.Errorf("create role-binding: %w", createErr) + } + return applyResult{Kind: "RoleBinding", Name: displayName, Status: "created"}, nil +} + +func resolveRoleID(ctx context.Context, client *sdkclient.Client, roleName string) (string, error) { + opts := sdktypes.NewListOptions().Size(100). + Search(fmt.Sprintf("name = '%s'", roleName)).Build() + list, err := client.Roles().List(ctx, opts) + if err != nil { + return "", fmt.Errorf("search roles for %q: %w", roleName, err) + } + for _, r := range list.Items { + if r.Name == roleName { + return r.ID, nil + } + } + return "", fmt.Errorf("role %q not found", roleName) +} + +func resolveScopeFK(ctx context.Context, client *sdkclient.Client, scope, scopeID string) (string, error) { + switch scope { + case "credential": + return resolveCredentialID(ctx, client, scopeID) + case "project": + proj, err := client.Projects().Get(ctx, scopeID) + if err != nil { + return "", fmt.Errorf("resolve project %q: %w", scopeID, err) + } + return proj.ID, nil + case "agent": + return resolveAgentID(ctx, client, scopeID) + case "session": + return resolveSessionID(ctx, client, scopeID) + default: + return "", fmt.Errorf("unsupported scope %q", scope) + } +} + +func resolveCredentialID(ctx context.Context, client *sdkclient.Client, nameOrID string) (string, error) { + cred, err := client.Credentials().Get(ctx, nameOrID) + if err == nil { + return cred.ID, nil + } + var apiErr *sdktypes.APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != 404 { + return "", fmt.Errorf("resolve credential %q: %w", nameOrID, err) + } + opts := sdktypes.NewListOptions().Size(100). + Search(fmt.Sprintf("name = '%s'", nameOrID)).Build() + list, err := client.Credentials().List(ctx, opts) + if err != nil { + return "", fmt.Errorf("search credentials for %q: %w", nameOrID, err) + } + var matches []sdktypes.Credential + for _, c := range list.Items { + if c.Name == nameOrID { + matches = append(matches, c) + } + } + if len(matches) == 0 { + return "", fmt.Errorf("credential %q not found", nameOrID) + } + if len(matches) == 1 { + return matches[0].ID, nil + } + var ids []string + for _, m := range matches { + ids = append(ids, m.ID) + } + return "", fmt.Errorf("multiple credentials named %q found (%s); use the credential ID instead", nameOrID, strings.Join(ids, ", ")) +} + +func resolveAgentID(ctx context.Context, client *sdkclient.Client, nameOrID string) (string, error) { + agent, err := client.Agents().Get(ctx, nameOrID) + if err == nil { + return agent.ID, nil + } + var apiErr *sdktypes.APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != 404 { + return "", fmt.Errorf("resolve agent %q: %w", nameOrID, err) + } + opts := sdktypes.NewListOptions().Size(100). + Search(fmt.Sprintf("name = '%s'", nameOrID)).Build() + list, err := client.Agents().List(ctx, opts) + if err != nil { + return "", fmt.Errorf("search agents for %q: %w", nameOrID, err) + } + for _, a := range list.Items { + if a.Name == nameOrID { + return a.ID, nil + } + } + return "", fmt.Errorf("agent %q not found", nameOrID) +} + +func resolveSessionID(ctx context.Context, client *sdkclient.Client, nameOrID string) (string, error) { + sess, err := client.Sessions().Get(ctx, nameOrID) + if err == nil { + return sess.ID, nil + } + var apiErr *sdktypes.APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != 404 { + return "", fmt.Errorf("resolve session %q: %w", nameOrID, err) + } + opts := sdktypes.NewListOptions().Size(100). + Search(fmt.Sprintf("name = '%s'", nameOrID)).Build() + list, err := client.Sessions().List(ctx, opts) + if err != nil { + return "", fmt.Errorf("search sessions for %q: %w", nameOrID, err) + } + for _, s := range list.Items { + if s.Name == nameOrID { + return s.ID, nil + } + } + return "", fmt.Errorf("session %q not found", nameOrID) +} + +func scopeFKMatches(rb sdktypes.RoleBinding, scope, fk string) bool { + switch scope { + case "credential": + return ptrEquals(rb.CredentialID, fk) + case "project": + return ptrEquals(rb.ProjectID, fk) + case "agent": + return ptrEquals(rb.AgentID, fk) + case "session": + return ptrEquals(rb.SessionID, fk) + } + return false +} + +func ptrEquals(p *string, v string) bool { + return p != nil && *p == v +} + +func roleBindingDisplayName(doc resource) string { + return doc.UserID + "\u2192" + doc.ScopeID +} + +func docDisplayName(d resource) string { + if d.Name != "" { + return d.Name + } + if strings.EqualFold(d.Kind, "RoleBinding") { + return roleBindingDisplayName(d) + } + return d.Kind +} + func marshalStringMap(m map[string]string) string { if len(m) == 0 { return "" @@ -682,7 +916,7 @@ func printDryRun(cmd *cobra.Command, docs []resource) error { if applyArgs.outputFormat == "json" { results := make([]applyResult, 0, len(docs)) for _, d := range docs { - results = append(results, applyResult{Kind: d.Kind, Name: d.Name, Status: "dry-run"}) + results = append(results, applyResult{Kind: d.Kind, Name: docDisplayName(d), Status: "dry-run"}) } enc := json.NewEncoder(cmd.OutOrStdout()) enc.SetIndent("", " ") @@ -691,7 +925,7 @@ func printDryRun(cmd *cobra.Command, docs []resource) error { w := cmd.OutOrStdout() fmt.Fprintln(w, "dry-run: would apply:") for _, d := range docs { - fmt.Fprintf(w, " %s/%s\n", strings.ToLower(d.Kind), d.Name) + fmt.Fprintf(w, " %s/%s\n", strings.ToLower(d.Kind), docDisplayName(d)) } return nil } diff --git a/components/ambient-cli/cmd/acpctl/credential/cmd_test.go b/components/ambient-cli/cmd/acpctl/credential/cmd_test.go new file mode 100644 index 000000000..475246b3a --- /dev/null +++ b/components/ambient-cli/cmd/acpctl/credential/cmd_test.go @@ -0,0 +1,456 @@ +package credential + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/ambient-code/platform/components/ambient-cli/internal/testhelper" + "github.com/ambient-code/platform/components/ambient-sdk/go-sdk/types" +) + +var testTime = time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) + +func sampleTokenResponse(id, provider, tok string) *types.CredentialTokenResponse { + resp := &types.CredentialTokenResponse{CredentialID: id, Provider: provider} + resp.Token = tok + return resp +} + +func sampleCredential(id, name, provider string) types.Credential { + return types.Credential{ + ObjectReference: types.ObjectReference{ID: id, CreatedAt: &testTime, UpdatedAt: &testTime}, + Name: name, + Provider: provider, + Description: "test credential", + } +} + +func TestCreateCredential_Success(t *testing.T) { + srv := testhelper.NewServer(t) + srv.Handle("/api/ambient/v1/credentials", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + srv.RespondJSON(t, w, http.StatusCreated, sampleCredential("cred-1", "github-main", "github")) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "create", "--name", "github-main", "--provider", "github", "--token", "ghp_xxx") + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stdout, "credential/github-main created") { + t.Errorf("expected 'credential/github-main created', got: %s", result.Stdout) + } +} + +func TestCreateCredential_MissingName(t *testing.T) { + srv := testhelper.NewServer(t) + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "create", "--provider", "github") + if result.Err == nil { + t.Fatal("expected error for missing --name") + } + if !strings.Contains(result.Err.Error(), "--name is required") { + t.Errorf("expected '--name is required', got: %v", result.Err) + } +} + +func TestCreateCredential_MissingProvider(t *testing.T) { + srv := testhelper.NewServer(t) + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "create", "--name", "my-cred") + if result.Err == nil { + t.Fatal("expected error for missing --provider") + } + if !strings.Contains(result.Err.Error(), "--provider is required") { + t.Errorf("expected '--provider is required', got: %v", result.Err) + } +} + +func TestCreateCredential_JSON(t *testing.T) { + srv := testhelper.NewServer(t) + srv.Handle("/api/ambient/v1/credentials", func(w http.ResponseWriter, r *http.Request) { + srv.RespondJSON(t, w, http.StatusCreated, sampleCredential("cred-json", "json-cred", "gitlab")) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "create", "--name", "json-cred", "--provider", "gitlab", "-o", "json") + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if !strings.Contains(result.Stdout, `"json-cred"`) { + t.Errorf("expected JSON with 'json-cred', got: %s", result.Stdout) + } +} + +func TestCreateCredential_AllFlags(t *testing.T) { + srv := testhelper.NewServer(t) + srv.Handle("/api/ambient/v1/credentials", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + var cred types.Credential + if err := json.Unmarshal(body, &cred); err != nil { + t.Fatalf("unmarshal request body: %v", err) + } + if cred.Token != "tok123" { + t.Errorf("expected token 'tok123', got %q", cred.Token) + } + if cred.Description != "my desc" { + t.Errorf("expected description 'my desc', got %q", cred.Description) + } + if cred.URL != "https://jira.example.com" { + t.Errorf("expected url 'https://jira.example.com', got %q", cred.URL) + } + if cred.Email != "test@example.com" { + t.Errorf("expected email 'test@example.com', got %q", cred.Email) + } + srv.RespondJSON(t, w, http.StatusCreated, sampleCredential("cred-all", "full-cred", "jira")) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "create", + "--name", "full-cred", + "--provider", "jira", + "--token", "tok123", + "--description", "my desc", + "--url", "https://jira.example.com", + "--email", "test@example.com", + "--labels", `{"env":"test"}`, + "--annotations", `{"note":"demo"}`, + ) + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } +} + +func TestListCredentials_Success(t *testing.T) { + srv := testhelper.NewServer(t) + srv.Handle("/api/ambient/v1/credentials", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + srv.RespondJSON(t, w, http.StatusOK, &types.CredentialList{ + Items: []types.Credential{ + sampleCredential("c1", "github-pat", "github"), + sampleCredential("c2", "jira-token", "jira"), + }, + }) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "list") + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stdout, "github-pat") { + t.Errorf("expected 'github-pat' in output, got: %s", result.Stdout) + } + if !strings.Contains(result.Stdout, "jira-token") { + t.Errorf("expected 'jira-token' in output, got: %s", result.Stdout) + } +} + +func TestListCredentials_JSON(t *testing.T) { + srv := testhelper.NewServer(t) + srv.Handle("/api/ambient/v1/credentials", func(w http.ResponseWriter, r *http.Request) { + srv.RespondJSON(t, w, http.StatusOK, &types.CredentialList{ + Items: []types.Credential{sampleCredential("c1", "gh-pat", "github")}, + }) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "list", "-o", "json") + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if !strings.Contains(result.Stdout, `"gh-pat"`) { + t.Errorf("expected JSON with 'gh-pat', got: %s", result.Stdout) + } +} + +func TestListCredentials_Empty(t *testing.T) { + srv := testhelper.NewServer(t) + srv.Handle("/api/ambient/v1/credentials", func(w http.ResponseWriter, r *http.Request) { + srv.RespondJSON(t, w, http.StatusOK, &types.CredentialList{Items: []types.Credential{}}) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "list") + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } +} + +func TestListCredentials_ProviderFilter(t *testing.T) { + srv := testhelper.NewServer(t) + srv.Handle("/api/ambient/v1/credentials", func(w http.ResponseWriter, r *http.Request) { + search := r.URL.Query().Get("search") + if !strings.Contains(search, "provider='github'") { + t.Errorf("expected search to contain provider='github', got: %s", search) + } + srv.RespondJSON(t, w, http.StatusOK, &types.CredentialList{ + Items: []types.Credential{sampleCredential("c1", "gh-only", "github")}, + }) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "list", "--provider", "github") + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } +} + +func TestGetCredential_Success(t *testing.T) { + srv := testhelper.NewServer(t) + srv.Handle("/api/ambient/v1/credentials/cred-42", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + srv.RespondJSON(t, w, http.StatusOK, sampleCredential("cred-42", "my-github", "github")) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "get", "cred-42") + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stdout, "my-github") { + t.Errorf("expected 'my-github' in output, got: %s", result.Stdout) + } +} + +func TestGetCredential_JSON(t *testing.T) { + srv := testhelper.NewServer(t) + srv.Handle("/api/ambient/v1/credentials/cred-j", func(w http.ResponseWriter, r *http.Request) { + srv.RespondJSON(t, w, http.StatusOK, sampleCredential("cred-j", "json-get", "gitlab")) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "get", "cred-j", "-o", "json") + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if !strings.Contains(result.Stdout, `"json-get"`) { + t.Errorf("expected JSON with 'json-get', got: %s", result.Stdout) + } +} + +func TestGetCredential_MissingID(t *testing.T) { + srv := testhelper.NewServer(t) + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "get") + if result.Err == nil { + t.Fatal("expected error for missing credential ID argument") + } +} + +func TestUpdateCredential_Success(t *testing.T) { + srv := testhelper.NewServer(t) + srv.Handle("/api/ambient/v1/credentials/cred-u1", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + t.Errorf("expected PATCH, got %s", r.Method) + } + body, _ := io.ReadAll(r.Body) + var patch map[string]interface{} + if err := json.Unmarshal(body, &patch); err != nil { + t.Fatalf("unmarshal patch body: %v", err) + } + if patch["description"] != "updated desc" { + t.Errorf("expected description 'updated desc', got %v", patch["description"]) + } + srv.RespondJSON(t, w, http.StatusOK, sampleCredential("cred-u1", "updated-cred", "github")) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "update", "cred-u1", "--description", "updated desc") + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stdout, "credential/updated-cred updated") { + t.Errorf("expected 'credential/updated-cred updated', got: %s", result.Stdout) + } +} + +func TestUpdateCredential_MissingID(t *testing.T) { + srv := testhelper.NewServer(t) + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "update") + if result.Err == nil { + t.Fatal("expected error for missing credential ID argument") + } +} + +func TestDeleteCredential_Success(t *testing.T) { + srv := testhelper.NewServer(t) + srv.Handle("/api/ambient/v1/credentials/cred-d1", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + t.Errorf("expected DELETE, got %s", r.Method) + } + w.WriteHeader(http.StatusNoContent) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "delete", "cred-d1", "--confirm") + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stdout, "credential/cred-d1 deleted") { + t.Errorf("expected 'credential/cred-d1 deleted', got: %s", result.Stdout) + } +} + +func TestDeleteCredential_MissingConfirm(t *testing.T) { + srv := testhelper.NewServer(t) + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "delete", "cred-d1") + if result.Err == nil { + t.Fatal("expected error for missing --confirm") + } + if !strings.Contains(result.Err.Error(), "--confirm") { + t.Errorf("expected '--confirm' in error, got: %v", result.Err) + } +} + +func TestDeleteCredential_MissingID(t *testing.T) { + srv := testhelper.NewServer(t) + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "delete", "--confirm") + if result.Err == nil { + t.Fatal("expected error for missing credential ID argument") + } +} + +func TestTokenCredential_Success(t *testing.T) { + srv := testhelper.NewServer(t) + srv.Handle("/api/ambient/v1/credentials/cred-t1/token", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Errorf("expected GET, got %s", r.Method) + } + srv.RespondJSON(t, w, http.StatusOK, sampleTokenResponse("cred-t1", "github", "test-value-gh")) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "token", "cred-t1") + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stdout, "test-value-gh") { + t.Errorf("expected raw token in output, got: %s", result.Stdout) + } +} + +func TestTokenCredential_JSON(t *testing.T) { + srv := testhelper.NewServer(t) + srv.Handle("/api/ambient/v1/credentials/cred-tj/token", func(w http.ResponseWriter, r *http.Request) { + srv.RespondJSON(t, w, http.StatusOK, sampleTokenResponse("cred-tj", "gitlab", "test-value-gl")) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "token", "cred-tj", "-o", "json") + if result.Err != nil { + t.Fatalf("unexpected error: %v", result.Err) + } + if !strings.Contains(result.Stdout, `"cred-tj"`) { + t.Errorf("expected JSON with 'cred-tj', got: %s", result.Stdout) + } + if !strings.Contains(result.Stdout, `"test-value-gl"`) { + t.Errorf("expected JSON with token, got: %s", result.Stdout) + } +} + +func TestTokenCredential_MissingID(t *testing.T) { + srv := testhelper.NewServer(t) + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "token") + if result.Err == nil { + t.Fatal("expected error for missing credential ID argument") + } +} + +func TestBindCredential_Success(t *testing.T) { + srv := testhelper.NewServer(t) + srv.Handle("/api/ambient/v1/credentials", func(w http.ResponseWriter, r *http.Request) { + srv.RespondJSON(t, w, http.StatusOK, &types.CredentialList{ + ListMeta: types.ListMeta{Total: 1}, + Items: []types.Credential{sampleCredential("cred-bind-1", "github-pat", "github")}, + }) + }) + srv.Handle("/api/ambient/v1/role_bindings", func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + t.Errorf("expected POST, got %s", r.Method) + } + body, _ := io.ReadAll(r.Body) + var rb map[string]interface{} + if err := json.Unmarshal(body, &rb); err != nil { + t.Fatalf("unmarshal role binding body: %v", err) + } + if rb["scope"] != "credential" { + t.Errorf("expected scope 'credential', got %v", rb["scope"]) + } + if rb["role_id"] != "credential:viewer" { + t.Errorf("expected role_id 'credential:viewer', got %v", rb["role_id"]) + } + credID := "cred-bind-1" + projectID := "my-project" + srv.RespondJSON(t, w, http.StatusCreated, &types.RoleBinding{ + ObjectReference: types.ObjectReference{ID: "rb-new"}, + RoleID: "credential:viewer", + Scope: "credential", + CredentialID: &credID, + ProjectID: &projectID, + }) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "bind", "github-pat", "--project", "my-project") + if result.Err != nil { + t.Fatalf("unexpected error: %v\nstdout: %s\nstderr: %s", result.Err, result.Stdout, result.Stderr) + } + if !strings.Contains(result.Stdout, "credential/github-pat bound to project/my-project") { + t.Errorf("expected bind confirmation, got: %s", result.Stdout) + } +} + +func TestBindCredential_MissingProject(t *testing.T) { + srv := testhelper.NewServer(t) + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "bind", "github-pat") + if result.Err == nil { + t.Fatal("expected error for missing --project") + } + if !strings.Contains(result.Err.Error(), "--project is required") { + t.Errorf("expected '--project is required', got: %v", result.Err) + } +} + +func TestBindCredential_MissingName(t *testing.T) { + srv := testhelper.NewServer(t) + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "bind") + if result.Err == nil { + t.Fatal("expected error for missing credential name argument") + } +} + +func TestBindCredential_NotFound(t *testing.T) { + srv := testhelper.NewServer(t) + srv.Handle("/api/ambient/v1/credentials", func(w http.ResponseWriter, r *http.Request) { + srv.RespondJSON(t, w, http.StatusOK, &types.CredentialList{ + ListMeta: types.ListMeta{Total: 0}, + Items: []types.Credential{}, + }) + }) + + testhelper.Configure(t, srv.URL) + result := testhelper.Run(t, Cmd, "bind", "nonexistent-cred", "--project", "my-project") + if result.Err == nil { + t.Fatal("expected error for credential not found") + } + if !strings.Contains(result.Err.Error(), "not found") { + t.Errorf("expected 'not found' in error, got: %v", result.Err) + } +} diff --git a/components/ambient-cli/cmd/acpctl/get/cmd.go b/components/ambient-cli/cmd/acpctl/get/cmd.go index 95b004fba..395ec0d54 100644 --- a/components/ambient-cli/cmd/acpctl/get/cmd.go +++ b/components/ambient-cli/cmd/acpctl/get/cmd.go @@ -455,7 +455,8 @@ func getRoleBindings(ctx context.Context, client *sdkclient.Client, printer *out if printer.Format() == output.FormatJSON { return printer.PrintJSON(rb) } - return printRoleBindingTable(printer, []sdktypes.RoleBinding{*rb}) + names := buildRoleBindingNameResolver(ctx, client, []sdktypes.RoleBinding{*rb}) + return printRoleBindingTable(printer, []sdktypes.RoleBinding{*rb}, names) } opts := sdktypes.NewListOptions().Size(args.limit).Build() list, err := client.RoleBindings().List(ctx, opts) @@ -465,7 +466,8 @@ func getRoleBindings(ctx context.Context, client *sdkclient.Client, printer *out if printer.Format() == output.FormatJSON { return printer.PrintJSON(list) } - return printRoleBindingTable(printer, list.Items) + names := buildRoleBindingNameResolver(ctx, client, list.Items) + return printRoleBindingTable(printer, list.Items, names) } func getCredentials(ctx context.Context, client *sdkclient.Client, printer *output.Printer, name string) error { @@ -510,11 +512,11 @@ func printCredentialTable(printer *output.Printer, credentials []sdktypes.Creden return nil } -func printRoleBindingTable(printer *output.Printer, rbs []sdktypes.RoleBinding) error { +func printRoleBindingTable(printer *output.Printer, rbs []sdktypes.RoleBinding, names map[string]string) error { columns := []output.Column{ {Name: "ID", Width: 27}, {Name: "USER", Width: 27}, - {Name: "ROLE", Width: 27}, + {Name: "ROLE", Width: 30}, {Name: "SCOPE", Width: 10}, {Name: "TARGET", Width: 27}, } @@ -525,22 +527,63 @@ func printRoleBindingTable(printer *output.Printer, rbs []sdktypes.RoleBinding) if rb.UserID != nil { userID = *rb.UserID } + roleName := resolvedName(names, rb.RoleID) target := "" switch { case rb.ProjectID != nil: - target = *rb.ProjectID + target = resolvedName(names, *rb.ProjectID) case rb.AgentID != nil: - target = *rb.AgentID + target = resolvedName(names, *rb.AgentID) case rb.SessionID != nil: - target = *rb.SessionID + target = resolvedName(names, *rb.SessionID) case rb.CredentialID != nil: - target = *rb.CredentialID + target = resolvedName(names, *rb.CredentialID) } - table.WriteRow(rb.ID, userID, rb.RoleID, rb.Scope, target) + table.WriteRow(rb.ID, userID, roleName, rb.Scope, target) } return nil } +func resolvedName(names map[string]string, id string) string { + if n, ok := names[id]; ok { + return n + } + return id +} + +func buildRoleBindingNameResolver(ctx context.Context, client *sdkclient.Client, rbs []sdktypes.RoleBinding) map[string]string { + names := make(map[string]string) + roleIDs := make(map[string]bool) + credIDs := make(map[string]bool) + for _, rb := range rbs { + roleIDs[rb.RoleID] = true + if rb.CredentialID != nil { + credIDs[*rb.CredentialID] = true + } + } + if len(roleIDs) > 0 { + opts := sdktypes.NewListOptions().Size(100).Build() + if roles, err := client.Roles().List(ctx, opts); err == nil { + for _, r := range roles.Items { + if roleIDs[r.ID] { + names[r.ID] = r.Name + } + } + } + } + if len(credIDs) > 0 { + opts := sdktypes.NewListOptions().Size(100).Build() + if creds, err := client.Credentials().List(ctx, opts); err == nil { + for _, c := range creds.Items { + if credIDs[c.ID] { + names[c.ID] = c.Name + } + } + } + } + return names +} + func watchSessions(cmd *cobra.Command, client *sdkclient.Client, printer *output.Printer) error { ctx, cancel := context.WithTimeout(cmd.Context(), args.watchTimeout) defer cancel() diff --git a/components/ambient-cli/cmd/acpctl/login/authcode.go b/components/ambient-cli/cmd/acpctl/login/authcode.go index f7b9a594a..e0e0fd0a2 100644 --- a/components/ambient-cli/cmd/acpctl/login/authcode.go +++ b/components/ambient-cli/cmd/acpctl/login/authcode.go @@ -266,6 +266,35 @@ func parseTokensResponse(body []byte) (*tokenResult, error) { }, nil } +func runClientCredentialsFlow(issuerURL, clientID, clientSecret string) (*tokenResult, error) { + issuerURL = strings.TrimRight(issuerURL, "/") + tokenURL := issuerURL + "/protocol/openid-connect/token" + + params := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {clientID}, + "client_secret": {clientSecret}, + } + + httpClient := &http.Client{Timeout: 30 * time.Second} + resp, err := httpClient.PostForm(tokenURL, params) + if err != nil { + return nil, fmt.Errorf("POST to token endpoint: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("read token response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, tokenEndpointError(resp.StatusCode, body) + } + + return parseTokensResponse(body) +} + func openBrowser(target string) error { var cmd string var cmdArgs []string diff --git a/components/ambient-cli/cmd/acpctl/login/cmd.go b/components/ambient-cli/cmd/acpctl/login/cmd.go index 0c5e92725..4a4b08e09 100644 --- a/components/ambient-cli/cmd/acpctl/login/cmd.go +++ b/components/ambient-cli/cmd/acpctl/login/cmd.go @@ -16,6 +16,7 @@ var args struct { project string insecureSkipVerify bool useAuthCode bool + clientCredentials bool issuerURL string clientID string clientSecret string @@ -31,7 +32,10 @@ To log in with a static token: acpctl login --token --url https://api.example.com To log in via browser (OAuth2 authorization code + PKCE via Red Hat SSO): - acpctl login --use-auth-code --url https://api.example.com`, + acpctl login --use-auth-code --url https://api.example.com + +To log in as a service account (headless, OAuth2 client_credentials grant): + acpctl login --client-credentials --client-id --client-secret --url https://api.example.com`, Args: cobra.MaximumNArgs(1), RunE: run, } @@ -43,17 +47,28 @@ func init() { flags.StringVar(&args.project, "project", "", "Default project name") flags.BoolVar(&args.insecureSkipVerify, "insecure-skip-tls-verify", false, "Skip TLS certificate verification (insecure)") flags.BoolVar(&args.useAuthCode, "use-auth-code", false, "Log in via browser using OAuth2 authorization code flow (Red Hat SSO)") + flags.BoolVar(&args.clientCredentials, "client-credentials", false, "Log in using OAuth2 client_credentials grant (headless service accounts)") flags.StringVar(&args.issuerURL, "issuer-url", defaultIssuerURL, "OIDC issuer URL (used with --use-auth-code)") flags.StringVar(&args.clientID, "client-id", defaultClientID, "OAuth2 client ID (used with --use-auth-code)") flags.StringVar(&args.clientSecret, "client-secret", "", "OAuth2 client secret (used with --use-auth-code for confidential clients; never persisted to config)") } func run(cmd *cobra.Command, positional []string) error { - if args.useAuthCode && args.token != "" { - return fmt.Errorf("--use-auth-code and --token are mutually exclusive") + modes := 0 + if args.token != "" { + modes++ + } + if args.useAuthCode { + modes++ + } + if args.clientCredentials { + modes++ } - if !args.useAuthCode && args.token == "" { - return fmt.Errorf("one of --token or --use-auth-code is required") + if modes != 1 { + return fmt.Errorf("exactly one of --token, --use-auth-code, or --client-credentials is required") + } + if args.clientCredentials && args.clientSecret == "" { + return fmt.Errorf("--client-secret is required with --client-credentials") } cfg, err := config.Load() if err != nil { @@ -83,7 +98,8 @@ func run(cmd *cobra.Command, positional []string) error { var accessToken string - if args.useAuthCode { + switch { + case args.useAuthCode: tokens, err := runAuthCodeFlow(args.issuerURL, args.clientID, args.clientSecret) if err != nil { return fmt.Errorf("auth-code login: %w", err) @@ -92,7 +108,16 @@ func run(cmd *cobra.Command, positional []string) error { cfg.RefreshToken = tokens.RefreshToken cfg.IssuerURL = args.issuerURL cfg.ClientID = args.clientID - } else { + case args.clientCredentials: + tokens, err := runClientCredentialsFlow(args.issuerURL, args.clientID, args.clientSecret) + if err != nil { + return fmt.Errorf("client-credentials login: %w", err) + } + accessToken = tokens.AccessToken + cfg.RefreshToken = "" + cfg.IssuerURL = args.issuerURL + cfg.ClientID = args.clientID + default: accessToken = args.token cfg.RefreshToken = "" cfg.IssuerURL = "" diff --git a/components/ambient-cli/cmd/acpctl/main.go b/components/ambient-cli/cmd/acpctl/main.go index c618356b3..dab06a511 100755 --- a/components/ambient-cli/cmd/acpctl/main.go +++ b/components/ambient-cli/cmd/acpctl/main.go @@ -29,7 +29,10 @@ import ( "github.com/spf13/cobra" ) -var insecureSkipTLSVerify bool +var ( + insecureSkipTLSVerify bool + apiURLOverride string +) var root = &cobra.Command{ Use: "acpctl", @@ -42,12 +45,16 @@ var root = &cobra.Command{ if insecureSkipTLSVerify { connection.SetInsecureSkipTLSVerify(true) } + if apiURLOverride != "" { + os.Setenv("AMBIENT_API_URL", apiURLOverride) + } return nil }, } func init() { root.PersistentFlags().BoolVar(&insecureSkipTLSVerify, "insecure-skip-tls-verify", false, "Skip TLS certificate verification (insecure)") + root.PersistentFlags().StringVar(&apiURLOverride, "api-url", "", "Override the API server URL for this invocation") root.AddCommand(login.Cmd) root.AddCommand(logout.Cmd) root.AddCommand(version.Cmd) diff --git a/components/ambient-cli/cmd/acpctl/session/messages.go b/components/ambient-cli/cmd/acpctl/session/messages.go index 28feacc28..e9587a1b7 100644 --- a/components/ambient-cli/cmd/acpctl/session/messages.go +++ b/components/ambient-cli/cmd/acpctl/session/messages.go @@ -12,6 +12,8 @@ import ( "strings" "time" + "github.com/charmbracelet/lipgloss" + "github.com/ambient-code/platform/components/ambient-cli/pkg/config" "github.com/ambient-code/platform/components/ambient-cli/pkg/connection" "github.com/ambient-code/platform/components/ambient-cli/pkg/output" @@ -19,6 +21,27 @@ import ( "github.com/spf13/cobra" ) +var ( + sseColorDim = lipgloss.Color("240") + sseColorCyan = lipgloss.Color("36") + sseColorBlue = lipgloss.Color("69") + sseColorGreen = lipgloss.Color("28") + sseColorRed = lipgloss.Color("196") + sseColorYellow = lipgloss.Color("214") + sseColorMagenta = lipgloss.Color("134") + + sseThinkTag = lipgloss.NewStyle().Foreground(sseColorDim).Bold(true) + sseThinkText = lipgloss.NewStyle().Foreground(sseColorDim).Italic(true) + sseToolTag = lipgloss.NewStyle().Foreground(sseColorCyan).Bold(true) + sseToolResult = lipgloss.NewStyle().Foreground(sseColorDim) + sseAssistant = lipgloss.NewStyle().Foreground(lipgloss.Color("255")) + sseRunTag = lipgloss.NewStyle().Foreground(sseColorGreen).Bold(true) + sseErrorTag = lipgloss.NewStyle().Foreground(sseColorRed).Bold(true) + sseAgentTag = lipgloss.NewStyle().Foreground(sseColorYellow).Bold(true) + sseStepTag = lipgloss.NewStyle().Foreground(sseColorMagenta).Bold(true) + sseArrow = lipgloss.NewStyle().Foreground(sseColorDim) +) + var msgArgs struct { follow bool followContinuous bool @@ -469,6 +492,15 @@ func streamMessagesContinuous(cmd *cobra.Command, client *sdkclient.Client, sess } func renderSSEStream(stream io.Reader, out io.Writer, jsonMode, exitOnRunFinished bool) error { + colorEnabled := output.IsTerminalWriter(out) + cw := &compactWriter{w: out} + r := &sseRenderer{ + out: cw, + color: colorEnabled, + toolArgsBuf: &strings.Builder{}, + lastToolName: "", + } + scanner := bufio.NewScanner(stream) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) var reasoningBuf strings.Builder @@ -490,58 +522,71 @@ func renderSSEStream(stream io.Reader, out io.Writer, jsonMode, exitOnRunFinishe Delta string `json:"delta"` ToolCallName string `json:"toolCallName"` Content string `json:"content"` + Message string `json:"message"` + StepName string `json:"stepName"` } if err := json.Unmarshal([]byte(data), &evt); err != nil { continue } switch evt.Type { + case "RUN_STARTED": + r.renderRunStarted() case "REASONING_MESSAGE_CONTENT": reasoningBuf.WriteString(evt.Delta) case "REASONING_END": if reasoningBuf.Len() > 0 { - fmt.Fprintf(out, "[thinking] %s\n", strings.TrimSpace(reasoningBuf.String())) + r.renderThinking(strings.TrimSpace(reasoningBuf.String())) reasoningBuf.Reset() } case "TEXT_MESSAGE_CONTENT": if evt.Delta != "" { inText = true - fmt.Fprint(out, evt.Delta) + r.renderTextDelta(evt.Delta) } case "TEXT_MESSAGE_END": if inText { - fmt.Fprintln(out) + fmt.Fprintln(cw) inText = false } case "TOOL_CALL_START": + r.flushToolArgs() if evt.ToolCallName != "" { - fmt.Fprintf(out, "[%s] ", evt.ToolCallName) + r.lastToolName = evt.ToolCallName + r.renderToolStart(evt.ToolCallName) } + case "TOOL_CALL_ARGS": + r.toolArgsBuf.WriteString(evt.Delta) + case "TOOL_CALL_END": + r.flushToolArgs() + r.lastToolName = "" case "TOOL_CALL_RESULT": + r.flushToolArgs() if evt.Content != "" { - var content string - if err := json.Unmarshal([]byte(evt.Content), &content); err != nil { - content = evt.Content - } - lines := strings.SplitN(strings.TrimSpace(content), "\n", 4) - preview := strings.Join(lines, " | ") - if len(lines) >= 4 { - preview += " ..." - } - fmt.Fprintf(out, "→ %s\n", preview) + r.renderToolResult(evt.Content) + } + case "STEP_STARTED": + if evt.StepName != "" { + r.renderStep(evt.StepName) } case "RUN_FINISHED": if inText { - fmt.Fprintln(out) + fmt.Fprintln(cw) inText = false } + r.renderRunFinished() if exitOnRunFinished { return nil } case "RUN_ERROR": if inText { - fmt.Fprintln(out) + fmt.Fprintln(cw) inText = false } + msg := evt.Message + if msg == "" { + msg = evt.Content + } + r.renderRunError(msg) if exitOnRunFinished { return fmt.Errorf("run failed") } @@ -549,7 +594,7 @@ func renderSSEStream(stream io.Reader, out io.Writer, jsonMode, exitOnRunFinishe } if inText { - fmt.Fprintln(out) + fmt.Fprintln(cw) } if scanErr := scanner.Err(); scanErr != nil { @@ -557,3 +602,103 @@ func renderSSEStream(stream io.Reader, out io.Writer, jsonMode, exitOnRunFinishe } return nil } + +type compactWriter struct { + w io.Writer + lastNL bool +} + +func (cw *compactWriter) Write(p []byte) (int, error) { + origLen := len(p) + var buf []byte + for _, b := range p { + if b == '\n' { + if cw.lastNL { + continue + } + cw.lastNL = true + } else if b == '\r' { + continue + } else { + cw.lastNL = false + } + buf = append(buf, b) + } + if len(buf) > 0 { + if _, err := cw.w.Write(buf); err != nil { + return 0, err + } + } + return origLen, nil +} + +type sseRenderer struct { + out io.Writer + color bool + toolArgsBuf *strings.Builder + lastToolName string +} + +func (r *sseRenderer) styled(s lipgloss.Style, text string) string { + if !r.color { + return text + } + return s.Render(text) +} + +func (r *sseRenderer) renderRunStarted() { + fmt.Fprintln(r.out, r.styled(sseRunTag, "▶ run started")) +} + +func (r *sseRenderer) renderRunFinished() { + fmt.Fprintln(r.out, r.styled(sseRunTag, "■ run finished")) +} + +func (r *sseRenderer) renderRunError(msg string) { + if msg != "" { + fmt.Fprintf(r.out, "%s %s\n", r.styled(sseErrorTag, "✗ error:"), r.styled(sseErrorTag, msg)) + } else { + fmt.Fprintln(r.out, r.styled(sseErrorTag, "✗ run failed")) + } +} + +func (r *sseRenderer) renderThinking(text string) { + tag := r.styled(sseThinkTag, "[thinking]") + body := r.styled(sseThinkText, text) + fmt.Fprintf(r.out, "%s %s\n", tag, body) +} + +func (r *sseRenderer) renderTextDelta(delta string) { + fmt.Fprint(r.out, delta) +} + +func (r *sseRenderer) renderToolStart(name string) { + tag := r.styled(sseToolTag, "["+name+"]") + fmt.Fprintf(r.out, "%s ", tag) +} + +func (r *sseRenderer) renderToolResult(content string) { + var decoded string + if err := json.Unmarshal([]byte(content), &decoded); err != nil { + decoded = content + } + lines := strings.SplitN(strings.TrimSpace(decoded), "\n", 4) + preview := strings.Join(lines, " | ") + if len(lines) >= 4 { + preview += " ..." + } + arrow := r.styled(sseArrow, "→") + body := r.styled(sseToolResult, preview) + fmt.Fprintf(r.out, "%s %s\n", arrow, body) +} + +func (r *sseRenderer) renderStep(name string) { + fmt.Fprintln(r.out, r.styled(sseStepTag, "⦿ step: "+name)) +} + +func (r *sseRenderer) flushToolArgs() { + if r.toolArgsBuf.Len() == 0 { + return + } + r.toolArgsBuf.Reset() +} diff --git a/components/ambient-cli/cmd/acpctl/whoami/cmd.go b/components/ambient-cli/cmd/acpctl/whoami/cmd.go index dd3909e87..76faf1cb1 100644 --- a/components/ambient-cli/cmd/acpctl/whoami/cmd.go +++ b/components/ambient-cli/cmd/acpctl/whoami/cmd.go @@ -11,6 +11,8 @@ import ( "github.com/spf13/cobra" ) +var showToken bool + var Cmd = &cobra.Command{ Use: "whoami", Short: "Display current user information", @@ -18,6 +20,10 @@ var Cmd = &cobra.Command{ RunE: run, } +func init() { + Cmd.Flags().BoolVarP(&showToken, "show-token", "t", false, "Print the current API token and exit") +} + func run(cmd *cobra.Command, _ []string) error { cfg, err := config.Load() if err != nil { @@ -31,6 +37,11 @@ func run(cmd *cobra.Command, _ []string) error { out := cmd.OutOrStdout() + if showToken { + fmt.Fprint(out, token) + return nil + } + if strings.HasPrefix(token, "sha256~") { fmt.Fprintf(out, "Token type: OpenShift service account\n") fmt.Fprintf(out, "API URL: %s\n", cfg.GetAPIUrl()) diff --git a/components/ambient-control-plane/internal/kubeclient/kubeclient.go b/components/ambient-control-plane/internal/kubeclient/kubeclient.go index 0212549b3..3148cccbb 100644 --- a/components/ambient-control-plane/internal/kubeclient/kubeclient.go +++ b/components/ambient-control-plane/internal/kubeclient/kubeclient.go @@ -310,6 +310,10 @@ func (kc *KubeClient) CreateNetworkPolicy(ctx context.Context, obj *unstructured return kc.dynamic.Resource(NetworkPolicyGVR).Namespace(obj.GetNamespace()).Create(ctx, obj, metav1.CreateOptions{}) } +func (kc *KubeClient) UpdateNetworkPolicy(ctx context.Context, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { + return kc.dynamic.Resource(NetworkPolicyGVR).Namespace(obj.GetNamespace()).Update(ctx, obj, metav1.UpdateOptions{}) +} + func (kc *KubeClient) GetResource(ctx context.Context, gvr schema.GroupVersionResource, namespace, name string) (*unstructured.Unstructured, error) { return kc.dynamic.Resource(gvr).Namespace(namespace).Get(ctx, name, metav1.GetOptions{}) } diff --git a/components/ambient-control-plane/internal/reconciler/kube_reconciler.go b/components/ambient-control-plane/internal/reconciler/kube_reconciler.go index 34c2e1b93..e96fb1018 100644 --- a/components/ambient-control-plane/internal/reconciler/kube_reconciler.go +++ b/components/ambient-control-plane/internal/reconciler/kube_reconciler.go @@ -310,9 +310,11 @@ func (r *SimpleKubeReconciler) ensureNamespaceExists(ctx context.Context, namesp func (r *SimpleKubeReconciler) ensureAPIServerNetworkPolicy(ctx context.Context, namespace string) error { name := "allow-ambient-api-server" + myNS := r.cfg.CPRuntimeNamespace - if _, err := r.nsKube().GetNetworkPolicy(ctx, namespace, name); err == nil { - return nil + existing, err := r.nsKube().GetNetworkPolicy(ctx, namespace, name) + if err == nil { + return r.reconcileAPIServerNetworkPolicy(ctx, existing, myNS) } np := &unstructured.Unstructured{ @@ -335,7 +337,7 @@ func (r *SimpleKubeReconciler) ensureAPIServerNetworkPolicy(ctx context.Context, map[string]interface{}{ "namespaceSelector": map[string]interface{}{ "matchLabels": map[string]interface{}{ - "kubernetes.io/metadata.name": r.cfg.CPRuntimeNamespace, + "kubernetes.io/metadata.name": myNS, }, }, }, @@ -361,6 +363,58 @@ func (r *SimpleKubeReconciler) ensureAPIServerNetworkPolicy(ctx context.Context, return nil } +func (r *SimpleKubeReconciler) reconcileAPIServerNetworkPolicy(ctx context.Context, np *unstructured.Unstructured, cpNamespace string) error { + ingress, _, _ := unstructured.NestedSlice(np.Object, "spec", "ingress") + if len(ingress) == 0 { + return nil + } + + rule, ok := ingress[0].(map[string]interface{}) + if !ok { + return nil + } + + fromList, _, _ := unstructured.NestedSlice(rule, "from") + + for _, entry := range fromList { + entryMap, ok := entry.(map[string]interface{}) + if !ok { + continue + } + nsSelector, _, _ := unstructured.NestedStringMap(entryMap, "namespaceSelector", "matchLabels") + if nsSelector["kubernetes.io/metadata.name"] == cpNamespace { + return nil + } + } + + fromList = append(fromList, map[string]interface{}{ + "namespaceSelector": map[string]interface{}{ + "matchLabels": map[string]interface{}{ + "kubernetes.io/metadata.name": cpNamespace, + }, + }, + }) + + if err := unstructured.SetNestedSlice(rule, fromList, "from"); err != nil { + return fmt.Errorf("setting ingress from list: %w", err) + } + ingress[0] = rule + if err := unstructured.SetNestedSlice(np.Object, ingress, "spec", "ingress"); err != nil { + return fmt.Errorf("setting ingress spec: %w", err) + } + + if _, err := r.nsKube().UpdateNetworkPolicy(ctx, np); err != nil { + return fmt.Errorf("updating network policy %s in %s: %w", np.GetName(), np.GetNamespace(), err) + } + + r.logger.Info(). + Str("namespace", np.GetNamespace()). + Str("policy", np.GetName()). + Str("added_cp_namespace", cpNamespace). + Msg("api-server network policy updated with additional CP namespace") + return nil +} + func (r *SimpleKubeReconciler) ensureImagePullAccess(ctx context.Context, namespace string) error { name := "ambient-image-puller" rb := &unstructured.Unstructured{ diff --git a/components/manifests/base/ambient-control-plane-service.yml b/components/manifests/base/ambient-control-plane-service.yml index 288a2f1a1..01e74b017 100644 --- a/components/manifests/base/ambient-control-plane-service.yml +++ b/components/manifests/base/ambient-control-plane-service.yml @@ -59,6 +59,14 @@ spec: value: "quay.io/ambient_code/vteam_claude_runner:latest" - name: MCP_IMAGE value: "quay.io/ambient_code/vteam_mcp:latest" + - name: GITHUB_MCP_IMAGE + value: "quay.io/ambient_code/vteam_credential_github:latest" + - name: JIRA_MCP_IMAGE + value: "quay.io/ambient_code/vteam_credential_jira:latest" + - name: K8S_MCP_IMAGE + value: "quay.io/ambient_code/vteam_credential_k8s:latest" + - name: GOOGLE_MCP_IMAGE + value: "quay.io/ambient_code/vteam_credential_google:latest" - name: USE_VERTEX valueFrom: configMapKeyRef: diff --git a/components/manifests/overlays/mpp-openshift/ambient-control-plane.yaml b/components/manifests/overlays/mpp-openshift/ambient-control-plane.yaml index b0c63c234..fb5e1085d 100755 --- a/components/manifests/overlays/mpp-openshift/ambient-control-plane.yaml +++ b/components/manifests/overlays/mpp-openshift/ambient-control-plane.yaml @@ -84,6 +84,14 @@ spec: value: "http://ambient-control-plane.ambient-code--ambient-s0.svc:8080/token" - name: MCP_IMAGE value: "quay.io/ambient_code/vteam_mcp:latest" + - name: GITHUB_MCP_IMAGE + value: "quay.io/ambient_code/vteam_credential_github:latest" + - name: JIRA_MCP_IMAGE + value: "quay.io/ambient_code/vteam_credential_jira:latest" + - name: K8S_MCP_IMAGE + value: "quay.io/ambient_code/vteam_credential_k8s:latest" + - name: GOOGLE_MCP_IMAGE + value: "quay.io/ambient_code/vteam_credential_google:latest" volumeMounts: - name: project-kube-token mountPath: /var/run/secrets/project-kube diff --git a/components/pr-test/build.sh b/components/pr-test/build.sh new file mode 100755 index 000000000..fa71a6c92 --- /dev/null +++ b/components/pr-test/build.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +set -euo pipefail + +PR_URL="${1:-}" +REGISTRY="${REGISTRY:-quay.io/ambient_code}" +PLATFORM="${PLATFORM:-linux/amd64}" +CONTAINER_ENGINE="${CONTAINER_ENGINE:-docker}" + +usage() { + echo "Usage: $0 " + echo " pr-url: e.g. https://github.com/ambient-code/platform/pull/1005" + echo "" + echo "Optional environment variables:" + echo " REGISTRY Registry prefix (default: quay.io/ambient_code)" + echo " PLATFORM Build platform (default: linux/amd64)" + echo " CONTAINER_ENGINE docker or podman (default: docker)" + exit 1 +} + +[[ -z "$PR_URL" ]] && usage + +PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$') +if [[ -z "$PR_NUMBER" ]]; then + echo "ERROR: Could not extract PR number from URL: $PR_URL" + exit 1 +fi + +IMAGE_TAG="pr-${PR_NUMBER}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +echo "==> Building and pushing PR #${PR_NUMBER} images (mpp-openshift target)" +echo " Tag: ${IMAGE_TAG}" +echo " Registry: ${REGISTRY}" +echo " Platform: ${PLATFORM}" +echo "" + +cd "$REPO_ROOT" + +GIT_SHA=$(git rev-parse HEAD) + +build_push() { + local name="$1" context="$2" dockerfile="$3" image="$4" + local full_image="${REGISTRY}/${image}:${IMAGE_TAG}" + echo "==> Building ${name} → ${full_image}" + "$CONTAINER_ENGINE" build \ + --platform "$PLATFORM" \ + --build-arg "AMBIENT_VERSION=${GIT_SHA}" \ + -f "$dockerfile" \ + -t "$full_image" \ + "$context" + echo "==> Pushing ${full_image}" + "$CONTAINER_ENGINE" push "$full_image" + echo "" +} + +build_push ambient-api-server \ + components/ambient-api-server \ + components/ambient-api-server/Dockerfile \ + vteam_api_server + +build_push ambient-control-plane \ + components \ + components/ambient-control-plane/Dockerfile \ + vteam_control_plane + +build_push ambient-runner \ + components/runners \ + components/runners/ambient-runner/Dockerfile \ + vteam_claude_runner + +build_push credential-github \ + components \ + components/credential-sidecars/github/Dockerfile \ + vteam_credential_github + +build_push credential-jira \ + components \ + components/credential-sidecars/jira/Dockerfile \ + vteam_credential_jira + +build_push credential-k8s \ + components \ + components/credential-sidecars/k8s/Dockerfile \ + vteam_credential_k8s + +build_push credential-google \ + components \ + components/credential-sidecars/google/Dockerfile \ + vteam_credential_google + +echo "==> All images pushed for PR #${PR_NUMBER}" +echo " Image tag: ${IMAGE_TAG}" +echo " Registry: ${REGISTRY}" diff --git a/components/pr-test/install-standard.sh b/components/pr-test/install-standard.sh index 2bc2dc6f7..029113943 100755 --- a/components/pr-test/install-standard.sh +++ b/components/pr-test/install-standard.sh @@ -552,6 +552,14 @@ spec: value: "${REGISTRY}/vteam_claude_runner:${IMAGE_TAG}" - name: MCP_IMAGE value: "${REGISTRY}/vteam_mcp:${IMAGE_TAG}" + - name: GITHUB_MCP_IMAGE + value: "${REGISTRY}/vteam_credential_github:${IMAGE_TAG}" + - name: JIRA_MCP_IMAGE + value: "${REGISTRY}/vteam_credential_jira:${IMAGE_TAG}" + - name: K8S_MCP_IMAGE + value: "${REGISTRY}/vteam_credential_k8s:${IMAGE_TAG}" + - name: GOOGLE_MCP_IMAGE + value: "${REGISTRY}/vteam_credential_google:${IMAGE_TAG}" - name: MCP_API_SERVER_URL value: "http://ambient-api-server.${NAMESPACE}.svc:8000" - name: CP_TOKEN_URL diff --git a/components/pr-test/install.sh b/components/pr-test/install.sh new file mode 100755 index 000000000..a099898db --- /dev/null +++ b/components/pr-test/install.sh @@ -0,0 +1,231 @@ +#!/usr/bin/env bash +set -euo pipefail + +NAMESPACE="${1:-}" +IMAGE_TAG="${2:-}" + +SOURCE_NAMESPACE="${SOURCE_NAMESPACE:-ambient-code--runtime-int}" +CONFIG_NAMESPACE="${CONFIG_NAMESPACE:-ambient-code--config}" + +REQUIRED_SOURCE_SECRETS=( + ambient-vertex + ambient-api-server + ambient-api-server-db + tenantaccess-ambient-control-plane-token +) + +usage() { + echo "Usage: $0 " + echo " namespace: e.g. ambient-code--pr-42" + echo " image-tag: e.g. pr-42" + echo "" + echo "Optional environment variables:" + echo " SOURCE_NAMESPACE Namespace to copy secrets from (default: ambient-code--runtime-int)" + echo " CONFIG_NAMESPACE Namespace containing lock ConfigMaps (default: ambient-code--config)" + exit 1 +} + +[[ -z "$NAMESPACE" || -z "$IMAGE_TAG" ]] && usage + +PR_ID=$(echo "$NAMESPACE" | grep -oE 'pr-[0-9]+') + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +OVERLAY_DIR="$REPO_ROOT/components/manifests/overlays/mpp-openshift" + +copy_secret() { + local name="$1" + echo " Copying secret: $name" + oc get secret "$name" -n "$SOURCE_NAMESPACE" -o json \ + | python3 -c " +import json, sys +s = json.load(sys.stdin) +del s['metadata']['namespace'] +del s['metadata']['resourceVersion'] +del s['metadata']['uid'] +del s['metadata']['creationTimestamp'] +s['metadata'].pop('ownerReferences', None) +s['metadata'].pop('annotations', None) +s.pop('status', None) +print(json.dumps(s)) +" | oc apply -n "$NAMESPACE" -f - +} + +echo "==> Installing Ambient into $NAMESPACE with images tagged $IMAGE_TAG" + +echo "==> Step 1: Verifying required secrets exist in $SOURCE_NAMESPACE" +FAILED=0 +for secret in "${REQUIRED_SOURCE_SECRETS[@]}"; do + if oc get secret "$secret" -n "$SOURCE_NAMESPACE" &>/dev/null 2>&1; then + echo " Secret OK: $secret" + else + echo "ERROR: Required secret missing from $SOURCE_NAMESPACE: $secret" + FAILED=1 + fi +done +[[ $FAILED -eq 1 ]] && exit 1 + +echo "==> Step 2: Copying secrets from $SOURCE_NAMESPACE" +for secret in "${REQUIRED_SOURCE_SECRETS[@]}"; do + copy_secret "$secret" +done + +echo "==> Step 3: Deploying mpp-openshift overlay with image tag $IMAGE_TAG" +TMPDIR=$(mktemp -d) +cp -r "$OVERLAY_DIR/." "$TMPDIR/" +trap "rm -rf $TMPDIR" EXIT + +pushd "$TMPDIR" > /dev/null + +python3 - "$NAMESPACE" "$IMAGE_TAG" << 'PYEOF' +import sys, re + +namespace, tag = sys.argv[1], sys.argv[2] +kfile = "kustomization.yaml" +text = open(kfile).read() + +text = re.sub(r'(^namespace:\s*).*', r'\g<1>' + namespace, text, flags=re.MULTILINE) +if not re.search(r'^namespace:', text, re.MULTILINE): + text = "namespace: " + namespace + "\n" + text + +for repo in ("vteam_api_server", "vteam_control_plane"): + text = re.sub( + r'(- name: quay\.io/ambient_code/' + repo + r':latest\n\s+newName:.*\n\s+newTag:\s*).*', + r'\g<1>' + tag, text, + ) + text = re.sub( + r'(- name: quay\.io/ambient_code/' + repo + r'\n\s+newTag:\s*).*', + r'\g<1>' + tag, text, + ) + +open(kfile, 'w').write(text) +PYEOF + +FILTER_SCRIPT="$TMPDIR/filter.py" +cat > "$FILTER_SCRIPT" << 'PYEOF' +import sys, re, os + +namespace = os.environ['NAMESPACE'] +pr_id = os.environ['PR_ID'] + +for doc in sys.stdin.read().split('\n---\n'): + doc = doc.strip() + if not doc: + continue + kind_m = re.search(r'^kind:\s*(\S+)', doc, re.MULTILINE) + if not kind_m: + continue + kind = kind_m.group(1) + if kind == 'Route': + if 'labels:' not in doc: + doc = re.sub(r'(metadata:)', r'\1\n labels:', doc, count=1) + if 'paas.redhat.com/appcode' not in doc: + doc = re.sub(r'( labels:)', r'\1\n paas.redhat.com/appcode: AMBC-001', doc, count=1) + doc = re.sub( + r'( host:\s*).*', + lambda m: m.group(1) + f'ambient-api-server-{namespace}.internal-router-shard.mpp-w2-preprod.cfln.p1.openshiftapps.com', + doc, + ) + print('---') + print(doc) +PYEOF + +oc kustomize . \ + | NAMESPACE="$NAMESPACE" PR_ID="$PR_ID" \ + python3 "$FILTER_SCRIPT" \ + | oc apply -n "$NAMESPACE" -f - + +popd > /dev/null + +echo "==> Step 4: Patching control-plane service URLs and kubeconfig" +oc set env deployment/ambient-control-plane -n "$NAMESPACE" \ + AMBIENT_API_SERVER_URL="http://ambient-api-server.${NAMESPACE}.svc:8000" \ + AMBIENT_GRPC_SERVER_ADDR="ambient-api-server.${NAMESPACE}.svc:9000" \ + CP_RUNTIME_NAMESPACE="$NAMESPACE" + +KUBE_HOST=$(oc whoami --show-server) +KUBE_CA=$(oc get secret tenantaccess-ambient-control-plane-token -n "$NAMESPACE" \ + -o jsonpath='{.data.ca\.crt}') + +python3 - << PYEOF +import subprocess, base64, json, os + +kube_host = os.environ.get('KUBE_HOST', '').strip() or """$KUBE_HOST""".strip() +kube_ca = os.environ.get('KUBE_CA', '').strip() or """$KUBE_CA""".strip() +namespace = """$NAMESPACE""".strip() + +kubeconfig = ( + "apiVersion: v1\n" + "kind: Config\n" + "clusters:\n" + "- name: cluster\n" + " cluster:\n" + f" server: {kube_host}\n" + f" certificate-authority-data: {kube_ca}\n" + "users:\n" + "- name: ambient-control-plane\n" + " user:\n" + " tokenFile: /var/run/secrets/project-kube/token\n" + "contexts:\n" + "- name: default\n" + " context:\n" + " cluster: cluster\n" + " user: ambient-control-plane\n" + "current-context: default\n" +) + +secret = { + "apiVersion": "v1", + "kind": "Secret", + "metadata": {"name": "ambient-control-plane-kubeconfig", "namespace": namespace}, + "data": {"kubeconfig": base64.b64encode(kubeconfig.encode()).decode()}, +} + +import tempfile +with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(secret, f) + fname = f.name + +r = subprocess.run(["oc", "apply", "-f", fname], capture_output=True, text=True) +print(r.stdout.strip()); print(r.stderr.strip()) +os.unlink(fname) +PYEOF + +oc set volume deployment/ambient-control-plane -n "$NAMESPACE" \ + --add --name=kubeconfig \ + --type=secret \ + --secret-name=ambient-control-plane-kubeconfig \ + --mount-path=/var/run/secrets/kubeconfig \ + --overwrite 2>&1 | grep -v "^$" || true + +oc set env deployment/ambient-control-plane -n "$NAMESPACE" \ + KUBECONFIG=/var/run/secrets/kubeconfig/kubeconfig + +echo "==> Step 5: Waiting for rollouts" +for deploy in ambient-api-server-db ambient-api-server ambient-control-plane; do + echo " Waiting for $deploy..." + oc rollout status deployment/"$deploy" -n "$NAMESPACE" --timeout=300s +done + +echo "==> Step 6: Verifying health" +API_HOST=$(oc get route ambient-api-server -n "$NAMESPACE" \ + -o jsonpath='{.spec.host}' 2>/dev/null || true) + +if [[ -z "$API_HOST" ]]; then + echo "ERROR: ambient-api-server route not found in $NAMESPACE" + exit 1 +fi + +HEALTH=$(curl -fsS --connect-timeout 5 --max-time 20 \ + --retry 3 --retry-all-errors "https://${API_HOST}/api/ambient" 2>&1 || true) +echo " API server: ${HEALTH:-}" + +echo "" +echo "==> Ambient installed successfully in $NAMESPACE" +echo " API server: https://${API_HOST}" +echo " Image tag: $IMAGE_TAG" + +if [[ -n "${GITHUB_OUTPUT:-}" ]]; then + echo "api_server_url=https://${API_HOST}" >> "$GITHUB_OUTPUT" + echo "namespace=$NAMESPACE" >> "$GITHUB_OUTPUT" +fi diff --git a/components/pr-test/provision.sh b/components/pr-test/provision.sh new file mode 100755 index 000000000..37ccb2377 --- /dev/null +++ b/components/pr-test/provision.sh @@ -0,0 +1,129 @@ +#!/usr/bin/env bash +set -euo pipefail + +COMMAND="${1:-}" +INSTANCE_ID="${2:-}" + +CONFIG_NAMESPACE="ambient-code--config" +ARGOCD_NAMESPACE="${ARGOCD_NAMESPACE:-ambient-code--argocd}" +MAX_S0X_INSTANCES="${MAX_S0X_INSTANCES:-5}" +READY_TIMEOUT="${READY_TIMEOUT:-60}" +DELETE_TIMEOUT="${DELETE_TIMEOUT:-120}" + +usage() { + echo "Usage: $0 " + echo " instance-id: e.g. pr-123-feat-xyz" + echo "" + echo "Environment variables:" + echo " MAX_S0X_INSTANCES Maximum concurrent S0.x instances (default: 5)" + echo " READY_TIMEOUT Seconds to wait for namespace Active (default: 60)" + echo " DELETE_TIMEOUT Seconds to wait for namespace deletion (default: 120)" + exit 1 +} + +[[ -z "$COMMAND" || -z "$INSTANCE_ID" ]] && usage +[[ "$COMMAND" != "create" && "$COMMAND" != "destroy" ]] && usage + +NAMESPACE="ambient-code--${INSTANCE_ID}" + +create() { + echo "==> Reserving slot via ConfigMap lock..." + LOCK_NAME="pr-test-slot-${INSTANCE_ID}" + if ! oc create configmap "$LOCK_NAME" -n "$CONFIG_NAMESPACE" \ + --from-literal=instance="$INSTANCE_ID" \ + --from-literal=created="$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + 2>/dev/null; then + echo "ERROR: Slot already reserved for instance $INSTANCE_ID (lock $LOCK_NAME exists)" + exit 1 + fi + echo " Slot reserved: $LOCK_NAME" + + echo "==> Checking S0.x instance capacity..." + ACTIVE=$(oc get tenantnamespace -n "$CONFIG_NAMESPACE" \ + -l ambient-code/instance-type=s0x --no-headers 2>/dev/null | wc -l | tr -d ' ') + + if [ "$ACTIVE" -ge "$MAX_S0X_INSTANCES" ]; then + echo "ERROR: At capacity — $ACTIVE/$MAX_S0X_INSTANCES S0.x instances active." + echo "Active instances:" + oc get tenantnamespace -n "$CONFIG_NAMESPACE" \ + -l ambient-code/instance-type=s0x -o name + oc delete configmap "$LOCK_NAME" -n "$CONFIG_NAMESPACE" --ignore-not-found=true + exit 1 + fi + echo " Capacity OK: $ACTIVE/$MAX_S0X_INSTANCES" + + echo "==> Applying TenantNamespace CR: $INSTANCE_ID" + cat < Waiting for namespace ${NAMESPACE} to become Active (timeout: ${READY_TIMEOUT}s)..." + DEADLINE=$((SECONDS + READY_TIMEOUT)) + while [ $SECONDS -lt $DEADLINE ]; do + NS_STATUS=$(oc get namespace "$NAMESPACE" -o jsonpath='{.status.phase}' 2>/dev/null || true) + TN_READY=$(oc get tenantnamespace "$INSTANCE_ID" -n "$CONFIG_NAMESPACE" \ + -o jsonpath='{.status.conditions[?(@.type=="Ready")].status}' 2>/dev/null || true) + TN_RECONCILED=$(oc get tenantnamespace "$INSTANCE_ID" -n "$CONFIG_NAMESPACE" \ + -o jsonpath='{.status.lastSuccessfulReconciliationTimestamp}' 2>/dev/null || true) + if [ "$NS_STATUS" == "Active" ] && { [ "$TN_READY" == "True" ] || [ -n "$TN_RECONCILED" ]; }; then + echo " Namespace ${NAMESPACE} is Active and TenantNamespace is Ready." + echo "$NAMESPACE" + exit 0 + fi + echo " ns=${NS_STATUS:-NotFound} tn-ready=${TN_READY:-unknown}, retrying..." + sleep 3 + done + + echo "ERROR: Namespace ${NAMESPACE} did not become Active+Ready within ${READY_TIMEOUT}s." + oc describe tenantnamespace "$INSTANCE_ID" -n "$CONFIG_NAMESPACE" || true + exit 1 +} + +destroy() { + APP_NAME="pr-test-${INSTANCE_ID}" + echo "==> Deleting ArgoCD Application: $APP_NAME" + oc delete application "$APP_NAME" -n "$ARGOCD_NAMESPACE" \ + --ignore-not-found=true 2>/dev/null || true + + echo "==> Deleting TenantNamespace CR: $INSTANCE_ID" + oc delete tenantnamespace "$INSTANCE_ID" -n "$CONFIG_NAMESPACE" \ + --ignore-not-found=true + + LOCK_NAME="pr-test-slot-${INSTANCE_ID}" + oc delete configmap "$LOCK_NAME" -n "$CONFIG_NAMESPACE" --ignore-not-found=true 2>/dev/null || true + + echo "==> Waiting for namespace ${NAMESPACE} to be deleted (timeout: ${DELETE_TIMEOUT}s)..." + DEADLINE=$((SECONDS + DELETE_TIMEOUT)) + while [ $SECONDS -lt $DEADLINE ]; do + NS_CHECK=$(oc get namespace "$NAMESPACE" 2>&1 || true) + if echo "$NS_CHECK" | grep -q '(NotFound)\|not found'; then + echo " Namespace ${NAMESPACE} deleted." + exit 0 + elif [ -z "$(oc get namespace "$NAMESPACE" -o name 2>/dev/null || true)" ]; then + echo " Namespace ${NAMESPACE} deleted." + exit 0 + fi + echo " Namespace still exists, waiting..." + sleep 5 + done + + echo "WARNING: Namespace ${NAMESPACE} still exists after ${DELETE_TIMEOUT}s. May need manual cleanup." + exit 1 +} + +case "$COMMAND" in + create) create ;; + destroy) destroy ;; +esac diff --git a/skills/control-plane/ambient/SKILL.md b/skills/control-plane/ambient/SKILL.md index 2582d080d..f90142ff8 100644 --- a/skills/control-plane/ambient/SKILL.md +++ b/skills/control-plane/ambient/SKILL.md @@ -31,6 +31,15 @@ You are an expert in deploying the Ambient Code Platform to OpenShift clusters. Runner pods (`vteam_claude_runner`, `vteam_state_sync`) are spawned dynamically by the operator — they are not standing deployments. +Credential sidecar containers are injected into session pods when the corresponding credential type is configured: + + < /dev/null | Sidecar Container | Image | Port | Provider | +|-------------------|-------|------|----------| +| `credential-github` | `quay.io/ambient_code/vteam_credential_github` | 8091 | GitHub PAT / App | +| `credential-jira` | `quay.io/ambient_code/vteam_credential_jira` | 8092 | Jira / Atlassian | +| `credential-k8s` | `quay.io/ambient_code/vteam_credential_k8s` | 8093 | Kubeconfig | +| `credential-google` | `quay.io/ambient_code/vteam_credential_google` | 8094 | Google Workspace | + --- ## Prerequisites @@ -152,6 +161,12 @@ kustomize edit set image \ quay.io/ambient_code/vteam_public_api:latest=quay.io/ambient_code/vteam_public_api:$IMAGE_TAG oc apply -k . -n $NAMESPACE + +oc set env deployment/ambient-control-plane -n $NAMESPACE \ + GITHUB_MCP_IMAGE=quay.io/ambient_code/vteam_credential_github:$IMAGE_TAG \ + JIRA_MCP_IMAGE=quay.io/ambient_code/vteam_credential_jira:$IMAGE_TAG \ + K8S_MCP_IMAGE=quay.io/ambient_code/vteam_credential_k8s:$IMAGE_TAG \ + GOOGLE_MCP_IMAGE=quay.io/ambient_code/vteam_credential_google:$IMAGE_TAG popd rm -rf "$TMPDIR" ```