Skip to content

Commit 9428f44

Browse files
fix(api): agent_action URLs must use api-host for /api/v1 paths (#213)
Eleven agent_action strings shipped pointing /api/v1 calls at the marketing host (https://instanode.dev/api/v1/...), which returns HTML 404 on every path. The canonical API host is api.instanode.dev. Every LLM agent that hit one of these walls relayed a non-working URL to the user, who then wasted a curl/POST attempt. Live verification of the bug surface: - curl https://instanode.dev/api/v1/resources -> HTTP/2 404 (HTML) - curl https://api.instanode.dev/api/v1/resources -> HTTP/2 401 (JSON) Fix: - Flip 11 sites in agent_action.go (3) + helpers.go (8) to https://api.instanode.dev/api/v1/... - Relax assertContract URL check to accept either canonical host (marketing https://instanode.dev/ for dashboard URLs, api host for programmatic API paths). Docblock now spells out the two surfaces. - Add TestAgentActionContract_APIPathsUseAPIHost — registry-iterating regression test that asserts no /api/v path appears on the marketing host. Iterates the live contract registry plus the long-form deploy-TTL builder (which was already correct but had been excluded from the contract gate for length). Verified to fail-fast on any re-introduction of the bug (rule 18). - Update one collateral test assertion in resource_pause_test.go to use the dual-host check so AgentActionResourceAlreadyPaused still satisfies its per-handler URL assertion. Coverage (CLAUDE.md rule 17): Symptom: agent_action returns "POST https://instanode.dev/api/v1/..." -> HTML 404 on the marketing site Enumeration: rg -nF 'https://instanode.dev/api/' internal/ -> 11 sites Sites found: 11 Sites touched: 11 Coverage test: TestAgentActionContract_APIPathsUseAPIHost (registry-iterating) Live verified: curl host-comparison above Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 9b2f88b commit 9428f44

4 files changed

Lines changed: 92 additions & 14 deletions

File tree

internal/handlers/agent_action.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ const AgentActionPauseRequiresPro = "Tell the user pausing resources requires th
9797
// when the row is already in 'paused' state. The remedy is "do nothing"
9898
// (the resource is in the requested state) or call /resume to flip back —
9999
// both of which the action verb covers via "Have them".
100-
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."
100+
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."
101101

102102
// AgentActionResourceNotPaused is returned by POST /resources/:id/resume when
103103
// 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
298298
// (raw or family root) doesn't exist.
299299
func newAgentActionBindingNotFound(envKey string) string {
300300
return fmt.Sprintf(
301-
"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.",
301+
"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.",
302302
envKey,
303303
)
304304
}
@@ -321,7 +321,7 @@ func newAgentActionBindingNoEnvTwin(rootID, resourceName, env string) string {
321321
name = rootID
322322
}
323323
return fmt.Sprintf(
324-
"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.",
324+
"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.",
325325
env, name, rootID, env, env,
326326
)
327327
}

internal/handlers/agent_action_contract_test.go

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,26 @@ func assertContract(t *testing.T, name, s string) {
102102
"%s: agent_action must start with \"Tell the user\" (the imperative the LLM agent re-articulates to the human). Got: %q", name, s)
103103

104104
// 4. Full HTTPS URL.
105-
assert.Contains(t, s, "https://instanode.dev/",
106-
"%s: agent_action must contain a full https://instanode.dev/ URL — not a relative path. Got: %q", name, s)
105+
//
106+
// The string MUST contain at least one absolute https URL on either of
107+
// the two canonical instanode.dev surfaces:
108+
//
109+
// - https://instanode.dev/... — marketing + dashboard (/pricing,
110+
// /login, /app, /docs, /status,
111+
// /support, /llms-full.txt, /claim).
112+
// - https://api.instanode.dev/... — programmatic API surface
113+
// (/api/v1/..., /healthz, /readyz,
114+
// /approve/<token>, /start, /webhooks).
115+
//
116+
// A relative path ("/pricing") or a bare hostname ("instanode.dev/pricing"
117+
// without the scheme) forces the LLM agent to guess, which is the
118+
// regression this assertion was added to prevent. The dual-host allowance
119+
// reflects the actual prod topology — see TestAgentActionContract_APIPathsUseAPIHost
120+
// for the companion invariant that pins API paths to the api-host.
121+
hasHost := strings.Contains(s, "https://instanode.dev/") ||
122+
strings.Contains(s, "https://api.instanode.dev/")
123+
assert.True(t, hasHost,
124+
"%s: agent_action must contain a full https://instanode.dev/ OR https://api.instanode.dev/ URL — not a relative path. Got: %q", name, s)
107125

108126
// 5. Soft length ceiling — LLMs reproduce sub-tweet copy verbatim.
109127
assert.Less(t, len(s), 280,
@@ -205,6 +223,60 @@ func TestAgentActionContract(t *testing.T) {
205223
}
206224
}
207225

226+
// TestAgentActionContract_APIPathsUseAPIHost is the bug-burner R11 regression
227+
// gate: every agent_action string that mentions a programmatic API path
228+
// (anything containing "/api/v") MUST attach it to the api-host
229+
// (https://api.instanode.dev/api/v...), NEVER the marketing/dashboard host
230+
// (https://instanode.dev/api/v...).
231+
//
232+
// Why this matters: api.instanode.dev is the only host that actually serves
233+
// /api/v1/* routes — the marketing site (instanode.dev) returns HTML 404 for
234+
// /api/v1/anything. Pre-fix, 11 agent_action strings shipped with the wrong
235+
// host, telling LLM agents to relay a non-working curl/POST URL to users.
236+
//
237+
// The test iterates the LIVE contract registry (agentActionContractCases),
238+
// so any new agent_action — static const, builder, or codeToAgentAction
239+
// entry — that re-introduces the bug fails this gate. Hand-typed slices
240+
// would themselves be a single-site fallacy (CLAUDE.md rule 18).
241+
func TestAgentActionContract_APIPathsUseAPIHost(t *testing.T) {
242+
cases := agentActionContractCases()
243+
require.NotEmpty(t, cases, "agentActionContractCases must list every string")
244+
245+
// Bug-burner R11: also include the long-form deploy-TTL string that is
246+
// excluded from the full contract gate by length (it documents THREE
247+
// next actions and is intentionally > 280 chars). The host invariant
248+
// still applies to it — it was already correct, but covering it here
249+
// means a future edit that flips the host will fail this gate.
250+
cases["newAgentActionDeployAutoExpire24h(id,ts)"] = newAgentActionDeployAutoExpire24h(
251+
"aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
252+
"2026-06-01T00:00:00Z",
253+
)
254+
255+
const (
256+
wrongHostAPIPrefix = "https://instanode.dev/api/v"
257+
rightHostAPIPrefix = "https://api.instanode.dev/api/v"
258+
)
259+
260+
for name, s := range cases {
261+
t.Run(name, func(t *testing.T) {
262+
assert.NotContains(t, s, wrongHostAPIPrefix,
263+
"%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",
264+
name, s)
265+
266+
// Sanity: if the string mentions "/api/v" at all, it must use
267+
// the api-host. This catches a hypothetical future bug where
268+
// someone writes a bare "instanode.dev/api/v" (no scheme) — the
269+
// NotContains above would miss it but the substring check below
270+
// catches the structural mistake.
271+
if strings.Contains(s, "/api/v") {
272+
assert.Contains(t, s, rightHostAPIPrefix,
273+
"%s: agent_action mentions an /api/v path but does not use the canonical api-host (%s...). Got: %q",
274+
name, rightHostAPIPrefix, s)
275+
}
276+
})
277+
}
278+
}
279+
208280
// TestAgentActionContract_RegistryCoverage guards against the most likely
209281
// regression: someone adds a new code to codeToAgentAction but its string
210282
// silently fails the contract. The map iteration in

internal/handlers/helpers.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ var codeToAgentAction = map[string]errorCodeMeta{
154154
UpgradeURL: "",
155155
},
156156
"no_existing_deployment_to_redeploy": {
157-
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.",
157+
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.",
158158
UpgradeURL: "",
159159
},
160160
"rate_limit_exceeded": {
@@ -311,10 +311,10 @@ var codeToAgentAction = map[string]errorCodeMeta{
311311
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.",
312312
},
313313
"cannot_remove_primary": {
314-
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.",
314+
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.",
315315
},
316316
"cannot_assign_owner_role": {
317-
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.",
317+
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.",
318318
},
319319

320320
// ── Body-validation errors ─────────────────────────────────────────────
@@ -502,7 +502,7 @@ var codeToAgentAction = map[string]errorCodeMeta{
502502
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.",
503503
},
504504
"missing_backup_id": {
505-
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.",
505+
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.",
506506
},
507507
"missing_confirm_slug": {
508508
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{
657657
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.",
658658
},
659659
"invalid_backup_id": {
660-
AgentAction: "Tell the user the backup_id is not a valid UUID. List backups at GET https://instanode.dev/api/v1/backups and retry.",
660+
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.",
661661
},
662662
"invalid_target": {
663663
AgentAction: "Tell the user the target value is invalid. Check the docs at https://instanode.dev/docs for the allowed targets.",
664664
},
665665
"invalid_target_resource_id": {
666-
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.",
666+
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.",
667667
},
668668
"invalid_parent_resource_id": {
669669
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{
719719
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.",
720720
},
721721
"backup_not_found": {
722-
AgentAction: "Tell the user the backup id is unknown. List available backups at GET https://instanode.dev/api/v1/backups and retry.",
722+
AgentAction: "Tell the user the backup id is unknown. List available backups at GET https://api.instanode.dev/api/v1/backups and retry.",
723723
},
724724
"approval_not_found": {
725725
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{
867867
},
868868
// (invitation_invalid covered in the auth/token section above)
869869
"backup_resource_mismatch": {
870-
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.",
870+
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.",
871871
},
872872
"restore_in_progress": {
873873
AgentAction: "Tell the user a restore is already in progress on this resource. Wait for it to complete — see https://instanode.dev/app/resources.",

internal/handlers/resource_pause_test.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import (
2121
"encoding/json"
2222
"net/http"
2323
"net/http/httptest"
24+
"strings"
2425
"testing"
2526

2627
"github.com/stretchr/testify/assert"
@@ -208,7 +209,12 @@ func TestPauseResource_AlreadyPaused_409(t *testing.T) {
208209
action, _ := body["agent_action"].(string)
209210
require.NotEmpty(t, action, "409 already_paused must carry agent_action")
210211
assert.Contains(t, action, "Tell the user")
211-
assert.Contains(t, action, "https://instanode.dev/")
212+
// agent_action contains an https URL on either the marketing or api host.
213+
// The dual-host allowance mirrors assertContract in
214+
// agent_action_contract_test.go; api-path strings live on api.instanode.dev.
215+
hasHost := strings.Contains(action, "https://instanode.dev/") ||
216+
strings.Contains(action, "https://api.instanode.dev/")
217+
assert.True(t, hasHost, "agent_action must contain a full https URL on either instanode.dev or api.instanode.dev. Got: %q", action)
212218
}
213219

214220
// TestResumeResource_NotPaused_409 — resume on an active row is 409 not_paused.

0 commit comments

Comments
 (0)