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: 6 additions & 0 deletions .changeset/gentle-rivers-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@caplets/core": patch
"caplets": patch
---

Fix `caplets doctor` Project Binding diagnostics for authenticated self-hosted remotes so supported session routes are reported as supported instead of always unsupported.
1 change: 1 addition & 0 deletions packages/core/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2557,6 +2557,7 @@ export function createProgram(io: CliIO = {}): Command {
.action(async (options: { json?: boolean }) => {
const doctorOptions = {
env,
...(io.fetch ? { fetch: io.fetch } : {}),
...(io.authDir ? { authDir: io.authDir } : {}),
...(io.daemon ? { daemon: io.daemon } : {}),
};
Expand Down
209 changes: 203 additions & 6 deletions packages/core/src/cli/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
resolveCapletsRemote,
resolveHostedCloudRemote,
resolveRemoteMode,
type ResolvedCapletsRemote,
} from "../remote/options";
import { createRemoteProfileStore } from "../remote/profile-store";
import type { RemoteProfileCredential, RemoteProfileStatus } from "../remote/profiles";
Expand All @@ -32,6 +33,8 @@ import { loadConfig, loadLocalOverlayConfigWithSources, type CapletConfig } from
import { resolveExposure } from "../exposure/policy";
import { daemonStatus, type DaemonOperationOptions } from "../daemon";

const PROJECT_BINDING_DOCTOR_PROBE_TIMEOUT_MS = 5_000;

export type DoctorOptions = {
env?: NodeJS.ProcessEnv | Record<string, string | undefined>;
cwd?: string;
Expand All @@ -40,6 +43,8 @@ export type DoctorOptions = {
authDir?: string;
observedOutputShapeCacheDir?: string;
daemon?: DaemonOperationOptions;
fetch?: typeof fetch;
projectBindingProbeTimeoutMs?: number;
};

export type DoctorJsonReport = {
Expand All @@ -62,6 +67,7 @@ export async function doctorJsonReport(options: DoctorOptions = {}): Promise<Doc
const remoteLogin = await resolveRemoteLoginSection(options, env);
const server = resolveServerSection(env);
const remote = resolveRemoteSection(env, remoteLogin);
const sessionSupport = await resolveProjectBindingSessionSupport(options, env, remoteLogin);

return {
server,
Expand All @@ -80,10 +86,10 @@ export async function doctorJsonReport(options: DoctorOptions = {}): Promise<Doc
: "unconfigured",
selectedWorkspace: remoteLogin.selectedWorkspace ?? remote.workspace ?? null,
webSocketUrl: remote.webSocketUrl,
sessionSupport: remoteLogin.kind === "self-hosted" ? "unsupported" : "unknown",
sessionSupport: sessionSupport.value,
lease: null,
lastUpgradeError: null,
recoveryCommand: projectBindingRecovery(remoteLogin, remote),
lastUpgradeError: sessionSupport.lastError,
recoveryCommand: projectBindingRecovery(remoteLogin, remote, sessionSupport.value),
},
sync: {
state: options.syncStatus?.state ?? "idle",
Expand Down Expand Up @@ -238,18 +244,205 @@ function vaultIssueFromWarning(message: string, path: string) {
function projectBindingRecovery(
remoteLogin: DoctorRemoteLoginSection,
remote: Record<string, unknown>,
sessionSupport: ProjectBindingSessionSupport,
): string {
if (remoteLogin.authenticated) {
return remoteLogin.kind === "self-hosted"
? "Self-hosted Project Binding sessions are not implemented by this runtime."
: "caplets attach --once";
if (remoteLogin.kind === "cloud") return "caplets attach --once";
if (sessionSupport === "supported") return "caplets attach --once";
if (sessionSupport === "unsupported") {
return "Upgrade the remote Caplets service and rerun caplets doctor.";
}
return "caplets doctor";
Comment thread
ian-pascoe marked this conversation as resolved.
}
if (remote.configured || remoteLogin.configured) {
return `caplets remote login ${remoteLogin.hostUrl ?? "<url>"}`;
}
return "caplets remote login <url>";
}

type ProjectBindingSessionSupport = "supported" | "unsupported" | "unknown";

async function resolveProjectBindingSessionSupport(
options: DoctorOptions,
env: NodeJS.ProcessEnv | Record<string, string | undefined>,
remoteLogin: DoctorRemoteLoginSection,
): Promise<{ value: ProjectBindingSessionSupport; lastError: string | null }> {
if (remoteLogin.kind !== "self-hosted") return { value: "unknown", lastError: null };
if (!remoteLogin.authenticated || !remoteLogin.credential?.accessToken) {
return { value: "unknown", lastError: null };
}

let remote: ResolvedCapletsRemote;
try {
remote = resolveCapletsRemote(selfHostedDoctorInput(remoteLogin), env);
} catch (error) {
return { value: "unknown", lastError: errorMessage(error) };
}

const fetchImpl = options.fetch ?? remote.fetch ?? fetch;
const timeoutMs = options.projectBindingProbeTimeoutMs ?? PROJECT_BINDING_DOCTOR_PROBE_TIMEOUT_MS;
const headers = new Headers(remote.requestInit.headers);
headers.set("content-type", "application/json");
try {
const response = await fetchWithTimeout(
fetchImpl,
projectBindingSessionsUrl(remote),
timeoutMs,
{
...remote.requestInit,
method: "POST",
headers,
body: JSON.stringify({ diagnosticProbe: true }),
},
);
if (response.status === 201) {
const created = await readDiagnosticSessionResponse(response);
if (!created.ok) return { value: "unknown", lastError: created.error };
const bindingId =
typeof created.value.binding?.bindingId === "string"
? created.value.binding.bindingId
: undefined;
const sessionId =
typeof created.value.sessionId === "string" ? created.value.sessionId : undefined;
if (!bindingId || !sessionId) {
return {
value: "unknown",
lastError:
"Project Binding diagnostic session response was missing bindingId or sessionId; cleanup was not attempted.",
};
}
const cleanupError = await endProjectBindingDiagnosticSession(
fetchImpl,
remote,
bindingId,
sessionId,
timeoutMs,
);
if (cleanupError) {
return { value: "unknown", lastError: cleanupError };
}
return { value: "supported", lastError: null };
Comment thread
ian-pascoe marked this conversation as resolved.
}
if (response.status === 400) return { value: "supported", lastError: null };

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Gate support on the attach path that starts binding

For a non-loopback self-hosted remote, this treats the current server's 400 from /project-bindings/sessions as supported and then recommends caplets attach --once, but the attach/native path only creates a self-hosted Project Binding manager when isLoopbackRemote(remoteOptions) (packages/core/src/native/service.ts:1601-1634) and only forwards attach-session metadata for loopback remotes (packages/core/src/native/service.ts:798-805). In that environment doctor now advertises a recovery path that the client never starts, so remote project-bound Caplets remain hidden; gate this supported result on the same capability or wire the non-loopback path before advertising it.

Useful? React with 👍 / 👎.

if (response.status >= 200 && response.status < 300) {
return { value: "supported", lastError: null };
}
if (response.status === 404 || response.status === 405 || response.status === 501) {
Comment thread
ian-pascoe marked this conversation as resolved.
return {
value: "unsupported",
lastError: `Project Binding session endpoint returned ${response.status}.`,
};
}
if (response.status === 401 || response.status === 403) {
return {
value: "unknown",
lastError: `Project Binding session probe was not authorized (${response.status}).`,
};
Comment on lines +336 to +340

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route unauthorized probes to remote login

When a saved self-hosted profile still looks authenticated locally but the server has revoked or rejected its bearer token, the protected Project Binding route returns 401/403; this branch records only unknown, and projectBindingRecovery then emits caplets doctor, so the user is told to rerun the same failing probe instead of re-establishing Remote Login. Treat this unauthorized probe result as a credentials problem (for example, recover with caplets remote login <url>) so revoked/stale credentials do not leave doctor in a loop.

Useful? React with 👍 / 👎.

}
return {
value: "unknown",
lastError: `Project Binding session probe returned ${response.status}.`,
};
} catch (error) {
return { value: "unknown", lastError: errorMessage(error) };
}
}

function projectBindingSessionsUrl(remote: ResolvedCapletsRemote): URL {
return controlProjectBindingUrl(remote, "sessions");
}

async function readDiagnosticSessionResponse(response: Response): Promise<
| {
ok: true;
value: {
binding?: { bindingId?: unknown };
sessionId?: unknown;
};
}
| { ok: false; error: string }
> {
try {
return {
ok: true,
value: (await response.json()) as {
binding?: { bindingId?: unknown };
sessionId?: unknown;
},
};
} catch (error) {
return {
ok: false,
error: `Project Binding diagnostic session response could not be parsed; cleanup was not attempted: ${errorMessage(error)}`,
};
}
}

async function endProjectBindingDiagnosticSession(
fetchImpl: typeof fetch,
remote: ResolvedCapletsRemote,
bindingId: string,
sessionId: string,
timeoutMs: number,
): Promise<string | null> {
const headers = new Headers(remote.requestInit.headers);
headers.set("content-type", "application/json");
try {
const response = await fetchWithTimeout(
fetchImpl,
controlProjectBindingUrl(remote, `${encodeURIComponent(bindingId)}/session`),
timeoutMs,
{
...remote.requestInit,
method: "DELETE",
headers,
body: JSON.stringify({
sessionId,
terminalReason: {
code: "completed",
message: "Project Binding diagnostic probe completed.",
},
}),
},
);
return response.ok ? null : `Project Binding diagnostic cleanup returned ${response.status}.`;
} catch (error) {
return `Project Binding diagnostic cleanup failed: ${errorMessage(error)}`;
}
}

async function fetchWithTimeout(
fetchImpl: typeof fetch,
url: URL,
timeoutMs: number,
init: RequestInit,
): Promise<Response> {
const controller = new AbortController();
const timeout = setTimeout(
() =>
controller.abort(new Error(`Project Binding session probe timed out after ${timeoutMs}ms.`)),
timeoutMs,
);
try {
return await fetchImpl(url, {
...init,
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
}

function controlProjectBindingUrl(remote: ResolvedCapletsRemote, suffix: string): URL {
const url = new URL(remote.projectBindingWebSocketUrl);
if (url.protocol === "wss:") url.protocol = "https:";
if (url.protocol === "ws:") url.protocol = "http:";
url.pathname = url.pathname.replace(/\/connect$/u, `/${suffix}`);
url.search = "";
url.hash = "";
return url;
}

async function resolveDaemonSection(
env: NodeJS.ProcessEnv | Record<string, string | undefined>,
options: DaemonOperationOptions | undefined,
Expand Down Expand Up @@ -572,6 +765,10 @@ function observedOutputShapePath(value: unknown): string | undefined {
: undefined;
}

function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}

function allCaplets(config: { [key: string]: unknown }): CapletConfig[] {
const typed = config as {
mcpServers?: Record<string, CapletConfig>;
Expand Down
Loading