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: 3 additions & 3 deletions internal/handlers/agent_action.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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,
)
}
Expand All @@ -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,
)
}
Expand Down
76 changes: 74 additions & 2 deletions internal/handlers/agent_action_contract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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/<token>, /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,
Expand Down Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions internal/handlers/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down Expand Up @@ -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/<other_user_id>/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/<other_user_id>/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/<user_id>/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/<user_id>/promote-to-primary instead.",
},

// ── Body-validation errors ─────────────────────────────────────────────
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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/<id>/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/<id>/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.",
Expand Down
8 changes: 7 additions & 1 deletion internal/handlers/resource_pause_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -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.
Expand Down
Loading