diff --git a/internal/handlers/agent_action.go b/internal/handlers/agent_action.go index 68500775..6444fa0f 100644 --- a/internal/handlers/agent_action.go +++ b/internal/handlers/agent_action.go @@ -97,7 +97,7 @@ const AgentActionPauseRequiresPro = "Tell the user pausing resources requires th // when the row is already in 'paused' state. The remedy is "do nothing" // (the resource is in the requested state) or call /resume to flip back — // both of which the action verb covers via "Have them". -const AgentActionResourceAlreadyPaused = "Tell the user this resource is already paused. Have them call POST https://instanode.dev/api/v1/resources/:id/resume to bring it back online." +const AgentActionResourceAlreadyPaused = "Tell the user this resource is already paused. Have them call POST https://api.instanode.dev/api/v1/resources/:id/resume to bring it back online." // AgentActionResourceNotPaused is returned by POST /resources/:id/resume when // the row isn't in 'paused' state — typically because it's already active. @@ -298,7 +298,7 @@ const AgentActionBindingFamilyDisabled = "Tell the user this server has family b // (raw or family root) doesn't exist. func newAgentActionBindingNotFound(envKey string) string { return fmt.Sprintf( - "Tell the user the resource referenced in resource_bindings.%s doesn't exist. Have them list their families with GET https://instanode.dev/api/v1/resources/families and use a valid root id.", + "Tell the user the resource referenced in resource_bindings.%s doesn't exist. Have them list their families with GET https://api.instanode.dev/api/v1/resources/families and use a valid root id.", envKey, ) } @@ -321,7 +321,7 @@ func newAgentActionBindingNoEnvTwin(rootID, resourceName, env string) string { name = rootID } return fmt.Sprintf( - "Tell the user to provision a %s twin of %q first: POST https://instanode.dev/api/v1/resources/%s/provision-twin with {\"env\":\"%s\"}. The deploy targets env=%s but no family member exists there.", + "Tell the user to provision a %s twin of %q first: POST https://api.instanode.dev/api/v1/resources/%s/provision-twin with {\"env\":\"%s\"}. The deploy targets env=%s but no family member exists there.", env, name, rootID, env, env, ) } diff --git a/internal/handlers/agent_action_contract_test.go b/internal/handlers/agent_action_contract_test.go index 16689c81..0c11ebe9 100644 --- a/internal/handlers/agent_action_contract_test.go +++ b/internal/handlers/agent_action_contract_test.go @@ -102,8 +102,26 @@ func assertContract(t *testing.T, name, s string) { "%s: agent_action must start with \"Tell the user\" (the imperative the LLM agent re-articulates to the human). Got: %q", name, s) // 4. Full HTTPS URL. - assert.Contains(t, s, "https://instanode.dev/", - "%s: agent_action must contain a full https://instanode.dev/ URL — not a relative path. Got: %q", name, s) + // + // The string MUST contain at least one absolute https URL on either of + // the two canonical instanode.dev surfaces: + // + // - https://instanode.dev/... — marketing + dashboard (/pricing, + // /login, /app, /docs, /status, + // /support, /llms-full.txt, /claim). + // - https://api.instanode.dev/... — programmatic API surface + // (/api/v1/..., /healthz, /readyz, + // /approve/, /start, /webhooks). + // + // A relative path ("/pricing") or a bare hostname ("instanode.dev/pricing" + // without the scheme) forces the LLM agent to guess, which is the + // regression this assertion was added to prevent. The dual-host allowance + // reflects the actual prod topology — see TestAgentActionContract_APIPathsUseAPIHost + // for the companion invariant that pins API paths to the api-host. + hasHost := strings.Contains(s, "https://instanode.dev/") || + strings.Contains(s, "https://api.instanode.dev/") + assert.True(t, hasHost, + "%s: agent_action must contain a full https://instanode.dev/ OR https://api.instanode.dev/ URL — not a relative path. Got: %q", name, s) // 5. Soft length ceiling — LLMs reproduce sub-tweet copy verbatim. assert.Less(t, len(s), 280, @@ -205,6 +223,60 @@ func TestAgentActionContract(t *testing.T) { } } +// TestAgentActionContract_APIPathsUseAPIHost is the bug-burner R11 regression +// gate: every agent_action string that mentions a programmatic API path +// (anything containing "/api/v") MUST attach it to the api-host +// (https://api.instanode.dev/api/v...), NEVER the marketing/dashboard host +// (https://instanode.dev/api/v...). +// +// Why this matters: api.instanode.dev is the only host that actually serves +// /api/v1/* routes — the marketing site (instanode.dev) returns HTML 404 for +// /api/v1/anything. Pre-fix, 11 agent_action strings shipped with the wrong +// host, telling LLM agents to relay a non-working curl/POST URL to users. +// +// The test iterates the LIVE contract registry (agentActionContractCases), +// so any new agent_action — static const, builder, or codeToAgentAction +// entry — that re-introduces the bug fails this gate. Hand-typed slices +// would themselves be a single-site fallacy (CLAUDE.md rule 18). +func TestAgentActionContract_APIPathsUseAPIHost(t *testing.T) { + cases := agentActionContractCases() + require.NotEmpty(t, cases, "agentActionContractCases must list every string") + + // Bug-burner R11: also include the long-form deploy-TTL string that is + // excluded from the full contract gate by length (it documents THREE + // next actions and is intentionally > 280 chars). The host invariant + // still applies to it — it was already correct, but covering it here + // means a future edit that flips the host will fail this gate. + cases["newAgentActionDeployAutoExpire24h(id,ts)"] = newAgentActionDeployAutoExpire24h( + "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "2026-06-01T00:00:00Z", + ) + + const ( + wrongHostAPIPrefix = "https://instanode.dev/api/v" + rightHostAPIPrefix = "https://api.instanode.dev/api/v" + ) + + for name, s := range cases { + t.Run(name, func(t *testing.T) { + assert.NotContains(t, s, wrongHostAPIPrefix, + "%s: agent_action mentions an /api/v path on the marketing host (https://instanode.dev/api/v...) — that URL returns HTML 404. Switch to https://api.instanode.dev/api/v... Got: %q", + name, s) + + // Sanity: if the string mentions "/api/v" at all, it must use + // the api-host. This catches a hypothetical future bug where + // someone writes a bare "instanode.dev/api/v" (no scheme) — the + // NotContains above would miss it but the substring check below + // catches the structural mistake. + if strings.Contains(s, "/api/v") { + assert.Contains(t, s, rightHostAPIPrefix, + "%s: agent_action mentions an /api/v path but does not use the canonical api-host (%s...). Got: %q", + name, rightHostAPIPrefix, s) + } + }) + } +} + // TestAgentActionContract_RegistryCoverage guards against the most likely // regression: someone adds a new code to codeToAgentAction but its string // silently fails the contract. The map iteration in diff --git a/internal/handlers/helpers.go b/internal/handlers/helpers.go index e9a4a254..27815a40 100644 --- a/internal/handlers/helpers.go +++ b/internal/handlers/helpers.go @@ -154,7 +154,7 @@ var codeToAgentAction = map[string]errorCodeMeta{ UpgradeURL: "", }, "no_existing_deployment_to_redeploy": { - AgentAction: "Tell the user no deployment with that name exists on this team. Omit redeploy=true to create one fresh, or list https://instanode.dev/api/v1/deployments to find the app_id and call POST /deploy/{id}/redeploy.", + AgentAction: "Tell the user no deployment with that name exists on this team. Omit redeploy=true to create one fresh, or list https://api.instanode.dev/api/v1/deployments to find the app_id and call POST /deploy/{id}/redeploy.", UpgradeURL: "", }, "rate_limit_exceeded": { @@ -311,10 +311,10 @@ var codeToAgentAction = map[string]errorCodeMeta{ AgentAction: "Tell the user the team needs at least one owner. Have them promote another member to owner at https://instanode.dev/app/team before changing or removing this one.", }, "cannot_remove_primary": { - AgentAction: "Tell the user they can't remove the primary user — every team needs a primary. Have them promote another member first via POST https://instanode.dev/api/v1/team/members//promote-to-primary, then retry the removal.", + AgentAction: "Tell the user they can't remove the primary user — every team needs a primary. Have them promote another member first via POST https://api.instanode.dev/api/v1/team/members//promote-to-primary, then retry the removal.", }, "cannot_assign_owner_role": { - AgentAction: "Tell the user the owner role can't be assigned via PATCH role — ownership transfers atomically. Have them call POST https://instanode.dev/api/v1/team/members//promote-to-primary instead.", + AgentAction: "Tell the user the owner role can't be assigned via PATCH role — ownership transfers atomically. Have them call POST https://api.instanode.dev/api/v1/team/members//promote-to-primary instead.", }, // ── Body-validation errors ───────────────────────────────────────────── @@ -502,7 +502,7 @@ var codeToAgentAction = map[string]errorCodeMeta{ AgentAction: "Tell the user one or more required fields are missing. Check the response message for the field list and retry — see https://instanode.dev/docs.", }, "missing_backup_id": { - AgentAction: "Tell the user the backup_id path parameter is missing. Use GET https://instanode.dev/api/v1/backups to find an id and retry.", + AgentAction: "Tell the user the backup_id path parameter is missing. Use GET https://api.instanode.dev/api/v1/backups to find an id and retry.", }, "missing_confirm_slug": { AgentAction: "Tell the user the confirm_slug field is required to confirm this destructive action — supply the slug exactly as shown in the prompt and retry — see https://instanode.dev/docs.", @@ -657,13 +657,13 @@ var codeToAgentAction = map[string]errorCodeMeta{ AgentAction: "Tell the user the approval_id is not a valid UUID. Check the approval link in your email and retry — see https://instanode.dev/docs/promote.", }, "invalid_backup_id": { - AgentAction: "Tell the user the backup_id is not a valid UUID. List backups at GET https://instanode.dev/api/v1/backups and retry.", + AgentAction: "Tell the user the backup_id is not a valid UUID. List backups at GET https://api.instanode.dev/api/v1/backups and retry.", }, "invalid_target": { AgentAction: "Tell the user the target value is invalid. Check the docs at https://instanode.dev/docs for the allowed targets.", }, "invalid_target_resource_id": { - AgentAction: "Tell the user the target_resource_id is not a valid UUID. List resources at GET https://instanode.dev/api/v1/resources and retry.", + AgentAction: "Tell the user the target_resource_id is not a valid UUID. List resources at GET https://api.instanode.dev/api/v1/resources and retry.", }, "invalid_parent_resource_id": { AgentAction: "Tell the user the parent_resource_id is not a valid UUID. Check the resource list at https://instanode.dev/app/resources and retry.", @@ -719,7 +719,7 @@ var codeToAgentAction = map[string]errorCodeMeta{ AgentAction: "Tell the user the parent resource referenced by this request no longer exists. Re-provision the parent or retarget — see https://instanode.dev/docs.", }, "backup_not_found": { - AgentAction: "Tell the user the backup id is unknown. List available backups at GET https://instanode.dev/api/v1/backups and retry.", + AgentAction: "Tell the user the backup id is unknown. List available backups at GET https://api.instanode.dev/api/v1/backups and retry.", }, "approval_not_found": { AgentAction: "Tell the user the approval link is invalid or expired. The team owner can re-issue the approval — see https://instanode.dev/docs/promote.", @@ -867,7 +867,7 @@ var codeToAgentAction = map[string]errorCodeMeta{ }, // (invitation_invalid covered in the auth/token section above) "backup_resource_mismatch": { - AgentAction: "Tell the user this backup belongs to a different resource. List the resource's backups at GET https://instanode.dev/api/v1/resources//backups and retry.", + AgentAction: "Tell the user this backup belongs to a different resource. List the resource's backups at GET https://api.instanode.dev/api/v1/resources//backups and retry.", }, "restore_in_progress": { AgentAction: "Tell the user a restore is already in progress on this resource. Wait for it to complete — see https://instanode.dev/app/resources.", diff --git a/internal/handlers/resource_pause_test.go b/internal/handlers/resource_pause_test.go index c4df403e..ffa9a0d1 100644 --- a/internal/handlers/resource_pause_test.go +++ b/internal/handlers/resource_pause_test.go @@ -21,6 +21,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -208,7 +209,12 @@ func TestPauseResource_AlreadyPaused_409(t *testing.T) { action, _ := body["agent_action"].(string) require.NotEmpty(t, action, "409 already_paused must carry agent_action") assert.Contains(t, action, "Tell the user") - assert.Contains(t, action, "https://instanode.dev/") + // agent_action contains an https URL on either the marketing or api host. + // The dual-host allowance mirrors assertContract in + // agent_action_contract_test.go; api-path strings live on api.instanode.dev. + hasHost := strings.Contains(action, "https://instanode.dev/") || + strings.Contains(action, "https://api.instanode.dev/") + assert.True(t, hasHost, "agent_action must contain a full https URL on either instanode.dev or api.instanode.dev. Got: %q", action) } // TestResumeResource_NotPaused_409 — resume on an active row is 409 not_paused.