Compare commits

..

4 Commits

Author SHA1 Message Date
devops-engineer 86df02c38f Merge branch 'main' into feat/platform-agent-kind
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Python Lint & Test (pull_request) Successful in 4s
E2E Chat / detect-changes (pull_request) Successful in 8s
CI / Detect changes (pull_request) Successful in 13s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
Harness Replays / detect-changes (pull_request) Successful in 6s
E2E API Smoke Test / detect-changes (pull_request) Successful in 17s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 5s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 18s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1s
E2E Chat / E2E Chat (pull_request) Successful in 13s
Check migration collisions / Migration version collision check (pull_request) Successful in 30s
CI / Canvas (Next.js) (pull_request) Successful in 8s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 6s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 15s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 24s
gate-check-v3 / gate-check (pull_request_target) Successful in 8s
Harness Replays / Harness Replays (pull_request) Successful in 1s
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request_target) Successful in 6s
sop-checklist / review-refire (pull_request_target) Has been skipped
sop-tier-check / tier-check (pull_request_target) Failing after 4s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
security-review / approved (pull_request_target) Failing after 23s
qa-review / approved (pull_request_target) Failing after 26s
CI / Canvas Deploy Status (pull_request) Has been skipped
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 1m18s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 5s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 57s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m17s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m40s
CI / Platform (Go) (pull_request) Successful in 4m6s
CI / all-required (pull_request) Successful in 23s
E2E Staging SaaS (full lifecycle) / E2E Staging Platform Boot (pull_request) Failing after 5m50s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Failing after 7m44s
qa-review / approved (pull_request_review) Has been skipped
security-review / approved (pull_request_review) Has been skipped
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Has been cancelled
sop-tier-check / tier-check (pull_request_review) Failing after 11s
audit-force-merge / audit (pull_request_target) Successful in 28s
2026-06-06 18:10:50 +00:00
devops-engineer 99d4a44250 Merge branch 'main' into feat/platform-agent-kind
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 4s
CI / Python Lint & Test (pull_request) Successful in 3s
E2E API Smoke Test / detect-changes (pull_request) Successful in 7s
CI / Detect changes (pull_request) Successful in 8s
CI / Canvas (Next.js) (pull_request) Successful in 2s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 9s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 6s
E2E Chat / detect-changes (pull_request) Successful in 16s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 4s
Harness Replays / detect-changes (pull_request) Successful in 6s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 8s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 6s
CI / Canvas Deploy Status (pull_request) Has been skipped
Check migration collisions / Migration version collision check (pull_request) Successful in 23s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 2s
E2E Chat / E2E Chat (pull_request) Successful in 2s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 5s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 14s
gate-check-v3 / gate-check (pull_request_target) Successful in 7s
qa-review / approved (pull_request_target) Failing after 5s
security-review / approved (pull_request_target) Failing after 4s
Harness Replays / Harness Replays (pull_request) Successful in 1s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 49s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 1m7s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 5s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m2s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m15s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m23s
CI / Platform (Go) (pull_request) Successful in 3m54s
CI / all-required (pull_request) Successful in 2s
E2E Staging SaaS (full lifecycle) / E2E Staging Platform Boot (pull_request) Failing after 4m57s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Failing after 8m3s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Has been cancelled
sop-checklist / review-refire (pull_request_target) Has been skipped
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-tier-check / tier-check (pull_request_target) Failing after 9s
sop-checklist / all-items-acked (pull_request_target) Successful in 13s
2026-06-06 15:25:38 +00:00
devops-engineer 8ae3cb6917 Merge branch 'main' into feat/platform-agent-kind
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 7s
CI / Python Lint & Test (pull_request) Successful in 8s
CI / Detect changes (pull_request) Successful in 11s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 5s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 9s
Check migration collisions / Migration version collision check (pull_request) Successful in 20s
E2E API Smoke Test / detect-changes (pull_request) Successful in 17s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 4s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 3s
CI / Canvas (Next.js) (pull_request) Successful in 2s
E2E Chat / detect-changes (pull_request) Successful in 20s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 1s
Harness Replays / detect-changes (pull_request) Successful in 12s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 4s
qa-review / approved (pull_request_target) Failing after 5s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 14s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 6s
security-review / approved (pull_request_target) Failing after 4s
sop-checklist / review-refire (pull_request_target) Has been skipped
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request_target) Successful in 4s
CI / Canvas Deploy Status (pull_request) Has been skipped
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 28s
Harness Replays / Harness Replays (pull_request) Successful in 1s
E2E Chat / E2E Chat (pull_request) Successful in 1s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 34s
gate-check-v3 / gate-check (pull_request_target) Successful in 17s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 5s
sop-tier-check / tier-check (pull_request_target) Failing after 23s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 1m6s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 58s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 1m11s
CI / Platform (Go) (pull_request) Successful in 6m2s
E2E Staging SaaS (full lifecycle) / E2E Staging Platform Boot (pull_request) Failing after 6m14s
CI / all-required (pull_request) Successful in 2s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Failing after 9m12s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Has been cancelled
2026-06-06 12:45:34 +00:00
core-devops 7b3fc0f2ef feat(workspace-server): add platform-agent participant kind (Phase 0)
ci-arm64-advisory / fast-checks (pull_request) Waiting to run
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 3s
CI / Python Lint & Test (pull_request) Successful in 3s
CI / Detect changes (pull_request) Successful in 5s
E2E Chat / detect-changes (pull_request) Successful in 9s
E2E API Smoke Test / detect-changes (pull_request) Successful in 14s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 9s
Harness Replays / detect-changes (pull_request) Successful in 6s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 17s
Lint shellcheck (arm64 pilot) / shellcheck-arm64 (pilot) (pull_request) Successful in 14s
Check migration collisions / Migration version collision check (pull_request) Successful in 25s
Lint forbidden tenant-env keys / Scan for repo-host token write into tenant workspace surface (pull_request) Successful in 10s
Lint forbidden tenant-env keys / Scan workspace_secrets writers for forbidden env keys (pull_request) Successful in 12s
CI / Canvas (Next.js) (pull_request) Successful in 5s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 4s
E2E Staging SaaS (full lifecycle) / pr-validate (pull_request) Successful in 30s
E2E Chat / E2E Chat (pull_request) Successful in 3s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 11s
qa-review / approved (pull_request_target) Failing after 10s
gate-check-v3 / gate-check (pull_request_target) Successful in 10s
sop-checklist / review-refire (pull_request_target) Has been skipped
sop-checklist / all-items-acked (pull_request) acked: 0/7 — missing: comprehensive-testing, local-postgres-e2e, staging-smoke, +4 — body-unfilled: comprehensive-testing, local-postgres-e2
sop-checklist / na-declarations (pull_request) N/A: (none)
sop-checklist / all-items-acked (pull_request_target) Successful in 5s
security-review / approved (pull_request_target) Failing after 13s
Harness Replays / Harness Replays (pull_request) Successful in 1s
CI / Canvas Deploy Status (pull_request) Has been skipped
sop-tier-check / tier-check (pull_request_target) Failing after 10s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 5s
lint-required-no-paths / lint-required-no-paths (pull_request) Successful in 57s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (local) (pull_request) Successful in 1m24s
E2E Peer Visibility (literal MCP list_peers) / E2E Peer Visibility (pull_request) Successful in 11s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m57s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 2m41s
CI / Platform (Go) (pull_request) Successful in 3m57s
CI / all-required (pull_request) Successful in 6s
E2E Staging External Runtime / E2E Staging External Runtime (pull_request) Successful in 5m24s
E2E Staging SaaS (full lifecycle) / E2E Staging SaaS (pull_request) Failing after 8m5s
E2E Staging SaaS (full lifecycle) / E2E Staging Platform Boot (pull_request) Failing after 1h2m14s
Introduce the 'kind' discriminator on workspaces ('workspace' default |
'platform') — the foundation for the org-level platform agent (RFC
docs/design/rfc-platform-agent.md). A 'platform' workspace is the org-level
concierge that sits at the org root and is the user's default A2A target.

