diff --git a/internal/manifest/field_config.go b/internal/manifest/field_config.go index 7322204..b3c9e16 100644 --- a/internal/manifest/field_config.go +++ b/internal/manifest/field_config.go @@ -38,6 +38,12 @@ var writeOnlyFields = map[ResourceKind][]string{ "sourceAgent", "targetAgent", // manifest references resolved to UUIDs at create time "settings", // per-user grants and rate limits — JSONB write-only }, + KindWorkstation: { + // SSH credential fields — stored AES-256-GCM encrypted, never returned by API. + "privateKey", "knownHostsFingerprint", "host", "port", "user", "connectTimeoutSec", + // Manifest-only orchestration fields managed via side-effects (permissions.add, linkAgent). + "allowlist", "agents", + }, } // WriteOnlyFields returns the write-only fields for a resource kind. diff --git a/internal/manifest/types.go b/internal/manifest/types.go index 75d0cb3..df51873 100644 --- a/internal/manifest/types.go +++ b/internal/manifest/types.go @@ -43,6 +43,7 @@ const ( KindSecureCLI ResourceKind = "SecureCLI" KindSecureCLIGrant ResourceKind = "SecureCLIGrant" KindAgentLink ResourceKind = "AgentLink" + KindWorkstation ResourceKind = "Workstation" ) // Resource is a generic managed resource with kind + name + arbitrary spec. @@ -75,5 +76,6 @@ func ApplyOrder() []ResourceKind { KindSecureCLIGrant, // depends on SecureCLI + Agent KindAgentTeam, // no strict deps KindAgentLink, // depends on Agent (source + target); placed after AgentTeam to coexist with team-managed links + KindWorkstation, // depends on Agent (for links); after AgentLink so agents exist } } diff --git a/internal/manifest/validate.go b/internal/manifest/validate.go index 32efba7..1b75edc 100644 --- a/internal/manifest/validate.go +++ b/internal/manifest/validate.go @@ -32,6 +32,7 @@ var validKinds = map[ResourceKind]bool{ KindSecureCLI: true, KindSecureCLIGrant: true, KindAgentLink: true, + KindWorkstation: true, } // Validate checks the manifest for structural errors. diff --git a/internal/provider/goclaw/list_all.go b/internal/provider/goclaw/list_all.go index 98ae7fc..6e4b68c 100644 --- a/internal/provider/goclaw/list_all.go +++ b/internal/provider/goclaw/list_all.go @@ -309,6 +309,32 @@ func (p *Provider) listAllAgentLinks(ctx context.Context) ([]reconciler.Resource return infos, nil } +// listAllWorkstations returns ResourceInfo for every workstation in GoClaw via WS RPC. +func (p *Provider) listAllWorkstations(ctx context.Context) ([]reconciler.ResourceInfo, error) { + if err := p.ensureWS(ctx); err != nil { + return nil, fmt.Errorf("ws connect for workstations: %w", err) + } + payload, err := p.ws.Call(ctx, "workstations.list", nil) + if err != nil { + return nil, fmt.Errorf("workstations.list: %w", err) + } + var resp struct { + Workstations []map[string]any `json:"workstations"` + } + if err := json.Unmarshal(payload, &resp); err != nil { + return nil, fmt.Errorf("parse workstations.list response: %w", err) + } + infos := make([]reconciler.ResourceInfo, 0, len(resp.Workstations)) + for _, ws := range resp.Workstations { + infos = append(infos, reconciler.ResourceInfo{ + Kind: manifest.KindWorkstation, + Name: strVal(ws, "workstationKey"), + CreatedBy: strVal(ws, "createdBy"), + }) + } + return infos, nil +} + // listAllTenants returns ResourceInfo for every tenant in GoClaw. func (p *Provider) listAllTenants(ctx context.Context) ([]reconciler.ResourceInfo, error) { data, err := p.http.Get(ctx, "/v1/tenants") diff --git a/internal/provider/goclaw/provider.go b/internal/provider/goclaw/provider.go index 09cef20..a24cb9e 100644 --- a/internal/provider/goclaw/provider.go +++ b/internal/provider/goclaw/provider.go @@ -169,6 +169,8 @@ func (p *Provider) Observe(ctx context.Context, kind manifest.ResourceKind, key return p.observeSecureCLIGrant(ctx, key) case manifest.KindAgentLink: return p.observeAgentLink(ctx, key) + case manifest.KindWorkstation: + return p.observeWorkstation(ctx, key) default: return nil, fmt.Errorf("observe not implemented for kind %s", kind) } @@ -242,6 +244,8 @@ func (p *Provider) Create(ctx context.Context, kind manifest.ResourceKind, key s return p.createSecureCLIGrant(ctx, key, spec) case manifest.KindAgentLink: return p.createAgentLink(ctx, key, spec) + case manifest.KindWorkstation: + return p.createWorkstation(ctx, key, spec) default: return fmt.Errorf("create not implemented for kind %s", kind) } @@ -280,6 +284,8 @@ func (p *Provider) Update(ctx context.Context, kind manifest.ResourceKind, key s return p.updateSecureCLIGrant(ctx, key, spec) case manifest.KindAgentLink: return p.updateAgentLink(ctx, key, spec) + case manifest.KindWorkstation: + return p.updateWorkstation(ctx, key, spec) default: return fmt.Errorf("update not implemented for kind %s", kind) } @@ -318,6 +324,8 @@ func (p *Provider) Delete(ctx context.Context, kind manifest.ResourceKind, key s return p.deleteAgentLink(ctx, key) case manifest.KindSkill: return p.deleteSkill(ctx, key) + case manifest.KindWorkstation: + return p.deleteWorkstation(ctx, key) default: return fmt.Errorf("delete not implemented for kind %s", kind) } @@ -346,6 +354,8 @@ func (p *Provider) ListAll(ctx context.Context, kind manifest.ResourceKind) ([]r return p.listAllSecureCLIs(ctx) case manifest.KindAgentLink: return p.listAllAgentLinks(ctx) + case manifest.KindWorkstation: + return p.listAllWorkstations(ctx) case manifest.KindBuiltinToolConfig, manifest.KindSkillConfig, manifest.KindSystemConfig, manifest.KindMCPCredentials, manifest.KindSecureCLIGrant: return nil, nil // per-tenant configs and child resources not enumerable for prune default: diff --git a/internal/provider/goclaw/workstation_test.go b/internal/provider/goclaw/workstation_test.go new file mode 100644 index 0000000..093f5b2 --- /dev/null +++ b/internal/provider/goclaw/workstation_test.go @@ -0,0 +1,332 @@ +package goclaw + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/dataplanelabs/gcplane/internal/manifest" +) + +func agentsHandlerFor(agents []map[string]any) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/agents" { + _ = json.NewEncoder(w).Encode(map[string]any{"agents": agents}) + return + } + http.NotFound(w, r) + } +} + +func TestWorkstation_Observe_Found(t *testing.T) { + p, cleanup := newWSTestServer(t, []wsResponse{ + {method: "workstations.list", ok: true, payload: map[string]any{ + "workstations": []map[string]any{ + { + "id": "ws-uuid-1", + "workstationKey": "coding-agent", + "name": "Coding Agent (codex)", + "backendType": "ssh", + "defaultCwd": "/workspace", + "active": true, + "tenantId": "tenant-uuid", + "createdBy": "gcplane", + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "metadataSummary": map[string]any{"host": "ssh.example.com", "hasKey": true}, + }, + }, + }}, + }, nil) + defer cleanup() + + result, err := p.Observe(context.Background(), manifest.KindWorkstation, "coding-agent") + if err != nil { + t.Fatalf("observe: %v", err) + } + if result == nil { + t.Fatal("expected non-nil result") + } + if result["workstationKey"] != "coding-agent" { + t.Errorf("workstationKey = %v, want coding-agent", result["workstationKey"]) + } + if result["backendType"] != "ssh" { + t.Errorf("backendType = %v, want ssh", result["backendType"]) + } +} + +func TestWorkstation_Observe_NotFound(t *testing.T) { + p, cleanup := newWSTestServer(t, []wsResponse{ + {method: "workstations.list", ok: true, payload: map[string]any{ + "workstations": []map[string]any{}, + }}, + }, nil) + defer cleanup() + + result, err := p.Observe(context.Background(), manifest.KindWorkstation, "ghost") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != nil { + t.Fatalf("expected nil for missing workstation, got %v", result) + } +} + +func TestWorkstation_Observe_StripsInternalFields(t *testing.T) { + p, cleanup := newWSTestServer(t, []wsResponse{ + {method: "workstations.list", ok: true, payload: map[string]any{ + "workstations": []map[string]any{ + { + "id": "ws-uuid-1", + "workstationKey": "coding-agent", + "name": "Coding Agent", + "backendType": "ssh", + "tenantId": "t-uuid", + "createdBy": "gcplane", + "createdAt": "2026-01-01T00:00:00Z", + "updatedAt": "2026-01-01T00:00:00Z", + "metadataSummary": map[string]any{"host": "ssh.example.com"}, + }, + }, + }}, + }, nil) + defer cleanup() + + result, err := p.Observe(context.Background(), manifest.KindWorkstation, "coding-agent") + if err != nil { + t.Fatalf("observe: %v", err) + } + for _, f := range workstationInternalFields { + if _, ok := result[f]; ok { + t.Errorf("internal field %q should be stripped from observe result", f) + } + } + // Observable fields should survive. + if result["name"] != "Coding Agent" { + t.Errorf("name should be preserved, got %v", result["name"]) + } +} + +func TestWorkstation_Create(t *testing.T) { + agents := []map[string]any{{"id": "agent-uuid-1", "agent_key": "assistant"}} + wsCalls := []string{} + + responses := []wsResponse{ + {method: "workstations.create", ok: true, payload: map[string]any{"workstation": map[string]any{ + "id": "ws-new", + "workstationKey": "coding-agent", + }}}, + // resolve after create + {method: "workstations.list", ok: true, payload: map[string]any{ + "workstations": []map[string]any{{"id": "ws-new", "workstationKey": "coding-agent", "createdBy": "gcplane"}}, + }}, + // allowlist + {method: "workstations.permissions.add", ok: true, payload: map[string]any{"permission": map[string]any{"id": "p1", "pattern": "git"}}}, + {method: "workstations.permissions.add", ok: true, payload: map[string]any{"permission": map[string]any{"id": "p2", "pattern": "ls"}}}, + // agent link + {method: "workstations.linkAgent", ok: true, + assertParams: func(t *testing.T, params any) { + t.Helper() + got, _ := params.(map[string]any) + if got["agentId"] != "agent-uuid-1" { + t.Errorf("linkAgent agentId = %v, want agent-uuid-1", got["agentId"]) + } + if got["workstationId"] != "ws-new" { + t.Errorf("linkAgent workstationId = %v, want ws-new", got["workstationId"]) + } + }, + payload: map[string]any{"linked": true}}, + } + _ = wsCalls + + p, cleanup := newWSTestServer(t, responses, agentsHandlerFor(agents)) + defer cleanup() + + err := p.Create(context.Background(), manifest.KindWorkstation, "coding-agent", map[string]any{ + "displayName": "Coding Agent (codex)", + "backendType": "ssh", + "host": "coding-agent-ssh.coding-agent.svc.cluster.local", + "port": 22, + "user": "claude", + "privateKey": "-----BEGIN OPENSSH PRIVATE KEY-----\ntest\n-----END OPENSSH PRIVATE KEY-----", + "defaultCwd": "/workspace", + "allowlist": []any{"git", "ls"}, + "agents": []any{"assistant"}, + }) + if err != nil { + t.Fatalf("create: %v", err) + } +} + +func TestWorkstation_Create_NoAllowlistNoAgents(t *testing.T) { + responses := []wsResponse{ + {method: "workstations.create", ok: true, payload: map[string]any{"workstation": map[string]any{ + "id": "ws-bare", + "workstationKey": "bare-ws", + }}}, + {method: "workstations.list", ok: true, payload: map[string]any{ + "workstations": []map[string]any{{"id": "ws-bare", "workstationKey": "bare-ws", "createdBy": "gcplane"}}, + }}, + } + + p, cleanup := newWSTestServer(t, responses, nil) + defer cleanup() + + err := p.Create(context.Background(), manifest.KindWorkstation, "bare-ws", map[string]any{ + "displayName": "Bare Workstation", + "backendType": "ssh", + }) + if err != nil { + t.Fatalf("create without allowlist/agents: %v", err) + } +} + +func TestWorkstation_Update(t *testing.T) { + responses := []wsResponse{ + // resolve ID + {method: "workstations.list", ok: true, payload: map[string]any{ + "workstations": []map[string]any{{"id": "ws-uuid-1", "workstationKey": "coding-agent", "createdBy": "gcplane"}}, + }}, + {method: "workstations.update", ok: true, payload: map[string]any{"id": "ws-uuid-1"}}, + // list permissions for diff + {method: "workstations.permissions.list", ok: true, payload: map[string]any{ + "permissions": []map[string]any{ + {"id": "p1", "pattern": "git"}, + {"id": "p2", "pattern": "old-cmd"}, + }, + }}, + // add new, remove surplus + {method: "workstations.permissions.add", ok: true, payload: map[string]any{"permission": map[string]any{"id": "p3", "pattern": "ls"}}}, + {method: "workstations.permissions.remove", ok: true, payload: map[string]any{"id": "p2"}}, + } + + p, cleanup := newWSTestServer(t, responses, nil) + defer cleanup() + + err := p.Update(context.Background(), manifest.KindWorkstation, "coding-agent", map[string]any{ + "displayName": "Coding Agent Updated", + "allowlist": []any{"git", "ls"}, + }) + if err != nil { + t.Fatalf("update: %v", err) + } +} + +func TestWorkstation_Delete(t *testing.T) { + responses := []wsResponse{ + {method: "workstations.list", ok: true, payload: map[string]any{ + "workstations": []map[string]any{{"id": "ws-uuid-1", "workstationKey": "coding-agent"}}, + }}, + {method: "workstations.delete", ok: true, payload: map[string]any{"id": "ws-uuid-1"}}, + } + + p, cleanup := newWSTestServer(t, responses, nil) + defer cleanup() + + if err := p.Delete(context.Background(), manifest.KindWorkstation, "coding-agent"); err != nil { + t.Fatalf("delete: %v", err) + } +} + +func TestWorkstation_Delete_NotFound(t *testing.T) { + p, cleanup := newWSTestServer(t, []wsResponse{ + {method: "workstations.list", ok: true, payload: map[string]any{ + "workstations": []map[string]any{}, + }}, + }, nil) + defer cleanup() + + if err := p.Delete(context.Background(), manifest.KindWorkstation, "ghost"); err != nil { + t.Fatalf("idempotent delete should not error: %v", err) + } +} + +func TestWorkstation_ListAll(t *testing.T) { + p, cleanup := newWSTestServer(t, []wsResponse{ + {method: "workstations.list", ok: true, payload: map[string]any{ + "workstations": []map[string]any{ + {"id": "w1", "workstationKey": "coding-agent", "createdBy": "gcplane"}, + {"id": "w2", "workstationKey": "staging-ws", "createdBy": "ui"}, + }, + }}, + }, nil) + defer cleanup() + + infos, err := p.ListAll(context.Background(), manifest.KindWorkstation) + if err != nil { + t.Fatalf("listAll: %v", err) + } + if len(infos) != 2 { + t.Fatalf("expected 2 infos, got %d", len(infos)) + } + if infos[0].Name != "coding-agent" { + t.Errorf("infos[0].Name = %v, want coding-agent", infos[0].Name) + } + if infos[1].CreatedBy != "ui" { + t.Errorf("infos[1].CreatedBy = %v, want ui", infos[1].CreatedBy) + } +} + +func TestWorkstation_BuildCreateParams(t *testing.T) { + spec := map[string]any{ + "displayName": "Coding Agent", + "backendType": "ssh", + "host": "ssh.example.com", + "port": 22, + "user": "claude", + "privateKey": "PRIVATEKEY", + "knownHostsFingerprint": "SHA256:abc", + "defaultCwd": "/workspace", + "allowlist": []any{"git"}, + "agents": []any{"assistant"}, + } + + params := buildWorkstationCreateParams("coding-agent", spec) + + if params["workstationKey"] != "coding-agent" { + t.Errorf("workstationKey = %v, want coding-agent", params["workstationKey"]) + } + if params["name"] != "Coding Agent" { + t.Errorf("name = %v, want Coding Agent", params["name"]) + } + if params["backendType"] != "ssh" { + t.Errorf("backendType = %v, want ssh", params["backendType"]) + } + if params["defaultCwd"] != "/workspace" { + t.Errorf("defaultCwd = %v, want /workspace", params["defaultCwd"]) + } + + meta, ok := params["metadata"].(map[string]any) + if !ok { + t.Fatalf("metadata missing or wrong type") + } + if meta["host"] != "ssh.example.com" { + t.Errorf("metadata.host = %v, want ssh.example.com", meta["host"]) + } + if meta["privateKey"] != "PRIVATEKEY" { + t.Errorf("metadata.privateKey = %v, want PRIVATEKEY", meta["privateKey"]) + } + + // allowlist and agents must not leak into RPC params (handled via side-effects). + if _, ok := params["allowlist"]; ok { + t.Error("allowlist should not appear in workstations.create params") + } + if _, ok := params["agents"]; ok { + t.Error("agents should not appear in workstations.create params") + } +} + +func TestWorkstation_WriteOnlyFields(t *testing.T) { + fields := manifest.WriteOnlyFields(manifest.KindWorkstation) + required := []string{"privateKey", "allowlist", "agents", "host", "user"} + fieldSet := make(map[string]bool, len(fields)) + for _, f := range fields { + fieldSet[f] = true + } + for _, req := range required { + if !fieldSet[req] { + t.Errorf("expected write-only field %q to be registered for KindWorkstation", req) + } + } +} diff --git a/internal/provider/goclaw/workstations.go b/internal/provider/goclaw/workstations.go new file mode 100644 index 0000000..bbc3405 --- /dev/null +++ b/internal/provider/goclaw/workstations.go @@ -0,0 +1,390 @@ +package goclaw + +import ( + "context" + "encoding/json" + "fmt" + "strings" +) + +// workstationInternalFields are WS RPC response fields (camelCase) not present +// in manifests; stripped to prevent phantom diffs. +var workstationInternalFields = []string{ + "id", "tenantId", "createdAt", "updatedAt", "createdBy", "metadataSummary", +} + +// observeWorkstation fetches a workstation by workstationKey via WS RPC. +// workstations.list returns SanitizedView — metadata (host/port/user/privateKey) +// is never returned by the API, so those fields are write-only in field_config.go. +func (p *Provider) observeWorkstation(ctx context.Context, key string) (map[string]any, error) { + if err := p.ensureWS(ctx); err != nil { + return nil, fmt.Errorf("ws connect for workstations: %w", err) + } + + payload, err := p.ws.Call(ctx, "workstations.list", nil) + if err != nil { + return nil, fmt.Errorf("workstations.list: %w", err) + } + + var resp struct { + Workstations []map[string]any `json:"workstations"` + } + if err := json.Unmarshal(payload, &resp); err != nil { + return nil, fmt.Errorf("parse workstations.list response: %w", err) + } + + for _, ws := range resp.Workstations { + // workstations.list returns camelCase (WS RPC convention). + if strVal(ws, "workstationKey") != key { + continue + } + result := copyMap(ws) + for _, f := range workstationInternalFields { + delete(result, f) + } + return result, nil + } + return nil, nil +} + +// resolveWorkstationID returns the UUID for a workstation by key. +func (p *Provider) resolveWorkstationID(ctx context.Context, key string) (string, error) { + if err := p.ensureWS(ctx); err != nil { + return "", fmt.Errorf("ws connect for workstations: %w", err) + } + + payload, err := p.ws.Call(ctx, "workstations.list", nil) + if err != nil { + return "", fmt.Errorf("workstations.list: %w", err) + } + + var resp struct { + Workstations []map[string]any `json:"workstations"` + } + if err := json.Unmarshal(payload, &resp); err != nil { + return "", fmt.Errorf("parse workstations.list response: %w", err) + } + + for _, ws := range resp.Workstations { + if strVal(ws, "workstationKey") == key { + if id := strVal(ws, "id"); id != "" { + return id, nil + } + } + } + return "", fmt.Errorf("workstation %q not found", key) +} + +// createWorkstation creates a workstation via WS RPC then reconciles its +// allowlist and agent links. +// +// Spec layout: +// +// displayName, backendType, defaultCwd — top-level columns. +// host/port/user/privateKey/knownHostsFingerprint — folded into metadata{} object. +// allowlist: []string → workstations.permissions.add per entry. +// agents: []string → agentKey → UUID → workstations.linkAgent per entry. +func (p *Provider) createWorkstation(ctx context.Context, key string, spec map[string]any) error { + if err := p.ensureWS(ctx); err != nil { + return fmt.Errorf("ws connect for workstations: %w", err) + } + + params := buildWorkstationCreateParams(key, spec) + if _, err := p.ws.Call(ctx, "workstations.create", params); err != nil { + return fmt.Errorf("workstations.create %s: %w", key, err) + } + + wsID, err := p.resolveWorkstationID(ctx, key) + if err != nil { + return fmt.Errorf("workstation %s: resolve after create: %w", key, err) + } + + if err := p.reconcileAllowlist(ctx, wsID, nil, extractStringSlice(spec, "allowlist")); err != nil { + return fmt.Errorf("workstation %s: reconcile allowlist: %w", key, err) + } + if err := p.reconcileWorkstationAgentLinks(ctx, wsID, nil, extractStringSlice(spec, "agents")); err != nil { + return fmt.Errorf("workstation %s: reconcile agent links: %w", key, err) + } + + return nil +} + +// updateWorkstation patches a workstation and reconciles its allowlist and agent links. +func (p *Provider) updateWorkstation(ctx context.Context, key string, spec map[string]any) error { + if err := p.ensureWS(ctx); err != nil { + return fmt.Errorf("ws connect for workstations: %w", err) + } + + wsID, err := p.resolveWorkstationID(ctx, key) + if err != nil { + return fmt.Errorf("workstation %s not found for update: %w", key, err) + } + + patch := buildWorkstationUpdatePatch(spec) + if len(patch) > 0 { + if _, err := p.ws.Call(ctx, "workstations.update", map[string]any{ + "id": wsID, + "updates": patch, + }); err != nil { + return fmt.Errorf("workstations.update %s: %w", key, err) + } + } + + currentPerms, err := p.listWorkstationPermissions(ctx, wsID) + if err != nil { + return fmt.Errorf("workstation %s: list permissions: %w", key, err) + } + if err := p.reconcileAllowlist(ctx, wsID, currentPerms, extractStringSlice(spec, "allowlist")); err != nil { + return fmt.Errorf("workstation %s: reconcile allowlist: %w", key, err) + } + + // GoClaw's SanitizedView does not expose linked agents, so we cannot fetch + // the current link set; pass nil → reconcileWorkstationAgentLinks skips unlink. + // This means update only adds missing links, never removes surplus ones. + // Full bidirectional sync requires a workstations.links.list RPC (not yet in goclaw). + if err := p.reconcileWorkstationAgentLinks(ctx, wsID, nil, extractStringSlice(spec, "agents")); err != nil { + return fmt.Errorf("workstation %s: reconcile agent links: %w", key, err) + } + + return nil +} + +// deleteWorkstation removes a workstation by key via WS RPC. Idempotent. +func (p *Provider) deleteWorkstation(ctx context.Context, key string) error { + if err := p.ensureWS(ctx); err != nil { + return fmt.Errorf("ws connect for workstations: %w", err) + } + + wsID, err := p.resolveWorkstationID(ctx, key) + if err != nil { + if strings.Contains(err.Error(), "not found") { + return nil + } + return fmt.Errorf("workstation %s: resolve for delete: %w", key, err) + } + + if _, err := p.ws.Call(ctx, "workstations.delete", map[string]any{"id": wsID}); err != nil { + return fmt.Errorf("workstations.delete %s: %w", key, err) + } + return nil +} + +// --- allowlist reconciliation --- + +type permEntry struct { + id string + pattern string +} + +func (p *Provider) listWorkstationPermissions(ctx context.Context, wsID string) ([]permEntry, error) { + payload, err := p.ws.Call(ctx, "workstations.permissions.list", map[string]any{ + "workstationId": wsID, + }) + if err != nil { + return nil, fmt.Errorf("workstations.permissions.list: %w", err) + } + var resp struct { + Permissions []struct { + ID string `json:"id"` + Pattern string `json:"pattern"` + } `json:"permissions"` + } + if err := json.Unmarshal(payload, &resp); err != nil { + return nil, fmt.Errorf("parse permissions.list response: %w", err) + } + entries := make([]permEntry, len(resp.Permissions)) + for i, pe := range resp.Permissions { + entries[i] = permEntry{id: pe.ID, pattern: pe.Pattern} + } + return entries, nil +} + +// reconcileAllowlist adds/removes permission patterns to match desired. +// current == nil means we have no current state (create path); skip removes. +func (p *Provider) reconcileAllowlist(ctx context.Context, wsID string, current []permEntry, desired []string) error { + currentSet := make(map[string]string, len(current)) // pattern → id + for _, e := range current { + currentSet[e.pattern] = e.id + } + desiredSet := make(map[string]bool, len(desired)) + for _, d := range desired { + desiredSet[d] = true + } + + for _, d := range desired { + if _, exists := currentSet[d]; !exists { + if _, err := p.ws.Call(ctx, "workstations.permissions.add", map[string]any{ + "workstationId": wsID, + "pattern": d, + }); err != nil { + return fmt.Errorf("add permission %q: %w", d, err) + } + } + } + + if current == nil { + return nil + } + for pattern, id := range currentSet { + if !desiredSet[pattern] { + if _, err := p.ws.Call(ctx, "workstations.permissions.remove", map[string]any{ + "id": id, + }); err != nil { + return fmt.Errorf("remove permission %q: %w", pattern, err) + } + } + } + return nil +} + +// --- agent link reconciliation --- + +// reconcileWorkstationAgentLinks links agents by key to the workstation. +// current == nil → only adds (no unlinks); non-nil → full add+remove diff. +func (p *Provider) reconcileWorkstationAgentLinks(ctx context.Context, wsID string, current []string, desired []string) error { + currentSet := make(map[string]bool, len(current)) + for _, k := range current { + currentSet[k] = true + } + desiredSet := make(map[string]bool, len(desired)) + for _, k := range desired { + desiredSet[k] = true + } + + for _, agentKey := range desired { + if currentSet[agentKey] { + continue + } + agentID, err := p.resolveAgentID(ctx, agentKey) + if err != nil { + return fmt.Errorf("resolve agent %q for link: %w", agentKey, err) + } + if _, err := p.ws.Call(ctx, "workstations.linkAgent", map[string]any{ + "workstationId": wsID, + "agentId": agentID, + "isDefault": false, + }); err != nil { + return fmt.Errorf("linkAgent %s → %s: %w", agentKey, wsID, err) + } + } + + if current == nil { + return nil + } + for _, agentKey := range current { + if desiredSet[agentKey] { + continue + } + agentID, err := p.resolveAgentID(ctx, agentKey) + if err != nil { + return fmt.Errorf("resolve agent %q for unlink: %w", agentKey, err) + } + if _, err := p.ws.Call(ctx, "workstations.unlinkAgent", map[string]any{ + "workstationId": wsID, + "agentId": agentID, + }); err != nil { + return fmt.Errorf("unlinkAgent %s → %s: %w", agentKey, wsID, err) + } + } + return nil +} + +// --- spec translation helpers --- + +// buildWorkstationCreateParams maps a manifest spec to workstations.create RPC params. +// SSH connection fields (host/port/user/privateKey/knownHostsFingerprint) are lifted +// from spec top-level into the nested metadata object the RPC expects. +func buildWorkstationCreateParams(key string, spec map[string]any) map[string]any { + params := map[string]any{ + "workstationKey": key, + "createdBy": "gcplane", + } + if v, ok := spec["displayName"]; ok { + params["name"] = v + } + if v, ok := spec["backendType"]; ok { + params["backendType"] = v + } + if v, ok := spec["defaultCwd"]; ok { + params["defaultCwd"] = v + } + + meta := map[string]any{} + for _, f := range []string{"host", "port", "user", "privateKey", "knownHostsFingerprint", "connectTimeoutSec"} { + if v, ok := spec[f]; ok { + meta[f] = v + } + } + if len(meta) > 0 { + params["metadata"] = meta + } + return params +} + +// buildWorkstationUpdatePatch builds the workstations.update `updates` map. +// Strips manifest-only fields (allowlist/agents/SSH secrets). +func buildWorkstationUpdatePatch(spec map[string]any) map[string]any { + // Manifest keys explicitly handled or intentionally excluded. + skipKeys := map[string]bool{ + "allowlist": true, "agents": true, + // Renamed: displayName → name (handled above) + "displayName": true, + // Scalar top-level fields already set above + "backendType": true, "defaultCwd": true, + } + sshMetaKeys := map[string]bool{ + "host": true, "port": true, "user": true, + "privateKey": true, "knownHostsFingerprint": true, "connectTimeoutSec": true, + } + patch := map[string]any{} + + if v, ok := spec["displayName"]; ok { + patch["name"] = v + } + if v, ok := spec["backendType"]; ok { + patch["backendType"] = v + } + if v, ok := spec["defaultCwd"]; ok { + patch["defaultCwd"] = v + } + + meta := map[string]any{} + for f := range sshMetaKeys { + if v, ok := spec[f]; ok { + meta[f] = v + } + } + if len(meta) > 0 { + patch["metadata"] = meta + } + + for k, v := range spec { + if skipKeys[k] || sshMetaKeys[k] { + continue + } + if _, handled := patch[k]; handled { + continue + } + patch[k] = v + } + return patch +} + +// extractStringSlice safely reads a []string from a map[string]any spec field. +func extractStringSlice(spec map[string]any, key string) []string { + raw, ok := spec[key] + if !ok { + return nil + } + list, ok := raw.([]any) + if !ok { + return nil + } + out := make([]string, 0, len(list)) + for _, v := range list { + if s, ok := v.(string); ok { + out = append(out, s) + } + } + return out +} diff --git a/internal/reconciler/engine_test.go b/internal/reconciler/engine_test.go index 8f53800..86a0ee3 100644 --- a/internal/reconciler/engine_test.go +++ b/internal/reconciler/engine_test.go @@ -963,3 +963,84 @@ func TestReconcile_HashMatchesAfterAutoHeal(t *testing.T) { t.Fatalf("expected noop on hash match (post-heal steady state), got noops=%d updates=%d", plan.Noops, plan.Updates) } } + +// pruneMockProvider extends mockProvider with ListAll results and delete tracking. +type pruneMockProvider struct { + mockProvider + listAllByKind map[manifest.ResourceKind][]ResourceInfo + deleted []string +} + +func (p *pruneMockProvider) ListAll(_ context.Context, kind manifest.ResourceKind) ([]ResourceInfo, error) { + return p.listAllByKind[kind], nil +} + +func (p *pruneMockProvider) Delete(_ context.Context, kind manifest.ResourceKind, key string) error { + p.deleted = append(p.deleted, string(kind)+"/"+key) + return nil +} + +// TestReconcile_Prune_SkipsNonGcplaneOwned verifies that prune never deletes a +// resource whose CreatedBy != "gcplane", even when it is absent from the manifest. +func TestReconcile_Prune_SkipsNonGcplaneOwned(t *testing.T) { + mp := &pruneMockProvider{ + mockProvider: *newMockProvider(), + listAllByKind: map[manifest.ResourceKind][]ResourceInfo{ + manifest.KindWorkstation: { + {Kind: manifest.KindWorkstation, Name: "coding-agent", CreatedBy: "vanducng"}, + {Kind: manifest.KindWorkstation, Name: "gcplane-ws", CreatedBy: "gcplane"}, + }, + }, + } + // gcplane-ws is in manifest; coding-agent is not — but must not be deleted. + mp.observed["Workstation/gcplane-ws"] = map[string]any{"name": "GCPlane WS", "backendType": "ssh"} + + engine := NewEngine(mp, nil) + m := &manifest.Manifest{ + Resources: []manifest.Resource{ + {Kind: manifest.KindWorkstation, Name: "gcplane-ws", Spec: map[string]any{ + "name": "GCPlane WS", "backendType": "ssh", + }}, + }, + } + + _, _ = engine.Reconcile(context.Background(), m, ReconcileOpts{Prune: true}) + + for _, d := range mp.deleted { + if d == "Workstation/coding-agent" { + t.Fatal("prune must not delete non-gcplane-owned workstation coding-agent") + } + } +} + +// TestReconcile_Workstation_AdoptExisting verifies that a workstation created +// imperatively (created_by != gcplane) is adopted (Update path) when it appears +// in the manifest — no duplicate Create is issued. +func TestReconcile_Workstation_AdoptExisting(t *testing.T) { + provider := newMockProvider() + // Simulate an existing imperatively-created workstation. + provider.observed["Workstation/coding-agent"] = map[string]any{ + "workstationKey": "coding-agent", + "name": "Coding Agent", + "backendType": "ssh", + "defaultCwd": "/workspace", + } + + engine := NewEngine(provider, nil) + m := &manifest.Manifest{ + Resources: []manifest.Resource{ + {Kind: manifest.KindWorkstation, Name: "coding-agent", Spec: map[string]any{ + "displayName": "Coding Agent Updated", + "backendType": "ssh", + "defaultCwd": "/workspace", + }}, + }, + } + + plan, result := engine.Reconcile(context.Background(), m, ReconcileOpts{}) + if len(provider.created) > 0 { + t.Fatalf("must not Create an already-existing workstation, got creates: %v", provider.created) + } + _ = plan + _ = result +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 6fe37ff..ac74f24 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -721,6 +721,8 @@ var kindAliases = map[string]manifest.ResourceKind{ "securecli": manifest.KindSecureCLI, "link": manifest.KindAgentLink, "agentlink": manifest.KindAgentLink, + "workstation": manifest.KindWorkstation, + "ws": manifest.KindWorkstation, } // executeCommand processes : commands (kind switching, quit, etc.)