Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions go/server/dm_e2e_pgtest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -395,6 +395,7 @@ func TestPeerDMSpawnPathDelivers(t *testing.T) {
Handle: "mgr-peer",
DisplayName: "Manager Peer",
ClientRequestId: "spawn-t6-1",
Role: "manager",
})
if err != nil {
t.Fatalf("SpawnAsAccount(manager) = %v, want success", err)
Expand Down
29 changes: 26 additions & 3 deletions go/server/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@ var errHandleTaken = errors.New("handle already taken")
// never a normal outcome — CodeInternal, never a silent success.
var errCallerNotAgent = errors.New("resolved caller is not an agent account")

// errUnknownRole is the in-band cause when a spawn names a role outside the
// closed taxonomy (spawnableRoles), including an empty role: every spawned node
// carries a valid role, and the server is the authority on the set. The label,
// not the prompt text, is validated — prompt text still arrives only via the
// operator config bundle. CodeInvalidArgument.
var errUnknownRole = errors.New("unknown spawn role")

// SpawnAsAccount creates a peer agent owned by the caller's OWNER and brings it
// online, running the same provision->placement->start->session chain a human
// spawn takes. The new agent's owner is the caller agent's owner (F2), resolved
Expand Down Expand Up @@ -186,6 +193,22 @@ func (l *lifecycleService) SpawnAsAccount(
ctx, cancel := context.WithTimeout(ctx, spawnChainTimeout)
defer cancel()

// Role validation: every spawned node carries a role from the closed
// taxonomy, and the server is the authority on the set. This is the first
// check in the chain — a structurally-invalid role is the cheapest possible
// rejection (a pure in-memory set lookup), so reject it before any store I/O
// and before the create/resume switch below, so the guard also covers the
// idempotent-resume branch and a malformed role always returns the same
// CodeInvalidArgument regardless of unrelated handle state. The LABEL is
// validated here, never the prompt text: the container's block-0 prompt
// still arrives only via the operator config bundle (prompts/<role>/SYSTEM.md),
// so a valid label with an unshipped prompt degrades to the default block-0
// (a visible runtime warn, not a spawn failure), while an off-taxonomy label
// never reaches the store at all.
if _, ok := spawnableRoles[req.GetRole()]; !ok {
return nil, connect.NewError(connect.CodeInvalidArgument, errUnknownRole)
}

// F2 ownership: the spawned peer inherits the CALLER'S OWNER. Resolve it
// from the store — the caller is an agent account, and its owner is who the
// new peer belongs to.
Expand Down Expand Up @@ -216,10 +239,10 @@ func (l *lifecycleService) SpawnAsAccount(
// Persona and role are set-at-creation from the spawn request (org-management
// Manager creation): under the D9 owner-acts model the caller's OWNER is the
// authority, so a Manager-creating spawn legitimately carries role+persona at
// creation. They are stored via CreateAgent (the source of record) and then
// creation. Role is caller-SELECTED but server-VALIDATED (above); persona is
// free-text. Both are stored via CreateAgent (the source of record) and then
// threaded to the Runner from the CREATED store account below, never from the
// request directly — so an empty role+persona spawn is byte-identical to
// today's field-less spawn.
// request directly.
created, err := l.store.CreateAgent(ctx, callerOwner, store.NewAgent{
Handle: req.GetHandle(),
DisplayName: req.GetDisplayName(),
Expand Down
2 changes: 2 additions & 0 deletions go/server/lifecycle_e2e_pgtest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ func e2eSpawnHappyPath(t *testing.T, w *e2eWire) (peerID store.AccountID, peerCo
Handle: "peer-1",
DisplayName: "Peer One",
ClientRequestId: "spawn-req-1",
Role: "manager",
}},
}))
if err != nil {
Expand Down Expand Up @@ -368,6 +369,7 @@ func TestForeignOwnerDespawnOverTheWireIsIndistinguishableNoOp(t *testing.T) {
Handle: "peer-b",
DisplayName: "Peer B",
ClientRequestId: "spawn-b-1",
Role: "manager",
})
if err != nil {
t.Fatalf("SpawnAsAccount(agent B) = %v, want success", err)
Expand Down
18 changes: 18 additions & 0 deletions go/server/lifecycle_pgtest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ func TestSpawnInheritsCallerOwner(t *testing.T) {
Handle: "peer-1",
DisplayName: "Peer One",
ClientRequestId: "spawn-1",
Role: "manager",
})
if err != nil {
t.Fatalf("SpawnAsAccount = %v, want success", err)
Expand Down Expand Up @@ -135,6 +136,7 @@ func TestSpawnSetsParentToCaller(t *testing.T) {
Handle: "peer-parent",
DisplayName: "Peer Parent",
ClientRequestId: "spawn-parent",
Role: "manager",
})
if err != nil {
t.Fatalf("SpawnAsAccount = %v, want success", err)
Expand Down Expand Up @@ -178,6 +180,7 @@ func TestSpawnSameClientRequestIdRetryJoins(t *testing.T) {
first, err := f.lc.SpawnAsAccount(ctx, f.agentID, &compassv1internal.SpawnPeerRequest{
Handle: "peer-dup",
ClientRequestId: "spawn-dup",
Role: "manager",
})
if err != nil {
t.Fatalf("first SpawnAsAccount = %v, want success", err)
Expand All @@ -186,6 +189,7 @@ func TestSpawnSameClientRequestIdRetryJoins(t *testing.T) {
second, err := f.lc.SpawnAsAccount(ctx, f.agentID, &compassv1internal.SpawnPeerRequest{
Handle: "peer-dup",
ClientRequestId: "spawn-dup",
Role: "manager",
})
if err != nil {
t.Fatalf("retry SpawnAsAccount = %v, want idempotent success", err)
Expand Down Expand Up @@ -232,6 +236,7 @@ func TestSpawnMidChainFailureRollsBack(t *testing.T) {
_, err := f.lc.SpawnAsAccount(ctx, f.agentID, &compassv1internal.SpawnPeerRequest{
Handle: "peer-roll",
ClientRequestId: "spawn-roll",
Role: "manager",
})
if err == nil {
t.Fatal("SpawnAsAccount with a failing Start = nil error, want the failure surfaced")
Expand All @@ -256,6 +261,7 @@ func TestSpawnMidChainFailureRollsBack(t *testing.T) {
resp, err := f.lc.SpawnAsAccount(ctx, f.agentID, &compassv1internal.SpawnPeerRequest{
Handle: "peer-roll",
ClientRequestId: "spawn-roll-2",
Role: "manager",
})
if err != nil {
t.Fatalf("re-spawn of the same handle after rollback = %v, want success (handle must not be burned)", err)
Expand Down Expand Up @@ -294,6 +300,7 @@ func TestSpawnSameHandleDifferentOwnerCreatesDistinctPeer(t *testing.T) {
respA, err := f.lc.SpawnAsAccount(ctx, f.agentID, &compassv1internal.SpawnPeerRequest{
Handle: "peer-shared",
ClientRequestId: "spawn-a",
Role: "manager",
})
if err != nil {
t.Fatalf("owner-A spawn = %v, want success", err)
Expand All @@ -313,6 +320,7 @@ func TestSpawnSameHandleDifferentOwnerCreatesDistinctPeer(t *testing.T) {
respB, err := f.lc.SpawnAsAccount(ctx, callerB.ID, &compassv1internal.SpawnPeerRequest{
Handle: "peer-shared",
ClientRequestId: "spawn-b",
Role: "manager",
})
if err != nil {
t.Fatalf("owner-B spawn of the same handle = %v, want success (distinct per-owner peer)", err)
Expand Down Expand Up @@ -362,6 +370,7 @@ func TestDespawnDifferentOwnerIsIndistinguishableNotFound(t *testing.T) {
peerB, err := f.lc.SpawnAsAccount(ctx, callerB.ID, &compassv1internal.SpawnPeerRequest{
Handle: "peer-b",
ClientRequestId: "spawn-peer-b",
Role: "manager",
})
if err != nil {
t.Fatalf("owner-B spawn = %v, want success", err)
Expand Down Expand Up @@ -421,6 +430,7 @@ func TestDespawnSameOwnerSiblingSucceeds(t *testing.T) {
target, err := f.lc.SpawnAsAccount(ctx, f.agentID, &compassv1internal.SpawnPeerRequest{
Handle: "sibling",
ClientRequestId: "spawn-sibling",
Role: "manager",
})
if err != nil {
t.Fatalf("spawn sibling = %v, want success", err)
Expand Down Expand Up @@ -458,6 +468,7 @@ func TestDespawnSecondTimeIsIdempotentSuccess(t *testing.T) {
target, err := f.lc.SpawnAsAccount(ctx, f.agentID, &compassv1internal.SpawnPeerRequest{
Handle: "peer-twice",
ClientRequestId: "spawn-twice",
Role: "manager",
})
if err != nil {
t.Fatalf("spawn = %v, want success", err)
Expand Down Expand Up @@ -591,6 +602,7 @@ func TestSpawnHandleCollidesWithUserAccountIsAlreadyExists(t *testing.T) {
_, err := f.lc.SpawnAsAccount(ctx, f.agentID, &compassv1internal.SpawnPeerRequest{
Handle: "taken-handle",
ClientRequestId: "spawn-collides-user",
Role: "manager",
})
if err == nil {
t.Fatal("spawn onto a user-held handle = success, want CodeAlreadyExists (never resume/steal, never leak account kind)")
Expand Down Expand Up @@ -624,6 +636,7 @@ func TestSpawnHandleCollidesWithSystemAccountIsAlreadyExists(t *testing.T) {
_, err = f.lc.SpawnAsAccount(ctx, f.agentID, &compassv1internal.SpawnPeerRequest{
Handle: sys.Handle,
ClientRequestId: "spawn-collides-system",
Role: "manager",
})
if err == nil {
t.Fatal("spawn onto the system handle = success, want CodeAlreadyExists (never shadow the system sender)")
Expand Down Expand Up @@ -653,6 +666,7 @@ func TestSpawnAutoOpensManagerPeerDM(t *testing.T) {
Handle: "peer-dm",
DisplayName: "Peer DM",
ClientRequestId: "spawn-dm-1",
Role: "manager",
})
if err != nil {
t.Fatalf("SpawnAsAccount = %v, want success", err)
Expand Down Expand Up @@ -696,6 +710,7 @@ func TestSpawnIdempotentReturnsSameDMName(t *testing.T) {
first, err := f.lc.SpawnAsAccount(ctx, f.agentID, &compassv1internal.SpawnPeerRequest{
Handle: "peer-dm-idem",
ClientRequestId: "spawn-dm-idem-1",
Role: "manager",
})
if err != nil {
t.Fatalf("SpawnAsAccount(first) = %v, want success", err)
Expand All @@ -709,6 +724,7 @@ func TestSpawnIdempotentReturnsSameDMName(t *testing.T) {
second, err := f.lc.SpawnAsAccount(ctx, f.agentID, &compassv1internal.SpawnPeerRequest{
Handle: "peer-dm-idem",
ClientRequestId: "spawn-dm-idem-2",
Role: "manager",
})
if err != nil {
t.Fatalf("SpawnAsAccount(second) = %v, want success", err)
Expand Down Expand Up @@ -745,6 +761,7 @@ func TestSpawnDMOpenFailureNeverRollsBackSpawn(t *testing.T) {
resp, err := lc.SpawnAsAccount(context.Background(), pf.agentID, &compassv1internal.SpawnPeerRequest{
Handle: "peer-dm-fail",
ClientRequestId: "spawn-dm-fail-1",
Role: "manager",
})
if err != nil {
t.Fatalf("SpawnAsAccount = %v, want success despite the DM-open failure (never a rollback)", err)
Expand All @@ -765,6 +782,7 @@ func TestSpawnDMOpenFailureNeverRollsBackSpawn(t *testing.T) {
resp, err := lc.SpawnAsAccount(context.Background(), pf.agentID, &compassv1internal.SpawnPeerRequest{
Handle: "peer-dm-nil",
ClientRequestId: "spawn-dm-nil-1",
Role: "manager",
})
if err != nil {
t.Fatalf("SpawnAsAccount = %v, want success with a nil DM opener", err)
Expand Down
13 changes: 13 additions & 0 deletions go/server/serve_seed.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@ const (
rootSupervisorRole = "manager"
)

// spawnableRoles is the closed Manager-role taxonomy a spawn request may name:
// supervisor (owns the whole tree — intake, incidents, broadcasts, first
// contact), owner (owns a product/service/domain), manager (owns one lane). The
// set is a frozen product decision, NOT derived from the operator config
// bundle's prompts/ members — legality is a fixed contract while the bundle is
// mutable state. All three are spawnable; a spawned supervisor is parented and
// permitted (standing up a separate tree is the intended use).
var spawnableRoles = map[string]struct{}{
"supervisor": {},
"owner": {},
"manager": {},
}

// seedClientRequestID is the fixed idempotency key the seed's SpawnAgent runs
// under. Fixed (not per-call) so a re-enroll that re-fires the seed for an
// already-seeded-and-live supervisor joins the completed spawn or is rejected
Expand Down
Loading
Loading