diff --git a/.changeset/local-completion-split-tools.md b/.changeset/local-completion-split-tools.md new file mode 100644 index 00000000..3f63e184 --- /dev/null +++ b/.changeset/local-completion-split-tools.md @@ -0,0 +1,6 @@ +--- +"caplets": patch +"@caplets/core": patch +--- + +Fix local shell completion discovery for downstream tool names and support split `caplets get-tool `, `caplets call-tool `, and `caplets get-prompt ` command forms while preserving existing qualified targets. Preserve the public `CAPLETS_SERVER_URL` origin for remote OAuth callback redirects. diff --git a/README.md b/README.md index 33b076df..7445c6f9 100644 --- a/README.md +++ b/README.md @@ -77,16 +77,18 @@ You can also invoke configured Caplets directly from the CLI for agent-friendly ```sh caplets get-caplet context7 caplets list-tools context7 -caplets get-tool context7.resolve-library-id -caplets call-tool context7.resolve-library-id --args '{"libraryName":"react"}' -caplets call-tool context7.resolve-library-id --args '{"libraryName":"react"}' --field result.id --format json +caplets get-tool context7 resolve-library-id +caplets call-tool context7 resolve-library-id --args '{"libraryName":"react"}' +caplets call-tool context7 resolve-library-id --args '{"libraryName":"react"}' --field result.id --format json caplets list-resources docs caplets read-resource docs file:///repo/README.md caplets list-prompts linear -caplets get-prompt linear.review_issue --args '{"issueId":"CAP-123"}' +caplets get-prompt linear review_issue --args '{"issueId":"CAP-123"}' caplets complete docs --resource-template 'file:///repo/{path}' --argument path --value src/ ``` +The older qualified form, such as `caplets call-tool context7.resolve-library-id` or `caplets get-prompt linear.review_issue`, remains supported for scripts and existing usage. + Direct CLI operation commands print Markdown summaries by default. Add `--format plain` for plain text or `--format json` for machine-readable JSON (`md` is accepted as an alias for `markdown`). If a downstream tool returns `isError: true`, Caplets still exits with status code 1. ### Shell completions @@ -117,7 +119,7 @@ caplets completion cmd > %USERPROFILE%\caplets-completion.cmd %USERPROFILE%\caplets-completion.cmd ``` -Completions include command names, options, common enum values, configured Caplet IDs, and cache-backed downstream names for qualified targets such as `caplets call-tool repo.`. Downstream discovery is bounded by the `completion` config timeouts and a platform-native cache directory. Generated shell scripts suppress completion stderr; run the underlying CLI command directly when debugging completion behavior. +Completions include command names, options, common enum values, configured Caplet IDs, and cache-backed downstream names for split targets such as `caplets call-tool repo ` and qualified targets such as `caplets call-tool repo.`. Downstream discovery is bounded by the `completion` config timeouts and a platform-native cache directory. Generated shell scripts suppress completion stderr; run the underlying CLI command directly when debugging completion behavior. Backends that require OAuth or token auth may need `caplets auth login ` before live downstream completions can return richer results. Completion never starts interactive login flows. diff --git a/docs/specs/2026-05-21-completion-discovery-refactor-design.md b/docs/specs/2026-05-21-completion-discovery-refactor-design.md index 83243898..561fdc02 100644 --- a/docs/specs/2026-05-21-completion-discovery-refactor-design.md +++ b/docs/specs/2026-05-21-completion-discovery-refactor-design.md @@ -156,18 +156,19 @@ Completion may start normal MCP stdio processes because MCP discovery requires i For `call-tool` and `get-tool`: -- `caplets call-tool ` suggests enabled backend IDs with a trailing dot: `github.`, `repo.`. -- `caplets call-tool repo.` suggests `repo.status`, `repo.build`, etc. -- `caplets call-tool gh.se` filters by the full typed prefix. +- `caplets call-tool ` suggests enabled backend IDs such as `github`, `repo`. +- `caplets call-tool repo ` suggests unqualified tool names such as `status`, `build`. +- `caplets call-tool repo.` remains supported and suggests qualified tool names such as `repo.status`, `repo.build`. - CLI and HTTP backends can provide config-defined action names without live probing. - OpenAPI, GraphQL, MCP, and Caplet-set backends may use live discovery through the cache manager. -- Failures/timeouts degrade to `server.` suggestions or no extra target names. +- Failures/timeouts degrade to backend ID suggestions or no extra target names. ### Prompts For `get-prompt`: -- `caplets get-prompt ` suggests MCP backend IDs with trailing dots when prompts are possible. +- `caplets get-prompt ` suggests MCP backend IDs when prompts are possible. +- `caplets get-prompt docs ` suggests unqualified prompt names such as `summarize`. - `caplets get-prompt docs.` discovers/caches MCP prompt names and suggests `docs.promptName`. - Non-MCP backends should not suggest prompt names. diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts index 35c597a8..dbaf9e8a 100644 --- a/packages/core/src/cli.ts +++ b/packages/core/src/cli.ts @@ -142,9 +142,14 @@ export function createProgram(io: CliIO = {}): Command { shell, words: completionWords, })) as string[]) - : await completeCliWords(completionWords, configPath ? { configPath } : {}); + : await completeCliWordsLocally(completionWords, { + ...(configPath ? { configPath } : {}), + ...(io.authDir ? { authDir: io.authDir } : {}), + }); } catch { - suggestions = []; + suggestions = remote + ? [] + : await completeCliWords(completionWords, configPath ? { configPath } : {}); } if (suggestions.length > 0) writeOut(`${suggestions.join("\n")}\n`); }); @@ -581,38 +586,47 @@ export function createProgram(io: CliIO = {}): Command { program .command(cliCommands.getTool) .description("Print one downstream tool schema.") - .argument("", "qualified target, split on the first dot") + .argument("", "Caplet ID or qualified target") + .argument("[tool]", "downstream tool name when caplet is provided separately") .option("--format ", "output format: markdown, md, plain, or json", parseOutputFormat) - .action(async (target: string, options: { format?: CliOutputFormat }) => { - const { caplet, tool } = parseQualifiedTarget(target); - await executeOperation( - caplet, - { operation: "get_tool", tool }, - { - writeOut, - writeErr, - setExitCode, - authDir: io.authDir, - env, - remote: remoteClientForCli(io), - format: options.format, - }, - ); - }); + .action( + async ( + capletOrTarget: string, + toolArgument: string | undefined, + options: { format?: CliOutputFormat }, + ) => { + const { caplet, tool } = parseQualifiedTarget(capletOrTarget, toolArgument); + await executeOperation( + caplet, + { operation: "get_tool", tool }, + { + writeOut, + writeErr, + setExitCode, + authDir: io.authDir, + env, + remote: remoteClientForCli(io), + format: options.format, + }, + ); + }, + ); program .command(cliCommands.callTool) .description("Call one downstream tool.") - .argument("", "qualified target, split on the first dot") + .argument("", "Caplet ID or qualified target") + .argument("[tool]", "downstream tool name when caplet is provided separately") .option("--args ", "JSON object of downstream tool arguments") .option("--field ", "project a field from structured output", collect, []) .option("--format ", "output format: markdown, md, plain, or json", parseOutputFormat) .action( async ( - target: string, + capletOrTarget: string, + toolArgument: string | undefined, options: { args?: string; field?: string[]; format?: CliOutputFormat }, ) => { - const { caplet, tool } = parseQualifiedTarget(target); + const { caplet, tool } = parseQualifiedTarget(capletOrTarget, toolArgument); const request = { operation: "call_tool", tool, @@ -782,29 +796,36 @@ export function createProgram(io: CliIO = {}): Command { program .command(cliCommands.getPrompt) .description("Get one MCP prompt by name.") - .argument("", "qualified target, split on the first dot") + .argument("", "MCP Caplet ID or qualified target") + .argument("[prompt]", "prompt name when caplet is provided separately") .option("--args ", "JSON object of prompt arguments") .option("--format ", "output format: markdown, md, plain, or json", parseOutputFormat) - .action(async (target: string, options: { args?: string; format?: CliOutputFormat }) => { - const { caplet, tool: prompt } = parseQualifiedTarget(target); - await executeOperation( - caplet, - { - operation: "get_prompt", - prompt, - arguments: parseJsonObjectOption(options.args, "get-prompt --args"), - }, - { - writeOut, - writeErr, - setExitCode, - authDir: io.authDir, - env, - remote: remoteClientForCli(io), - format: options.format, - }, - ); - }); + .action( + async ( + capletOrTarget: string, + promptArgument: string | undefined, + options: { args?: string; format?: CliOutputFormat }, + ) => { + const { caplet, tool: prompt } = parseQualifiedTarget(capletOrTarget, promptArgument); + await executeOperation( + caplet, + { + operation: "get_prompt", + prompt, + arguments: parseJsonObjectOption(options.args, "get-prompt --args"), + }, + { + writeOut, + writeErr, + setExitCode, + authDir: io.authDir, + env, + remote: remoteClientForCli(io), + format: options.format, + }, + ); + }, + ); program .command(cliCommands.complete) .description("Complete an MCP prompt or resource-template argument.") @@ -1079,15 +1100,44 @@ function parseOutputFormat(value: string): CliOutputFormat { } } -function parseQualifiedTarget(target: string): { caplet: string; tool: string } { - const dot = target.indexOf("."); - if (dot <= 0 || dot === target.length - 1) { +function parseQualifiedTarget( + capletOrTarget: string, + toolArgument?: string | undefined, +): { caplet: string; tool: string } { + if (toolArgument !== undefined) { + if (capletOrTarget.length === 0 || toolArgument.length === 0) { + throw new CapletsError( + "REQUEST_INVALID", + "Expected target in the form or .", + ); + } + return { caplet: capletOrTarget, tool: toolArgument }; + } + + const dot = capletOrTarget.indexOf("."); + if (dot <= 0 || dot === capletOrTarget.length - 1) { throw new CapletsError( "REQUEST_INVALID", - "Expected qualified target in the form .", + "Expected target in the form or .", ); } - return { caplet: target.slice(0, dot), tool: target.slice(dot + 1) }; + return { caplet: capletOrTarget.slice(0, dot), tool: capletOrTarget.slice(dot + 1) }; +} + +async function completeCliWordsLocally( + words: string[], + options: { configPath?: string | undefined; authDir?: string | undefined }, +): Promise { + const engine = new CapletsEngine({ + ...(options.configPath ? { configPath: options.configPath } : {}), + ...(options.authDir ? { authDir: options.authDir } : {}), + watch: false, + }); + try { + return await engine.completeCliWords(words); + } finally { + await engine.close(); + } } function parseCallToolArgs(value: string | undefined): Record { diff --git a/packages/core/src/cli/completion.ts b/packages/core/src/cli/completion.ts index cfa24d74..f6c03ec1 100644 --- a/packages/core/src/cli/completion.ts +++ b/packages/core/src/cli/completion.ts @@ -109,27 +109,34 @@ export async function completeCliWords( return prefixFilter(ids, current); } - if ( - normalized.length === 2 && - (qualifiedToolCommands.has(command) || qualifiedPromptCommands.has(command)) - ) { - if (current.includes(".")) { - const serverId = current.slice(0, current.indexOf(".")); - const kind = qualifiedToolCommands.has(command) ? "tools" : "prompts"; + if (qualifiedToolCommands.has(command) || qualifiedPromptCommands.has(command)) { + const kind = qualifiedToolCommands.has(command) ? "tools" : "prompts"; + const idFilter = qualifiedPromptCommands.has(command) + ? { backend: "mcp" as const } + : undefined; + + if (normalized.length === 2) { + if (current.includes(".")) { + const serverId = current.slice(0, current.indexOf(".")); + return prefixFilter( + (await discoverCompletionCandidates(serverId, kind, discoveryOptions(options))).map( + (candidate) => candidate.value, + ), + current, + ); + } + return prefixFilter(configuredCapletIds(options, idFilter), current); + } + + if (normalized.length === 3 && subcommand && !subcommand.includes(".")) { + if (current.startsWith("-")) return []; return prefixFilter( - (await discoverCompletionCandidates(serverId, kind, discoveryOptions(options))).map( - (candidate) => candidate.value, + (await discoverCompletionCandidates(subcommand, kind, discoveryOptions(options))).map( + (candidate) => candidate.value.replace(`${subcommand}.`, ""), ), current, ); } - return prefixFilter( - configuredCapletIds( - options, - qualifiedPromptCommands.has(command) ? { backend: "mcp" } : undefined, - ).map((id) => `${id}.`), - current, - ); } if (command === cliCommands.readResource && normalized.length === 3) { diff --git a/packages/core/src/serve/http.ts b/packages/core/src/serve/http.ts index ed45c8da..cbc1bc95 100644 --- a/packages/core/src/serve/http.ts +++ b/packages/core/src/serve/http.ts @@ -141,6 +141,7 @@ export function createHttpServeApp( authFlowStore, c.req.url, paths.control, + options.publicOrigin, options.trustProxy, (name) => c.req.header(name), ), @@ -158,6 +159,7 @@ export function createHttpServeApp( authFlowStore, c.req.url, paths.control, + options.publicOrigin, options.trustProxy, (name) => c.req.header(name), ), @@ -196,6 +198,7 @@ function controlContext( authFlowStore: RemoteAuthFlowStore, requestUrl: string, controlPath: string, + publicOrigin: string | undefined, trustProxy: boolean, header: (name: string) => string | undefined, ): RemoteControlDispatchContext { @@ -205,7 +208,7 @@ function controlContext( authFlowStore, controlCallbackBaseUrl: new URL( controlPath, - publicRequestOrigin(requestUrl, trustProxy, header), + publicOrigin ?? publicRequestOrigin(requestUrl, trustProxy, header), ).toString(), writeErr, }; diff --git a/packages/core/src/serve/options.ts b/packages/core/src/serve/options.ts index 74d4d13a..b36bdc43 100644 --- a/packages/core/src/serve/options.ts +++ b/packages/core/src/serve/options.ts @@ -23,6 +23,7 @@ export type HttpServeOptions = { host: string; port: number; path: string; + publicOrigin?: string | undefined; auth: HttpBasicAuthOptions; warnUnauthenticatedNetwork: boolean; loopback: boolean; @@ -103,6 +104,7 @@ export function resolveServeOptions( host, port, path, + ...(serverUrl ? { publicOrigin: serverUrl.origin } : {}), auth, warnUnauthenticatedNetwork: !loopback && !auth.enabled, loopback, diff --git a/packages/core/test/cli-completion.test.ts b/packages/core/test/cli-completion.test.ts index 3975b1cc..90eb7b5e 100644 --- a/packages/core/test/cli-completion.test.ts +++ b/packages/core/test/cli-completion.test.ts @@ -117,7 +117,7 @@ describe("CLI completion resolver", () => { "repo", ]); await expect(completeCliWords(["call-tool", "git"], { configPath })).resolves.toEqual([ - "github.", + "github", ]); await expect(completeCliWords(["auth", "login", ""], { configPath })).resolves.toEqual([ "github", @@ -153,7 +153,7 @@ describe("CLI completion resolver", () => { }); dirs.push(dir); - await expect(completeCliWords(["get-prompt", ""], { configPath })).resolves.toEqual(["docs."]); + await expect(completeCliWords(["get-prompt", ""], { configPath })).resolves.toEqual(["docs"]); await expect(completeCliWords(["read-resource", ""], { configPath })).resolves.toEqual([ "docs", ]); @@ -192,6 +192,17 @@ describe("CLI completion resolver", () => { }); dirs.push(dir); + await expect(completeCliWords(["call-tool", ""], { configPath })).resolves.toEqual([ + "repo", + "status_api", + ]); + await expect(completeCliWords(["call-tool", "repo", ""], { configPath })).resolves.toEqual([ + "status", + "build", + ]); + await expect(completeCliWords(["get-tool", "status_api", ""], { configPath })).resolves.toEqual( + ["check"], + ); await expect(completeCliWords(["call-tool", "repo."], { configPath })).resolves.toEqual([ "repo.status", "repo.build", @@ -201,6 +212,28 @@ describe("CLI completion resolver", () => { ]); }); + it("does not discover tool names when completing option flags after a split target", async () => { + const { dir, configPath } = writeCompletionConfig({ + mcpServers: { + repo: { + name: "Repo", + description: "Repository MCP server for completion tests.", + command: "node", + }, + }, + }); + dirs.push(dir); + const listTools = vi.fn(async () => [{ name: "status" }]); + + await expect( + completeCliWords(["call-tool", "repo", "--format"], { + configPath, + managers: { listTools }, + }), + ).resolves.toEqual([]); + expect(listTools).not.toHaveBeenCalled(); + }); + it("uses cached discovered tool names when live discovery times out", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-completion-cache-")); dirs.push(dir); @@ -355,6 +388,24 @@ describe("CLI completion resolver", () => { ).resolves.toEqual(["file:///repo/{path}"]); }); + it("suggests split and dotted prompt targets", async () => { + const { dir, configPath } = writeMcpConfigWithDir("docs"); + dirs.push(dir); + + await expect( + completeCliWords(["get-prompt", "docs", ""], { + configPath, + managers: { listPrompts: async () => [{ name: "summarize" }] }, + }), + ).resolves.toEqual(["summarize"]); + await expect( + completeCliWords(["get-prompt", "docs."], { + configPath, + managers: { listPrompts: async () => [{ name: "summarize" }] }, + }), + ).resolves.toEqual(["docs.summarize"]); + }); + it("returns no suggestions instead of throwing when config loading fails", async () => { await expect( completeCliWords(["get-caplet", ""], { configPath: "/missing/config.json" }), diff --git a/packages/core/test/cli.test.ts b/packages/core/test/cli.test.ts index cbee7135..69b311a0 100644 --- a/packages/core/test/cli.test.ts +++ b/packages/core/test/cli.test.ts @@ -539,6 +539,105 @@ describe("cli init", () => { } }); + it("gets a CLI tool with split caplet and tool arguments", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-cli-tool-")); + const configPath = join(dir, "config.json"); + const out: string[] = []; + try { + writeCliOperationConfig(configPath); + await runCli(["get-tool", "local", "echo_json", "--format", "json"], { + env: { CAPLETS_CONFIG: configPath }, + writeOut: (value) => out.push(value), + }); + + expect(JSON.parse(out.join(""))).toMatchObject({ tool: { name: "echo_json" } }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("calls a CLI tool with split caplet and tool arguments", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-cli-tool-")); + const configPath = join(dir, "config.json"); + const out: string[] = []; + try { + writeCliOperationConfig(configPath); + await runCli( + [ + "call-tool", + "local", + "echo_json", + "--args", + JSON.stringify({ message: "hi" }), + "--format", + "json", + ], + { + env: { CAPLETS_CONFIG: configPath }, + writeOut: (value) => out.push(value), + }, + ); + + expect(JSON.parse(out.join(""))).toMatchObject({ + structuredContent: { json: { message: "hi" } }, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("gets an MCP prompt with split caplet and prompt arguments", async () => { + const requests: unknown[] = []; + const out: string[] = []; + const fetchMock = vi.fn( + async (_url: Parameters[0], init?: Parameters[1]) => { + requests.push(JSON.parse(String(init?.body))); + return new Response( + JSON.stringify({ + ok: true, + result: { + description: "Review an issue.", + messages: [{ role: "user", content: { type: "text", text: "Review CAP-123" } }], + }, + }), + { headers: { "content-type": "application/json" } }, + ); + }, + ); + + await runCli( + [ + "get-prompt", + "linear", + "review_issue", + "--args", + JSON.stringify({ issueId: "CAP-123" }), + "--format", + "json", + ], + { + env: { CAPLETS_MODE: "remote", CAPLETS_SERVER_URL: "http://127.0.0.1:5387" }, + fetch: fetchMock, + writeOut: (value) => out.push(value), + }, + ); + + expect(requests).toEqual([ + { + command: "get_prompt", + arguments: { + caplet: "linear", + request: { + operation: "get_prompt", + prompt: "review_issue", + arguments: { issueId: "CAP-123" }, + }, + }, + }, + ]); + expect(JSON.parse(out.join(""))).toMatchObject({ description: "Review an issue." }); + }); + it("prints agent-first summaries by default for direct Caplet operation commands", async () => { const dir = mkdtempSync(join(tmpdir(), "caplets-call-summary-")); const configPath = join(dir, "config.json"); @@ -1877,19 +1976,27 @@ describe("cli completion commands", () => { }); it("runs the hidden completion endpoint", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-cli-completion-")); + const configPath = join(dir, "config.json"); const out: string[] = []; + try { + writeInspectionConfig(configPath); - await runCli(["__complete", "--shell", "bash", "--", "add", ""], { - writeOut: (value) => out.push(value), - }); + await runCli(["__complete", "--shell", "bash", "--", "add", ""], { + env: { CAPLETS_MODE: "local", CAPLETS_CONFIG: configPath }, + writeOut: (value) => out.push(value), + }); - expect(out.join("").split("\n").filter(Boolean)).toEqual([ - "cli", - "mcp", - "openapi", - "graphql", - "http", - ]); + expect(out.join("").split("\n").filter(Boolean)).toEqual([ + "cli", + "mcp", + "openapi", + "graphql", + "http", + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } }); it("maps the PowerShell trailing-space sentinel before resolving completions", async () => { @@ -1906,11 +2013,7 @@ describe("cli completion commands", () => { }, ); - expect(out.join("").split("\n").filter(Boolean)).toEqual([ - "catalog.", - "filesystem.", - "users.", - ]); + expect(out.join("").split("\n").filter(Boolean)).toEqual(["catalog", "filesystem", "users"]); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -1932,6 +2035,23 @@ describe("cli completion commands", () => { rmSync(dir, { recursive: true, force: true }); } }); + + it("uses engine-backed discovery for local hidden tool completion", async () => { + const dir = mkdtempSync(join(tmpdir(), "caplets-cli-completion-")); + const configPath = join(dir, "config.json"); + const out: string[] = []; + try { + writeOpenApiCompletionConfig(configPath); + await runCli(["__complete", "--shell", "bash", "--", "get-tool", "users."], { + env: { CAPLETS_CONFIG: configPath }, + writeOut: (value) => out.push(value), + }); + + expect(out.join("").split("\n").filter(Boolean)).toEqual(["users.lookupUser"]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); function writeInspectionConfig(path: string): void { @@ -1976,6 +2096,47 @@ function writeInspectionConfig(path: string): void { ); } +function writeOpenApiCompletionConfig(path: string): void { + const dir = dirname(path); + mkdirSync(dir, { recursive: true }); + const specPath = join(dir, "openapi.json"); + writeFileSync( + specPath, + JSON.stringify({ + openapi: "3.0.3", + info: { title: "Users API", version: "1.0.0" }, + servers: [{ url: "https://api.example.com" }], + paths: { + "/users/{id}": { + get: { + operationId: "lookupUser", + responses: { "200": { description: "OK" } }, + }, + }, + }, + }), + ); + writeFileSync( + path, + JSON.stringify({ + openapiEndpoints: { + users: { + name: "Users API", + description: "Manage users through OpenAPI.", + specPath, + auth: { type: "none" }, + }, + }, + completion: { + discoveryTimeoutMs: 250, + overallTimeoutMs: 500, + cacheTtlMs: 0, + negativeCacheTtlMs: 0, + }, + }), + ); +} + function writeCliOperationConfig(path: string): void { const dir = dirname(path); mkdirSync(dir, { recursive: true }); diff --git a/packages/core/test/serve-http.test.ts b/packages/core/test/serve-http.test.ts index 0183a9ed..b42d3ddd 100644 --- a/packages/core/test/serve-http.test.ts +++ b/packages/core/test/serve-http.test.ts @@ -304,6 +304,40 @@ describe("createHttpServeApp", () => { await engine.close(); }); + it("uses CAPLETS_SERVER_URL public scheme for remote auth callback URLs", async () => { + const context = testContext({ oauth: true }); + const engine = new CapletsEngine({ + configPath: context.configPath, + projectConfigPath: context.projectConfigPath, + watch: false, + }); + const app = createHttpServeApp( + httpOptions({ path: "/caplets", publicOrigin: "https://caplets.example.com" }), + engine, + { + writeErr: () => {}, + control: context, + }, + ); + + const response = await app.request("http://127.0.0.1:5387/caplets/control", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ command: "auth_login_start", arguments: { server: "remote" } }), + }); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body).toEqual(expect.objectContaining({ ok: true })); + const result = (body as { result: { authorizationUrl: string } }).result; + const authorizationUrl = new URL(result.authorizationUrl); + expect(authorizationUrl.searchParams.get("redirect_uri")).toMatch( + /^https:\/\/caplets\.example\.com\/caplets\/control\/auth\/callback\//u, + ); + + await engine.close(); + }); + it("ignores forwarded host and proto for remote auth callback URLs by default", async () => { const context = testContext({ oauth: true }); const engine = new CapletsEngine({ @@ -457,6 +491,7 @@ function httpOptions(overrides: Partial = {}): HttpServeOption host: "127.0.0.1", port: 5387, path: "/", + publicOrigin: undefined, auth: { enabled: false, user: "caplets" }, warnUnauthenticatedNetwork: false, loopback: true, diff --git a/packages/core/test/serve-options.test.ts b/packages/core/test/serve-options.test.ts index 51b1e1b0..cbe6400e 100644 --- a/packages/core/test/serve-options.test.ts +++ b/packages/core/test/serve-options.test.ts @@ -34,6 +34,22 @@ describe("resolveServeOptions", () => { port: 7890, path: "/caplets", auth: { enabled: true, user: "caplets", password: testPassword }, + publicOrigin: "http://localhost:7890", + }); + }); + + it("preserves HTTPS CAPLETS_SERVER_URL as the public origin", () => { + expect( + resolveServeOptions( + { transport: "http", allowUnauthenticatedHttp: true }, + { CAPLETS_SERVER_URL: "https://caplets.example.com/caplets" }, + ), + ).toMatchObject({ + transport: "http", + host: "caplets.example.com", + port: 5387, + path: "/caplets", + publicOrigin: "https://caplets.example.com", }); });