diff --git a/CHANGELOG.md b/CHANGELOG.md index f523079c5..84345bb46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ ### Fixed +- Agent files accept server route tools (`{ "tool_name", "route_path", "method", "description" }`), the tool type that lets an in-app agent call one of a full-stack app's own server routes. `base44 agents pull` wrote them, but every later command that reads the project — `agents push`, `deploy`, and most others — refused the file at `tool_configs[N]`. `method` defaults to `post` and `description` to `agent server route`, as on the server. - `base44 link --app-id` links directly instead of first listing your apps to confirm the ID. The listing only covered the 50 most recently updated apps, and a workspace API key (`BASE44_API_KEY`) is refused on it, so `link --app-id` failed in CI. A wrong ID now fails on the first request that addresses the app. - `base44 deploy --git-hash` (and `site deploy --publish`) no longer fails after a successful publish when the app's URL can't be looked up, as happened with a workspace API key (`BASE44_API_KEY`). It reports the deploy and leaves out the URL. - An app id passed with `--app-id` or `BASE44_APP_ID` no longer discards the project the command runs in. Commands that need a project — `site dev`, `dev`, `connectors pull`/`push` — refused as if there were none. A checkout with `base44/config.jsonc` but no `.app.jsonc`, such as a hosted sandbox, can now run them. diff --git a/packages/cli/src/core/resources/agent/schema.ts b/packages/cli/src/core/resources/agent/schema.ts index 985a83c2a..31cba6e27 100644 --- a/packages/cli/src/core/resources/agent/schema.ts +++ b/packages/cli/src/core/resources/agent/schema.ts @@ -12,9 +12,33 @@ const BackendFunctionToolConfigSchema = z.object({ description: z.string().default("agent backend function"), }); +// A tool backed by one of a full-stack app's own server routes. route_path +// mirrors the backend's validated_route_path: an app-relative path of RFC 3986 +// path characters, so a leading `//` (another host), a backslash, `?` and `#` +// are refused. +const ServerRouteToolConfigSchema = z.object({ + tool_name: z + .string() + .regex( + /^[A-Za-z0-9_-]{1,64}$/, + "tool_name must be 1-64 letters, digits, '_' or '-'", + ), + route_path: z + .string() + .trim() + .max(2000) + .regex( + /^\/(?!\/)[A-Za-z0-9\-._~!$&'()*+,;=:@%/]*$/, + "route_path must be an app-relative route like '/api/orders'", + ), + method: z.enum(["get", "post", "put", "patch", "delete"]).default("post"), + description: z.string().default("agent server route"), +}); + const ToolConfigSchema = z.union([ EntityToolConfigSchema, BackendFunctionToolConfigSchema, + ServerRouteToolConfigSchema, ]); const MemoryConfigSchema = z.object({ diff --git a/packages/cli/tests/cli/agents_pull.spec.ts b/packages/cli/tests/cli/agents_pull.spec.ts index e9f236c4b..efbd144b9 100644 --- a/packages/cli/tests/cli/agents_pull.spec.ts +++ b/packages/cli/tests/cli/agents_pull.spec.ts @@ -159,4 +159,43 @@ describe("agents pull command", () => { expect(parsed.description).toBe("Updated description"); expect(parsed.instructions).toBe("Updated instructions"); }); + + it("pushes a pulled server route tool back unchanged", async () => { + const remoteAgent = { + name: "time_agent", + description: "Tells the time", + instructions: "Use the route", + tool_configs: [ + { entity_name: "Task", allowed_operations: ["read"] }, + { + tool_name: "convert_time", + route_path: "/api/zuri/convert-time", + method: "post", + description: "Converts a time between zones", + }, + ], + selected_skill_names: [], + }; + await t.givenLoggedInWithProject(fixture("basic")); + t.api.mockAgentsFetch({ items: [remoteAgent], total: 1 }); + let pushedBody: unknown; + t.api.mockRoute( + "PUT", + `/api/apps/${t.api.appId}/agent-configs`, + (req, res) => { + pushedBody = req.body; + res + .status(200) + .json({ created: [], updated: ["time_agent"], deleted: [] }); + }, + ); + + const pull = await t.run("agents", "pull"); + const push = await t.run("agents", "push", "--yes"); + + t.expectResult(pull).toSucceed(); + t.expectResult(push).toSucceed(); + t.expectResult(push).toContain("Updated: time_agent"); + expect(pushedBody).toEqual([remoteAgent]); + }); }); diff --git a/packages/cli/tests/core/agents-schema.spec.ts b/packages/cli/tests/core/agents-schema.spec.ts index 3b4e0ce45..42944c84b 100644 --- a/packages/cli/tests/core/agents-schema.spec.ts +++ b/packages/cli/tests/core/agents-schema.spec.ts @@ -85,3 +85,58 @@ describe("AgentConfigSchema memory_config", () => { expect(parsed.selected_skill_names).toEqual([]); }); }); + +describe("AgentConfigSchema server route tools", () => { + const routeTool = { + tool_name: "convert_time", + route_path: "/api/zuri/convert-time", + method: "get", + description: "Converts a time between zones", + }; + + it("round-trips a route tool unchanged", () => { + const parsed = AgentConfigSchema.parse({ + ...baseAgent, + tool_configs: [routeTool], + }); + + expect(parsed.tool_configs).toEqual([routeTool]); + }); + + it("fills backend-matching defaults when method and description are omitted", () => { + const parsed = AgentConfigSchema.parse({ + ...baseAgent, + tool_configs: [{ tool_name: "sync", route_path: "/api/sync" }], + }); + + expect(parsed.tool_configs).toEqual([ + { + tool_name: "sync", + route_path: "/api/sync", + method: "post", + description: "agent server route", + }, + ]); + }); + + it.each([ + ["a tool_name with a space", { tool_name: "convert time" }], + ["a tool_name over 64 characters", { tool_name: "a".repeat(65) }], + ["a blank route_path", { route_path: " " }], + ["a route_path starting with //", { route_path: "//evil.example/x" }], + ["a route_path that is not app-relative", { route_path: "api/x" }], + ["a route_path with a query string", { route_path: "/api/x?y=1" }], + [ + "a route_path over 2000 characters", + { route_path: `/${"a".repeat(2000)}` }, + ], + ["a method the backend does not allow", { method: "head" }], + ])("rejects %s", (_label, override) => { + const result = AgentConfigSchema.safeParse({ + ...baseAgent, + tool_configs: [{ ...routeTool, ...override }], + }); + + expect(result.success).toBe(false); + }); +});