Skip to content
Merged
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
6 changes: 5 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,11 @@ settings remain unchanged. No old label is accepted as a fallback.

Historical Runtime and project-version upgrades are not supported. Preserve
older installations, Runtime files, provider resources and Session history;
install the current release separately. Use this release's template builder for
install the current release separately. Startup never verifies and rebinds historical
allocations or accepts node deployments without a valid specification. Keep the
original Core responsible for unresolved resources; see the
[operator boundary](services/agents-api/HOSTED-SANDBOX-MANAGER.md#historical-installations).
Use this release's template builder for
new E2B templates. Landed migrations and historical evidence stay as repository
history; ordinary current-version database initialization uses the migration runner.

Expand Down
52 changes: 17 additions & 35 deletions internal/agentdaemon/gateway/bootstrap_url_test.go
Original file line number Diff line number Diff line change
@@ -1,47 +1,29 @@
package gateway

import (
"context"
"errors"
"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device"
)

func TestBootstrapResolvesPublicURLOnlyAfterAuthentication(t *testing.T) {
func TestBootstrapPublishesURLOnlyAfterAuthentication(t *testing.T) {
const token = "synthetic-bootstrap-credential"
for _, tc := range []struct {
name, bearer, result string
err error
status, calls int
}{
{"selected origin", token, "wss://core.example/api/v1/agent-daemon/ws", nil, 200, 1},
{"unavailable", token, "", errors.New("private database detail"), 503, 1},
{"empty origin", token, "", nil, 503, 1},
{"unauthenticated", "wrong", "", nil, 401, 0},
} {
t.Run(tc.name, func(t *testing.T) {
calls := 0
h := NewHandler(HandlerConfig{Registry: NewRegistry(), PublicWSURL: "ws://private-core:8091/api/v1/agent-daemon/ws",
Authenticator: NewAuthenticator(&stubRuntimeStore{ok: true, row: device.Credential{ID: "device", WorkspaceID: "tenant", Type: RuntimeTypeAgentDaemon, CredentialHash: device.HashCredential(token)}}),
ResolveWSURL: func(context.Context, AuthenticatedRuntime) (string, error) { calls++; return tc.result, tc.err },
})
r := httptest.NewRequest(http.MethodPost, "/agent-daemon/bootstrap", strings.NewReader(`{"device_id":"device"}`))
r.Header.Set("Authorization", "Bearer "+tc.bearer)
w := httptest.NewRecorder()
h.Bootstrap(w, r)
if w.Code != tc.status || calls != tc.calls {
t.Fatalf("status %d, calls %d", w.Code, calls)
}
if strings.Contains(w.Body.String(), "private-") || strings.Contains(w.Body.String(), "private database detail") {
t.Fatalf("private configuration leaked: %s", w.Body.String())
}
if tc.status == 200 && !strings.Contains(w.Body.String(), tc.result) {
t.Fatalf("selected address missing: %s", w.Body.String())
}
})
const publicURL = "wss://core.example/api/v1/agent-daemon/ws"
for _, valid := range []bool{true, false} {
h := NewHandler(HandlerConfig{Registry: NewRegistry(), PublicWSURL: publicURL,
Authenticator: NewAuthenticator(&stubRuntimeStore{ok: true, row: device.Credential{ID: "device", WorkspaceID: "tenant", Type: RuntimeTypeAgentDaemon, CredentialHash: device.HashCredential(token)}})})
r := httptest.NewRequest(http.MethodPost, "/agent-daemon/bootstrap", strings.NewReader(`{"device_id":"device"}`))
bearer, status := "wrong", http.StatusUnauthorized
if valid {
bearer, status = token, http.StatusOK
}
r.Header.Set("Authorization", "Bearer "+bearer)
w := httptest.NewRecorder()
h.Bootstrap(w, r)
if w.Code != status || strings.Contains(w.Body.String(), publicURL) != valid {
t.Fatalf("bootstrap status=%d body=%s", w.Code, w.Body.String())
}
}
}
13 changes: 1 addition & 12 deletions internal/agentdaemon/gateway/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,6 @@ type HandlerConfig struct {
// response so deployments behind a TLS terminator can advertise
// the externally-reachable URL.
PublicWSURL string
// ResolveWSURL chooses a configured endpoint from the authenticated device's
// persisted allocation binding. Resolution failures never use a fallback URL.
ResolveWSURL func(context.Context, AuthenticatedRuntime) (string, error)

// OwnerStore enables multi-pod WebSocket ownership. When set, every
// successful daemon WS dial-in claims device_id -> owner_pod_id in
Expand Down Expand Up @@ -239,18 +236,10 @@ func (h *Handler) Bootstrap(w http.ResponseWriter, r *http.Request) {
writeAuthError(w, status, code, err.Error())
return
}
wsURL := h.cfg.PublicWSURL
if h.cfg.ResolveWSURL != nil {
wsURL, err = h.cfg.ResolveWSURL(r.Context(), auth)
if err != nil || wsURL == "" {
writeAuthError(w, http.StatusServiceUnavailable, "bootstrap_unavailable", "Runtime connection address is unavailable")
return
}
}
resp := map[string]any{
"device_id": auth.DeviceID,
"workspace_id": auth.WorkspaceID,
"ws_url": wsURL,
"ws_url": h.cfg.PublicWSURL,
"heartbeat_seconds": int(h.cfg.HeartbeatInterval.Seconds()),
"protocol_version": proto.Version,
}
Expand Down
21 changes: 21 additions & 0 deletions services/agents-api/HOSTED-SANDBOX-MANAGER.md
Original file line number Diff line number Diff line change
Expand Up @@ -399,3 +399,24 @@ reset its idle timer. An offline node or a provider without an observation sourc
returns unavailable telemetry. Provider timestamps retain their existing
validation, so excessive node/Core clock skew can also make a sample unavailable;
suspension eligibility continues to use database time.

## Historical installations

Historical upgrades and resource adoption are unsupported. Core does not verify
old allocation receipts to bind them to a new node, manufacture placements for
pending Sessions, or reconnect nodes whose deployment has no valid specification.
Startup rejects an incompatible saved selection before claiming its owner epoch.
New installations use a separate database and state directory, select a deployment
specification, and enroll nodes through the current configuration protocol.

Keep the previous Core, its database, credentials, node identities and provider
resources together until its work and cleanup are settled. Running compute,
retained snapshots, uncertain create/restore operations and unconfirmed cleanup
remain that installation's responsibility. Do not point a new installation at
those resources, erase their receipts, or treat a refused startup as cleanup.
Operators must explicitly settle them using the original installation before
retiring it. This release does not perform that operation during startup.

Current-version reconnect, immutable placement, maintenance fencing, idempotent
receipts, cancellation and cleanup confirmation still apply. Released allocation
history remains stored; it is never rebound to another node.
4 changes: 3 additions & 1 deletion services/agents-api/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -1185,7 +1185,9 @@ All sandbox writes require the observed generation, including initial POST at
zero; reject stale state before provider preparation, reset or no-op checks, and
repeat it under the committing row lock. Same-provider PUT advances the target
without draining execution or retiring nodes, tokens or the owner epoch. Node
providers prepare independently and keep their old qualified serving pin. A different backend, E2B team or unspecified old selection requires reset.
providers prepare independently and keep their old qualified serving pin. A different backend or E2B team requires reset. Historical selections without a
valid specification are unsupported and rejected at startup; keep their original
Core responsible for retained resources and install the current release separately.
Preserve historical allocation ownership and placement, never migrate a Session.

Persist immutable allocation and placement deployment generations, distinct from
Expand Down
14 changes: 0 additions & 14 deletions services/agents-api/cmd/server/daemon_bootstrap.go
Original file line number Diff line number Diff line change
@@ -1,24 +1,10 @@
package main

import (
"context"
"errors"
"net/url"

"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway"
)

// webSocketURL answers daemon bootstrap. Every daemon uses the one URL derived
// from OAC_PUBLIC_URL, except a legacy embedded node's own Runtime.
func (m *managedNodes) webSocketURL(publicURL string) func(context.Context, gateway.AuthenticatedRuntime) (string, error) {
return func(ctx context.Context, auth gateway.AuthenticatedRuntime) (string, error) {
if m != nil && m.runtime != nil && auth.RuntimeNodeID != "" && auth.RuntimeNodeID == m.runtime.LocalNodeID {
return runtimeWebSocketURL(m.runtime.CoreURL)
}
return publicURL, nil
}
}

// runtimeWebSocketURL derives the daemon WebSocket URL from a Core origin or
// its /api/v1 base.
func runtimeWebSocketURL(coreURL string) (string, error) {
Expand Down
45 changes: 6 additions & 39 deletions services/agents-api/cmd/server/daemon_bootstrap_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,6 @@ import (

"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device"
"github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/gateway"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution"
"github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store"
)

type bootstrapCredentialStore struct {
Expand All @@ -24,37 +22,20 @@ func (s bootstrapCredentialStore) GetDeviceCredential(context.Context, string) (
CredentialHash: device.HashCredential("synthetic-token"), RuntimeNodeID: s.nodeID, RuntimeAllocationID: s.allocationID}, true, nil
}

func TestBootstrapAddressFollowsAuthenticatedAllocation(t *testing.T) {
const publicURL = "wss://private-proxy.example/api/v1/agent-daemon/ws"
local := &managedNodes{runtime: &execution.RuntimeProvider{LocalNodeID: "local-node", CoreURL: "http://host.microsandbox.internal:8091/api/v1"}}
remote := &managedNodes{setup: &managedSetup{store: &setupStore{value: store.SandboxSetup{Provider: "docker", Generation: 1}}}}
remote.setup.publish(&execution.RuntimeProvider{CoreURL: "https://selected-node-entry.example/api/v1", Generation: 1})
zero := &managedNodes{setup: &managedSetup{}}
for _, tc := range []struct {
name, node, want string
managed *managedNodes
}{
{"embedded managed", "local-node", "ws://host.microsandbox.internal:8091/api/v1/agent-daemon/ws", local},
{"remote managed beside embedded node", "remote-node", publicURL, local},
{"self-hosted beside embedded node", "", publicURL, local},
// Every placed daemon uses the one address derived from the public URL.
{"remote managed after Web setup", "remote-node", publicURL, remote},
{"self-hosted after Web setup", "", publicURL, remote},
{"self-hosted before Web setup", "", publicURL, zero},
{"standalone self-hosted", "", publicURL, nil},
} {
t.Run(tc.name, func(t *testing.T) {
func TestBootstrapAddressUsesPublicOrigin(t *testing.T) {
const publicURL = "wss://public.example/api/v1/agent-daemon/ws"
for _, node := range []string{"local-node", "remote-node", ""} {
t.Run(node, func(t *testing.T) {
h := gateway.NewHandler(gateway.HandlerConfig{Registry: gateway.NewRegistry(), PublicWSURL: publicURL,
Authenticator: gateway.NewAuthenticator(bootstrapCredentialStore{nodeID: tc.node}),
ResolveWSURL: tc.managed.webSocketURL(publicURL)})
Authenticator: gateway.NewAuthenticator(bootstrapCredentialStore{nodeID: node})})
request := httptest.NewRequest(http.MethodPost, "https://forged.example/api/v1/agent-daemon/bootstrap",
strings.NewReader(`{"device_id":"runtime","node_id":"local-node","runtime_node_id":"local-node"}`))
request.Header.Set("Authorization", "Bearer synthetic-token")
request.Header.Set("X-Forwarded-Host", "forged-proxy.example")
response := httptest.NewRecorder()
h.Bootstrap(response, request)
var body map[string]any
if response.Code != http.StatusOK || json.Unmarshal(response.Body.Bytes(), &body) != nil || body["ws_url"] != tc.want {
if response.Code != http.StatusOK || json.Unmarshal(response.Body.Bytes(), &body) != nil || body["ws_url"] != publicURL {
t.Fatalf("bootstrap status=%d body=%s", response.Code, response.Body.String())
}
if len(body) != 5 {
Expand All @@ -63,17 +44,3 @@ func TestBootstrapAddressFollowsAuthenticatedAllocation(t *testing.T) {
})
}
}

func TestEmbeddedBootstrapDoesNotFallbackWhenInternalAddressIsInvalid(t *testing.T) {
managed := &managedNodes{runtime: &execution.RuntimeProvider{LocalNodeID: "local-node", CoreURL: "invalid"}}
h := gateway.NewHandler(gateway.HandlerConfig{Registry: gateway.NewRegistry(), PublicWSURL: "wss://public.example/api/v1/agent-daemon/ws",
Authenticator: gateway.NewAuthenticator(bootstrapCredentialStore{nodeID: "local-node"}),
ResolveWSURL: managed.webSocketURL("wss://public.example/api/v1/agent-daemon/ws")})
request := httptest.NewRequest(http.MethodPost, "/api/v1/agent-daemon/bootstrap", strings.NewReader(`{"device_id":"runtime"}`))
request.Header.Set("Authorization", "Bearer synthetic-token")
response := httptest.NewRecorder()
h.Bootstrap(response, request)
if response.Code != http.StatusServiceUnavailable || strings.Contains(response.Body.String(), "public.example") {
t.Fatalf("bootstrap fell back after route failure: %d %s", response.Code, response.Body.String())
}
}
2 changes: 1 addition & 1 deletion services/agents-api/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ func run() error {
if err != nil {
return err
}
daemonHandler, registry, err = runtime.NewGatewayWithURLResolver(executionStore, wsURL, managedNodes.webSocketURL(wsURL))
daemonHandler, registry, err = runtime.NewGateway(executionStore, wsURL)
if err != nil {
return err
}
Expand Down
21 changes: 0 additions & 21 deletions services/agents-api/internal/db/queries/runtime_nodes.sql
Original file line number Diff line number Diff line change
Expand Up @@ -76,22 +76,6 @@ UPDATE runtime_placements SET released_at=COALESCE(released_at,clock_timestamp()
WHERE environment_id IN(SELECT id FROM environments WHERE session_id=$1)
AND NOT EXISTS(SELECT 1 FROM runtime_allocations a WHERE a.environment_id=runtime_placements.environment_id);

-- name: AdoptRuntimePlacements :exec
INSERT INTO runtime_placements(environment_id,node_id,released_at,deployment_generation)
SELECT a.environment_id,$1,a.released_at,a.deployment_generation FROM runtime_allocations a WHERE a.provider_key=$2 AND a.state<>'released' AND a.node_id IS NULL
ON CONFLICT(environment_id) DO NOTHING;

-- name: AdoptPendingRuntimePlacements :exec
INSERT INTO runtime_placements(environment_id,node_id,deployment_generation)
SELECT e.id,$1,(SELECT generation FROM runtime_deployment) FROM environments e JOIN sessions s ON s.id=e.session_id
WHERE s.deleted_at IS NULL AND e.status='pending' AND s.configuration->'environment'->>'type'='openai_hosted'
AND NOT EXISTS(SELECT 1 FROM runtime_allocations a WHERE a.environment_id=e.id)
ON CONFLICT(environment_id) DO NOTHING;

-- name: BindLegacyRuntimeAllocations :exec
UPDATE runtime_allocations SET node_id=$1,compute_activity_at=clock_timestamp()
WHERE provider_key=$2 AND node_id IS NULL AND state<>'released';

-- name: ListNodeRuntimeAllocations :many
SELECT a.id,a.node_id,a.deployment_generation,a.observation_error,a.state,a.compute_phase,a.compute_phase_changed_at,a.initialization,a.created_at,a.environment_id,e.session_id,s.tenant_id
FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id JOIN sessions s ON s.id=e.session_id
Expand All @@ -103,8 +87,3 @@ SELECT id,$2,sqlc.arg(generation)::bigint FROM environments WHERE session_id=$1;

-- name: SetRuntimeObservation :exec
UPDATE runtime_allocations SET observation_error=$4 WHERE id=$1 AND compute_revision=$2 AND state=$3 AND state<>'released';

-- name: ListLegacyRuntimeAllocations :many
SELECT sqlc.embed(a), e.session_id, s.tenant_id, s.deleted_at
FROM runtime_allocations a JOIN environments e ON e.id=a.environment_id JOIN sessions s ON s.id=e.session_id
WHERE a.node_id IS NULL AND a.state<>'released' AND a.id>$1 ORDER BY a.id LIMIT 32 FOR UPDATE OF a;
Loading
Loading