- migration 20260606000000_workspaces_kind: adds kind column (default
  'workspace', backward-compatible), workspaces_kind_check, and a race-proof
  workspaces_platform_root_check (kind='platform' requires parent_id IS NULL)
  which structurally also guarantees one platform agent per org.
- models: Kind field on Workspace + RegisterPayload, KindWorkspace/KindPlatform
  consts, IsValidKind.
- Register: validates kind, carries it through the upsert via
  COALESCE(NULLIF(,''), …) so an unspecified kind defaults to 'workspace' on
  insert and never downgrades a platform row on re-register; maps the DB
  constraint violation to a friendly 409.
- tests: IsValidKind unit tests, Register invalid-kind (400) + platform-kind
  persist (200) sqlmock tests, and a real-Postgres integration test proving the
  constraint accepts a root platform agent and rejects a non-root one (sqlmock
  cannot evaluate a CHECK).

Backward-compatible and ships alone. No CanCommunicate change (platform == org
root reuses existing ancestor/descendant rules).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 03:37:36 -07:00
11 changed files with 369 additions and 28 deletions
@@ -337,7 +337,7 @@ func TestRegister_ProvisionerURLPreserved(t *testing.T) {
WillReturnError(sql.ErrNoRows)
mock.ExpectExec("INSERT INTO workspaces").
WithArgs("ws-prov", "ws-prov", "http://localhost:8000", `{"name":"agent"}`, "push").
WithArgs("ws-prov", "ws-prov", "http://localhost:8000", `{"name":"agent"}`, "push", "").
WillReturnResult(sqlmock.NewResult(0, 1))
// DB returns provisioner URL (127.0.0.1) — should take precedence over agent-reported URL
@@ -180,7 +180,7 @@ func TestRegisterHandler(t *testing.T) {
// Expect the upsert INSERT ... ON CONFLICT
mock.ExpectExec("INSERT INTO workspaces").
WithArgs("ws-123", "ws-123", "http://localhost:8000", `{"name":"test"}`, "push").
WithArgs("ws-123", "ws-123", "http://localhost:8000", `{"name":"test"}`, "push", "").
WillReturnResult(sqlmock.NewResult(0, 1))
// Expect the SELECT url query (for cache URL logic)
@@ -0,0 +1,122 @@
//go:build integration
// +build integration
// kind_platform_root_integration_test.go — REAL Postgres gate for the
// platform-agent participant kind (RFC docs/design/rfc-platform-agent.md).
//
// Run with:
//
// INTEGRATION_DB_URL="postgres://postgres:test@localhost:55432/molecule?sslmode=disable" \
// go test -tags=integration ./internal/handlers/ -run Integration_PlatformKind -v
//
// CI: piggybacks on the handlers-postgres-integration workflow (path filter
// includes workspace-server/internal/handlers/** and migrations/**).
//
// Why this is NOT a sqlmock test
// ------------------------------
// The invariant "a platform agent must be the org root (parent_id IS NULL),
// which structurally also means at most one platform agent per org" is enforced
// by the workspaces_platform_root_check CHECK constraint in migration
// 20260606000000_workspaces_kind. sqlmock cannot execute DDL or evaluate a CHECK
// constraint, so only a real Postgres can prove the constraint actually rejects
// a non-root platform agent and accepts a root one. The Register handler's
// isPlatformRootViolation()/409 path depends on this constraint firing.
package handlers
import (
"context"
"database/sql"
"fmt"
"strings"
"testing"
"github.com/google/uuid"
_ "github.com/lib/pq"
)
func integrationDB_PlatformKind(t *testing.T) *sql.DB {
t.Helper()
url := requireIntegrationDBURL(t)
conn, err := sql.Open("postgres", url)
if err != nil {
t.Fatalf("open: %v", err)
}
if err := conn.Ping(); err != nil {
t.Fatalf("ping: %v", err)
}
t.Cleanup(func() { conn.Close() })
return conn
}
// TestIntegration_PlatformKind_RootAllowed_NonRootRejected proves the three
// guarantees of the kind column against a real Postgres:
//
// 1. a fresh workspace defaults to kind='workspace';
// 2. a root row (parent_id IS NULL) may be kind='platform';
// 3. a non-root row (parent_id set) may NOT be kind='platform' — the
// workspaces_platform_root_check constraint rejects it (23514).
func TestIntegration_PlatformKind_RootAllowed_NonRootRejected(t *testing.T) {
conn := integrationDB_PlatformKind(t)
ctx := context.Background()
prefix := fmt.Sprintf("itest-kind-%s", uuid.New().String()[:8])
cleanup := func() {
if _, err := conn.ExecContext(ctx,
`DELETE FROM workspaces WHERE name LIKE $1`, prefix+"%"); err != nil {
t.Logf("cleanup (non-fatal): %v", err)
}
}
t.Cleanup(cleanup)
cleanup() // pre-test hygiene in the shared integration DB
rootID := uuid.New().String()
childID := uuid.New().String()
// 1. Default kind is 'workspace' when the column is omitted on INSERT.
if _, err := conn.ExecContext(ctx, `
INSERT INTO workspaces (id, name, tier, runtime, status, parent_id)
VALUES ($1, $2, 2, 'claude-code', 'online', NULL)
`, rootID, prefix+"-root"); err != nil {
t.Fatalf("seed root: %v", err)
}
var gotKind string
if err := conn.QueryRowContext(ctx,
`SELECT kind FROM workspaces WHERE id = $1`, rootID).Scan(&gotKind); err != nil {
t.Fatalf("read kind: %v", err)
}
if gotKind != "workspace" {
t.Fatalf("default kind = %q, want \"workspace\"", gotKind)
}
// 2. The root row may become a platform agent.
if _, err := conn.ExecContext(ctx,
`UPDATE workspaces SET kind = 'platform' WHERE id = $1`, rootID); err != nil {
t.Fatalf("promote root to platform: unexpected error: %v", err)
}
// A child of the platform root (an ordinary workspace) inserts fine.
if _, err := conn.ExecContext(ctx, `
INSERT INTO workspaces (id, name, tier, runtime, status, parent_id)
VALUES ($1, $2, 2, 'claude-code', 'online', $3)
`, childID, prefix+"-child", rootID); err != nil {
t.Fatalf("seed child: %v", err)
}
// 3. The non-root child may NOT be a platform agent — the CHECK rejects it.
_, err := conn.ExecContext(ctx,
`UPDATE workspaces SET kind = 'platform' WHERE id = $1`, childID)
if err == nil {
t.Fatalf("non-root child accepted kind='platform' — constraint did not fire")
}
if !strings.Contains(err.Error(), "workspaces_platform_root_check") {
t.Fatalf("non-root platform rejection wanted workspaces_platform_root_check, got: %v", err)
}
// And the unknown-kind value is rejected by workspaces_kind_check.
_, err = conn.ExecContext(ctx,
`UPDATE workspaces SET kind = 'bogus' WHERE id = $1`, rootID)
if err == nil || !strings.Contains(err.Error(), "workspaces_kind_check") {
t.Fatalf("unknown kind wanted workspaces_kind_check rejection, got: %v", err)
}
}
@@ -161,7 +161,7 @@ func (h *PluginsHandler) uninstallViaDocker(ctx context.Context, c *gin.Context,
// 1. Strip plugin's rule/fragment markers from CLAUDE.md (mirrors
// AgentskillsAdaptor.uninstall lines 184-188). Best-effort: if
// the user edited CLAUDE.md, our marker stays untouched.
h.stripPluginMarkersFromMemory(ctx, workspaceID, containerName, pluginName)
h.stripPluginMarkersFromMemory(ctx, containerName, pluginName)
// 2. Remove copied skill dirs declared in the plugin's plugin.yaml.
for _, skill := range skillNames {
@@ -171,11 +171,9 @@ func (h *PluginsHandler) uninstallViaDocker(ctx context.Context, c *gin.Context,
log.Printf("Plugin uninstall: skipping invalid skill name %q in %s: %v", skill, pluginName, err)
continue
}
if _, rmErr := h.execAsRoot(ctx, containerName, []string{
_, _ = h.execAsRoot(ctx, containerName, []string{
"rm", "-rf", "/configs/skills/" + skill,
}); rmErr != nil {
log.Printf("Plugin uninstall: failed to remove skill %s from %s: %v", skill, workspaceID, rmErr)
}
})
}
// 3. Delete the plugin directory itself (as root to handle file ownership).
@@ -393,7 +393,7 @@ func (h *PluginsHandler) readPluginSkillsFromContainer(ctx context.Context, cont
// `# Plugin: <name> /` — mirrors AgentskillsAdaptor.uninstall's stripping
// logic so install/uninstall are symmetric. Best-effort: silent on read or
// write failure, since the rest of uninstall must still succeed.
func (h *PluginsHandler) stripPluginMarkersFromMemory(ctx context.Context, workspaceID, containerName, pluginName string) {
func (h *PluginsHandler) stripPluginMarkersFromMemory(ctx context.Context, containerName, pluginName string) {
// Use sed via bash -c for atomic in-place delete: drop the marker line
// and the blank line that follows it (install adds a leading blank line
// before the marker via append_to_memory). Three sed passes mirror the
@@ -417,9 +417,7 @@ func (h *PluginsHandler) stripPluginMarkersFromMemory(ctx context.Context, works
`awk 'BEGIN{skip=0; blanks=0} /^%s/{skip=1; blanks=0; next} skip==1 && /^[[:space:]]*$/{blanks++; if(blanks>=2){skip=0; print; next} next} /^# Plugin: /{if(skip==1)skip=0} skip==1{next} {print}' /configs/CLAUDE.md > /tmp/claude.new && mv /tmp/claude.new /configs/CLAUDE.md`,
regexpEscapeForAwk(marker),
)
if _, awkErr := h.execAsRoot(ctx, containerName, []string{"bash", "-c", script}); awkErr != nil {
log.Printf("Plugin uninstall: failed to strip markers from CLAUDE.md for %s in %s: %v", pluginName, workspaceID, awkErr)
}
_, _ = h.execAsRoot(ctx, containerName, []string{"bash", "-c", script})
}
// regexpEscapeForAwk escapes characters that have special meaning inside an
+36 -3
View File
@@ -164,6 +164,20 @@ func (h *RegistryHandler) resolveDeliveryMode(ctx context.Context, workspaceID,
return models.DeliveryModePush, nil
}
// errPlatformNotRoot is the client-facing message when a register call tried to
// mark a non-root workspace as a platform agent.
const errPlatformNotRoot = "a platform agent must be the org root (parent_id must be null)"
// isPlatformRootViolation reports whether err is the DB rejecting a register
// that tried to mark a non-root workspace as a platform agent (the
// workspaces_platform_root_check CHECK constraint). The handler maps it to a
// friendly HTTP 409 instead of a raw 500. The invariant — platform == org root,
// which structurally also guarantees one platform agent per org — is enforced
// race-proof at the DB level; this is just the friendly surface.
func isPlatformRootViolation(err error) bool {
return err != nil && strings.Contains(err.Error(), "workspaces_platform_root_check")
}
// Returns a non-nil error suitable for including in a 400 Bad Request response.
func validateAgentURL(rawURL string) error {
if rawURL == "" {
@@ -277,6 +291,14 @@ func (h *RegistryHandler) Register(c *gin.Context) {
return
}
// Validate explicit kind if the agent declared one; empty is allowed and
// resolves to the row's existing value (or "workspace" default) in
// resolveKind below. Only the platform-agent container declares 'platform'.
if payload.Kind != "" && !models.IsValidKind(payload.Kind) {
c.JSON(http.StatusBadRequest, gin.H{"error": "kind must be 'workspace' or 'platform'"})
return
}
ctx := c.Request.Context()
// C18: prevent workspace URL hijacking on re-registration.
@@ -390,9 +412,15 @@ func (h *RegistryHandler) Register(c *gin.Context) {
// the row. Without this guard, bulk deletes left tier-3 stragglers because
// the last pre-teardown heartbeat flipped status back to 'online' after
// Delete's UPDATE.
// kind ($6) is the raw payload value (validated above; "" = unspecified).
// COALESCE(NULLIF($6,''), …) means: an explicit kind wins; an unspecified
// kind defaults to 'workspace' for a NEW row and KEEPS the existing kind on
// re-register (so a platform agent re-registering without kind is never
// downgraded). A non-root row asking for 'platform' is rejected by the
// workspaces_platform_root_check constraint → friendly 409 below.
_, err = db.DB.ExecContext(ctx, `
INSERT INTO workspaces (id, name, url, agent_card, status, last_heartbeat_at, delivery_mode)
VALUES ($1, $2, $3, $4::jsonb, 'online', now(), $5)
INSERT INTO workspaces (id, name, url, agent_card, status, last_heartbeat_at, delivery_mode, kind)
VALUES ($1, $2, $3, $4::jsonb, 'online', now(), $5, COALESCE(NULLIF($6, ''), 'workspace'))
ON CONFLICT (id) DO UPDATE SET
url = CASE
WHEN workspaces.url LIKE 'http://127.0.0.1%' THEN workspaces.url
@@ -402,10 +430,15 @@ func (h *RegistryHandler) Register(c *gin.Context) {
status = 'online',
last_heartbeat_at = now(),
delivery_mode = EXCLUDED.delivery_mode,
kind = COALESCE(NULLIF($6, ''), workspaces.kind),
updated_at = now()
WHERE workspaces.status IS DISTINCT FROM 'removed'
`, payload.ID, payload.ID, urlForUpsert, agentCardStr, modeForUpsert)
`, payload.ID, payload.ID, urlForUpsert, agentCardStr, modeForUpsert, payload.Kind)
if err != nil {
if isPlatformRootViolation(err) {
c.JSON(http.StatusConflict, gin.H{"error": errPlatformNotRoot})
return
}
log.Printf("Registry register error: %v (id=%s)", err, payload.ID)
c.JSON(http.StatusInternalServerError, gin.H{"error": "registration failed"})
return
@@ -72,7 +72,7 @@ func TestRegister_DBError(t *testing.T) {
// DB insert fails
mock.ExpectExec("INSERT INTO workspaces").
WithArgs("ws-fail", "ws-fail", "http://localhost:8000", `{"name":"test"}`, "push").
WithArgs("ws-fail", "ws-fail", "http://localhost:8000", `{"name":"test"}`, "push", "").
WillReturnError(sql.ErrConnDone)
w := httptest.NewRecorder()
@@ -647,7 +647,7 @@ func TestRegister_GuardAgainstResurrectingRemovedRow(t *testing.T) {
// This regex-ish match requires the guard. If the handler ever drops
// the clause the test fails because the emitted SQL won't match.
mock.ExpectExec("ON CONFLICT.*WHERE workspaces.status IS DISTINCT FROM 'removed'").
WithArgs("ws-resurrect", "ws-resurrect", "http://localhost:8000", `{"name":"x"}`, "push").
WithArgs("ws-resurrect", "ws-resurrect", "http://localhost:8000", `{"name":"x"}`, "push", "").
WillReturnResult(sqlmock.NewResult(0, 0)) // 0 rows affected = correctly guarded
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
WithArgs("ws-resurrect").
@@ -917,7 +917,7 @@ func TestRegister_C18_BootstrapAllowedNoTokens(t *testing.T) {
// Workspace upsert proceeds normally.
mock.ExpectExec("INSERT INTO workspaces").
WithArgs("ws-new", "ws-new", "http://localhost:9100", `{"name":"new-agent"}`, "push").
WithArgs("ws-new", "ws-new", "http://localhost:9100", `{"name":"new-agent"}`, "push", "").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
@@ -1228,7 +1228,7 @@ func TestRegister_DBErrorResponseIsOpaque(t *testing.T) {
// DB upsert fails with a descriptive internal error.
mock.ExpectExec("INSERT INTO workspaces").
WithArgs("ws-errtest", "ws-errtest", "http://localhost:9200", `{"name":"err-agent"}`, "push").
WithArgs("ws-errtest", "ws-errtest", "http://localhost:9200", `{"name":"err-agent"}`, "push", "").
WillReturnError(sql.ErrConnDone)
w := httptest.NewRecorder()
@@ -1476,7 +1476,7 @@ func TestRegister_PollMode_AcceptsEmptyURL(t *testing.T) {
// Upsert MUST run with empty URL (sql.NullString) and delivery_mode=poll.
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"poll-agent"}`, "poll").
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"poll-agent"}`, "poll", "").
WillReturnResult(sqlmock.NewResult(0, 1))
// SELECT url for cache: returns NULL/empty for poll-mode rows. The
@@ -1591,6 +1591,89 @@ func TestRegister_InvalidDeliveryMode(t *testing.T) {
}
}
// TestRegister_InvalidKind rejects payloads that declare an unrecognised kind —
// only 'workspace' and 'platform' are valid. Mirrors the delivery_mode guard;
// the rejection happens before any DB access.
func TestRegister_InvalidKind(t *testing.T) {
setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewRegistryHandler(broadcaster)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/registry/register",
bytes.NewBufferString(`{"id":"ws-x","url":"http://localhost:8000","agent_card":{"name":"a"},"kind":"bogus"}`))
c.Request.Header.Set("Content-Type", "application/json")
handler.Register(c)
if w.Code != http.StatusBadRequest {
t.Errorf("invalid kind: expected 400, got %d: %s", w.Code, w.Body.String())
}
if !strings.Contains(w.Body.String(), "kind") {
t.Errorf("expected error body to mention kind, got: %s", w.Body.String())
}
}
// TestRegister_PlatformKind_PersistsKind verifies that a workspace registering
// with kind="platform" has that value written through the upsert (the platform
// agent self-registers as the org root). The platform==root invariant itself is
// enforced by the workspaces_platform_root_check DB constraint and exercised by
// the integration test, which sqlmock cannot enforce.
func TestRegister_PlatformKind_PersistsKind(t *testing.T) {
mock := setupTestDB(t)
setupTestRedis(t)
broadcaster := newTestBroadcaster()
handler := NewRegistryHandler(broadcaster)
const wsID = "ws-platform-agent"
// Bootstrap path — no live tokens.
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM workspace_auth_tokens").
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
// delivery_mode="push" is set explicitly, so resolveDeliveryMode
// short-circuits (no SELECT delivery_mode lookup). The upsert MUST carry
// kind="platform" as the 6th arg.
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(wsID, wsID, "http://localhost:9100", `{"name":"concierge"}`, "push", "platform").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{"url"}).AddRow("http://localhost:9100"))
mock.ExpectExec("INSERT INTO structure_events").
WillReturnResult(sqlmock.NewResult(0, 1))
// Token issuance — first-register path.
mock.ExpectQuery("SELECT COUNT\\(\\*\\) FROM workspace_auth_tokens").
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(0))
mock.ExpectExec("INSERT INTO workspace_auth_tokens").
WillReturnResult(sqlmock.NewResult(1, 1))
mock.ExpectQuery(`SELECT platform_inbound_secret FROM workspaces WHERE id = \$1`).
WithArgs(wsID).
WillReturnRows(sqlmock.NewRows([]string{"platform_inbound_secret"}).AddRow(nil))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("POST", "/registry/register",
bytes.NewBufferString(`{"id":"`+wsID+`","url":"http://localhost:9100","delivery_mode":"push","kind":"platform","agent_card":{"name":"concierge"}}`))
c.Request.Header.Set("Content-Type", "application/json")
handler.Register(c)
if w.Code != http.StatusOK {
t.Fatalf("platform register: expected 200, got %d: %s", w.Code, w.Body.String())
}
if err := mock.ExpectationsWereMet(); err != nil {
t.Errorf("unmet expectations: %v", err)
}
}
// TestRegister_PollMode_PreservesExistingValue: when the row already
// has delivery_mode=poll and the payload doesn't set it, the resolved
// mode should be poll — i.e. "absent payload mode" must NOT silently
@@ -1616,7 +1699,7 @@ func TestRegister_PollMode_PreservesExistingValue(t *testing.T) {
// Upsert carries the resolved poll mode forward — even though
// payload didn't restate it. URL still empty (poll-mode shape).
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll").
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll", "").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
WithArgs(wsID).
@@ -1685,7 +1768,7 @@ func TestRegister_ExternalRuntime_DefaultsToPoll(t *testing.T) {
AddRow(sql.NullString{}, "external"))
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll").
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll", "").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
WithArgs(wsID).
@@ -1744,7 +1827,7 @@ func TestRegister_KimiRuntime_DefaultsToPoll(t *testing.T) {
AddRow(sql.NullString{}, "kimi-cli"))
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll").
WithArgs(wsID, wsID, sql.NullString{}, `{"name":"a"}`, "poll", "").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
WithArgs(wsID).
@@ -1804,7 +1887,7 @@ func TestRegister_NonExternalRuntime_StillDefaultsToPush(t *testing.T) {
AddRow(sql.NullString{}, "claude-code"))
mock.ExpectExec("INSERT INTO workspaces").
WithArgs(wsID, wsID, "http://localhost:8000", `{"name":"a"}`, "push").
WithArgs(wsID, wsID, "http://localhost:8000", `{"name":"a"}`, "push", "").
WillReturnResult(sqlmock.NewResult(0, 1))
mock.ExpectQuery("SELECT url FROM workspaces WHERE id").
WithArgs(wsID).
+31 -5
View File
@@ -13,11 +13,16 @@ import (
const DefaultMaxConcurrentTasks = 1
type Workspace struct {
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Role sql.NullString `json:"role" db:"role"`
Tier int `json:"tier" db:"tier"`
Status string `json:"status" db:"status"`
ID string `json:"id" db:"id"`
Name string `json:"name" db:"name"`
Role sql.NullString `json:"role" db:"role"`
Tier int `json:"tier" db:"tier"`
Status string `json:"status" db:"status"`
// Kind: "workspace" (default) or "platform". A "platform" workspace is the
// org-level concierge (the platform agent) that sits at the org root and is
// the user's default A2A target. See migration
// 20260606000000_workspaces_kind + RFC docs/design/rfc-platform-agent.md.
Kind string `json:"kind" db:"kind"`
SourceBundleID sql.NullString `json:"source_bundle_id" db:"source_bundle_id"`
AgentCard json.RawMessage `json:"agent_card" db:"agent_card"`
URL sql.NullString `json:"url" db:"url"`
@@ -63,6 +68,21 @@ func IsValidDeliveryMode(s string) bool {
return s == DeliveryModePush || s == DeliveryModePoll
}
// Workspace kind constants. Matches the CHECK constraint in migration
// 20260606000000_workspaces_kind. KindPlatform marks the org-level concierge
// (the platform agent) which sits at the org root; see
// docs/design/rfc-platform-agent.md.
const (
KindWorkspace = "workspace"
KindPlatform = "platform"
)
// IsValidKind reports whether s is a recognised workspace kind. Empty string is
// NOT valid here — callers resolve the default (KindWorkspace) before calling.
func IsValidKind(s string) bool {
return s == KindWorkspace || s == KindPlatform
}
type RegisterPayload struct {
ID string `json:"id" binding:"required"`
// URL is required for push-mode workspaces; optional / unused for
@@ -76,6 +96,12 @@ type RegisterPayload struct {
// value on the workspace row, or default to push for new rows".
// When set, must be one of DeliveryModePush / DeliveryModePoll.
DeliveryMode string `json:"delivery_mode,omitempty"`
// Kind is optional. Empty string means "keep the existing value on the
// workspace row, or default to KindWorkspace for new rows". When set, must
// be one of KindWorkspace / KindPlatform. KindPlatform additionally requires
// the row to be its own org root (parent_id IS NULL) and to be the only
// platform agent in the org — enforced by the Register handler.
Kind string `json:"kind,omitempty"`
}
type HeartbeatPayload struct {
@@ -34,6 +34,35 @@ func TestIsValidDeliveryMode_Invalid(t *testing.T) {
}
}
// ==================== IsValidKind ====================
func TestIsValidKind_Valid(t *testing.T) {
for _, k := range []string{KindWorkspace, KindPlatform} {
if !IsValidKind(k) {
t.Errorf("IsValidKind(%q) = false, want true", k)
}
}
}
func TestIsValidKind_Invalid(t *testing.T) {
cases := []struct {
val string
want bool
}{
{"", false}, // empty is not valid — callers resolve the default
{"platforms", false}, // typo
{"Platform", false}, // case-sensitive
{"platform ", false}, // trailing space
{"root", false}, // not a kind
{"user", false}, // the user is implicit, not a workspace kind
}
for _, tc := range cases {
if got := IsValidKind(tc.val); got != tc.want {
t.Errorf("IsValidKind(%q) = %v, want %v", tc.val, got, tc.want)
}
}
}
// ==================== WorkspaceStatus ====================
func TestWorkspaceStatus_String(t *testing.T) {
@@ -0,0 +1,7 @@
-- Reverse the participant-kind discriminator.
-- Non-destructive: dropping the column makes every workspace an ordinary
-- workspace again (the platform agent loses its marker but its row survives).
DROP INDEX IF EXISTS idx_workspaces_kind;
ALTER TABLE workspaces DROP CONSTRAINT IF EXISTS workspaces_platform_root_check;
ALTER TABLE workspaces DROP CONSTRAINT IF EXISTS workspaces_kind_check;
ALTER TABLE workspaces DROP COLUMN IF EXISTS kind;
@@ -0,0 +1,45 @@
-- Participant-kind discriminator for the org-level platform agent.
-- (RFC: docs/design/rfc-platform-agent.md)
--
-- 'workspace' (default) = an ordinary workspace / agent.
-- 'platform' = the org-level concierge (the "platform agent"). It is
-- the single org root (parent_id IS NULL) and the user's
-- default A2A chat target. Exactly one per org.
--
-- There is no org_id column — an "org" is the parent_id-chain root resolved by
-- org_scope.go (orgRootID/sameOrg). "platform == org root" and "one platform
-- agent per org" are therefore enforced in the Register/create handlers, not in
-- pure SQL. This column is only the discriminator (default-target / billing
-- exclusion / UX), defined once here and mirrored by the Go constants
-- models.KindWorkspace / models.KindPlatform.
--
-- Backward-compatible: every existing row defaults to 'workspace'. The CHECK is
-- added NOT VALID then validated so the ALTER can never fail on legacy data.
ALTER TABLE workspaces
ADD COLUMN IF NOT EXISTS kind TEXT NOT NULL DEFAULT 'workspace';
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'workspaces_kind_check') THEN
ALTER TABLE workspaces
ADD CONSTRAINT workspaces_kind_check CHECK (kind IN ('workspace', 'platform')) NOT VALID;
ALTER TABLE workspaces VALIDATE CONSTRAINT workspaces_kind_check;
END IF;
END $$;
-- platform == org root, enforced at the DB level (race-proof). A platform agent
-- MUST have parent_id IS NULL. Because an org is the subtree under a single
-- parent_id IS NULL root (org_scope.go) and only a root may be 'platform', this
-- also structurally guarantees at most ONE platform agent per org. The handler
-- additionally pre-checks this to return a friendly 409 instead of a raw 23514.
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'workspaces_platform_root_check') THEN
ALTER TABLE workspaces
ADD CONSTRAINT workspaces_platform_root_check
CHECK (kind <> 'platform' OR parent_id IS NULL) NOT VALID;
ALTER TABLE workspaces VALIDATE CONSTRAINT workspaces_platform_root_check;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_workspaces_kind ON workspaces(kind);