Skip to content
Closed
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
114 changes: 112 additions & 2 deletions apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import * as Effect from "effect/Effect";
import * as FileSystem from "effect/FileSystem";
import * as Path from "effect/Path";
import * as Schema from "effect/Schema";
import * as TestClock from "effect/testing/TestClock";

import {
buildClaudeCapabilitiesProbeQueryOptions,
CLAUDE_CAPABILITIES_PROBE_SETTING_SOURCES,
probeClaudeCapabilities,
resolveClaudeEnvironmentHomePath,
} from "./ClaudeProvider.ts";

const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
Expand Down Expand Up @@ -38,15 +40,34 @@ it("isolates Claude capability probes without dropping workspace setting sources
assert.equal(options.env?.ENABLE_CLAUDEAI_MCP_SERVERS, "false");
});

it("derives the child home with HOME then USERPROFILE precedence", () => {
assert.equal(
resolveClaudeEnvironmentHomePath(
{
HOME: "/posix-home",
USERPROFILE: "C:\\windows-profile",
},
"/process-home",
),
"/posix-home",
);
assert.equal(
resolveClaudeEnvironmentHomePath({ USERPROFILE: "C:\\windows-profile" }, "/process-home"),
"C:\\windows-profile",
);
assert.equal(resolveClaudeEnvironmentHomePath({}, "/process-home"), "/process-home");
});

it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
it.effect("serializes strict no-MCP options and still resolves account capabilities", () =>
it.effect("serializes probe options and keeps core capabilities independent from skills", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const tempDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-claude-probe-sdk-" });
const executablePath = path.join(tempDir, "fake-claude.mjs");
const invocationPath = path.join(tempDir, "invocation.json");
const workspaceCwd = path.join(tempDir, "workspace");
const inheritedConfigDir = path.join(tempDir, "inherited-claude-config");
yield* fs.makeDirectory(workspaceCwd, { recursive: true });

yield* fs.writeFileString(
Expand All @@ -72,7 +93,20 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
"const lines = createInterface({ input: process.stdin });",
'lines.on("line", (line) => {',
" const message = JSON.parse(line);",
' if (message.type !== "control_request" || message.request?.subtype !== "initialize") return;',
' if (message.type !== "control_request") return;',
' if (message.request?.subtype === "reload_skills") {',
' if (process.env.T3_PROBE_SKIP_SKILL_RESPONSE === "true") return;',
" process.stdout.write(JSON.stringify({",
' type: "control_response",',
" response: {",
' subtype: "success",',
" request_id: message.request_id,",
' response: { skills: [{ name: "probe-fake-skill", description: "Fake skill (user)", argumentHint: "" }] },',
" },",
' }) + "\\n");',
" return;",
" }",
' if (message.request?.subtype !== "initialize") return;',
" process.stdout.write(JSON.stringify({",
' type: "control_response",',
" response: {",
Expand All @@ -99,12 +133,17 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
decodeClaudeSettings({ binaryPath: executablePath }),
{
...process.env,
CLAUDE_CONFIG_DIR: inheritedConfigDir,
T3_PROBE_INVOCATION_PATH: invocationPath,
ENABLE_CLAUDEAI_MCP_SERVERS: "true",
},
workspaceCwd,
);

// The fake skill dir doesn't exist on disk, so the mapper falls back to a
// constructed path under the inherited config dir and takes the scope
// from the stripped `(user)` description suffix.
const expectedSkillPath = path.join(inheritedConfigDir, "skills", "probe-fake-skill");
assert.deepEqual(capabilities, {
email: "dev@example.com",
subscriptionType: "pro",
Expand All @@ -117,6 +156,77 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => {
input: { hint: "[path]" },
},
],
skills: [
{
name: "probe-fake-skill",
path: expectedSkillPath,
enabled: true,
description: "Fake skill",
scope: "user",
},
],
});

const childHome = path.join(tempDir, "child-home");
const capabilitiesFromChildHome = yield* probeClaudeCapabilities(
decodeClaudeSettings({ binaryPath: executablePath }),
{
...process.env,
CLAUDE_CONFIG_DIR: undefined,
HOME: childHome,
USERPROFILE: path.join(tempDir, "other-profile"),
T3_PROBE_INVOCATION_PATH: invocationPath,
},
workspaceCwd,
);
assert.equal(
capabilitiesFromChildHome?.skills[0]?.path,
path.join(childHome, ".claude", "skills", "probe-fake-skill"),
);

const capabilitiesFromTildeConfigDir = yield* probeClaudeCapabilities(
decodeClaudeSettings({ binaryPath: executablePath }),
{
...process.env,
CLAUDE_CONFIG_DIR: "~/custom-claude",
HOME: childHome,
T3_PROBE_INVOCATION_PATH: invocationPath,
},
workspaceCwd,
);
assert.equal(
capabilitiesFromTildeConfigDir?.skills[0]?.path,
path.join(childHome, "custom-claude", "skills", "probe-fake-skill"),
);

const capabilitiesWithoutSkills = yield* probeClaudeCapabilities(
decodeClaudeSettings({ binaryPath: executablePath }),
{
...process.env,
T3_PROBE_INVOCATION_PATH: invocationPath,
T3_PROBE_SKIP_SKILL_RESPONSE: "true",
},
workspaceCwd,
{
// A reload may outlive the initialization budget without erasing the
// account and command data that initialization already returned.
initializationMs: 1_000,
skillsReloadMs: 1_250,
},
).pipe(TestClock.withLive);
assert.deepEqual(capabilitiesWithoutSkills, {
email: "dev@example.com",
subscriptionType: "pro",
tokenSource: "oauth",
apiProvider: undefined,
slashCommands: [
{
name: "review",
description: "Review changes",
input: { hint: "[path]" },
},
],
skills: [],
});

// @effect-diagnostics-next-line preferSchemaOverJson:off
Expand Down
131 changes: 93 additions & 38 deletions apps/server/src/provider/Layers/ClaudeProvider.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import * as NodeOS from "node:os";

import {
type ClaudeSettings,
type ModelCapabilities,
type ModelSelection,
type ServerProviderModel,
type ServerProviderSkill,
type ServerProviderSlashCommand,
} from "@t3tools/contracts";
import * as DateTime from "effect/DateTime";
Expand Down Expand Up @@ -40,6 +43,7 @@ import {
} from "../providerSnapshot.ts";
import { resolveClaudeSdkExecutablePath } from "../Drivers/ClaudeExecutable.ts";
import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
import { parseClaudeReloadedSkills, pathExistsSync } from "./ClaudeSkillDiscovery.ts";

const DEFAULT_CLAUDE_MODEL_CAPABILITIES: ModelCapabilities = createModelCapabilities({
optionDescriptors: [],
Expand Down Expand Up @@ -510,6 +514,23 @@ function apiProviderAuthMetadata(
// `undefined` and left the provider unverified and unselectable in the picker.
const CAPABILITIES_PROBE_TIMEOUT_MS = 25_000;

const SKILLS_RELOAD_TIMEOUT_MS = 5_000;

type ClaudeCapabilitiesProbeTimeouts = {
readonly initializationMs?: number;
readonly skillsReloadMs?: number;
};

function resolveClaudeEnvironmentHomePath(
environment: NodeJS.ProcessEnv,
fallbackHomePath = NodeOS.homedir(),
): string {
// HOME is the POSIX home variable and is also intentionally honored when a
// caller supplies it cross-platform; USERPROFILE covers the normal Windows
// environment when HOME is absent.
return environment.HOME ?? environment.USERPROFILE ?? fallbackHomePath;
}

/**
* Keep workspace-scoped command discovery intact while isolating the periodic
* health check from configured MCP servers.
Expand Down Expand Up @@ -564,6 +585,7 @@ type ClaudeCapabilitiesProbe = {
*/
readonly apiProvider: string | undefined;
readonly slashCommands: ReadonlyArray<ServerProviderSlashCommand>;
readonly skills: ReadonlyArray<ServerProviderSkill>;
};

function parseClaudeInitializationCommands(
Expand Down Expand Up @@ -655,58 +677,88 @@ const probeClaudeCapabilities = (
claudeSettings: ClaudeSettings,
environment?: NodeJS.ProcessEnv,
cwd?: string,
timeouts: ClaudeCapabilitiesProbeTimeouts = {},
) => {
const abort = new AbortController();
return Effect.gen(function* () {
const path = yield* Path.Path;
const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment);
const executablePath = yield* resolveClaudeSdkExecutablePath(
claudeSettings.binaryPath,
claudeEnvironment,
);
return yield* Effect.tryPromise(async () => {
const q = claudeQuery({
// Never yield — we only need initialization data, not a conversation.
// This prevents any prompt from reaching the Anthropic API.
// oxlint-disable-next-line require-yield
prompt: (async function* (): AsyncGenerator<SDKUserMessage> {
await waitForAbortSignal(abort.signal);
})(),
options: buildClaudeCapabilitiesProbeQueryOptions({
executablePath,
abortController: abort,
environment: claudeEnvironment,
cwd,
}),
});
const init = await q.initializationResult();
const account = init.account as
| {
readonly email?: string;
readonly subscriptionType?: string;
readonly tokenSource?: string;
readonly apiProvider?: string;
}
| undefined;
return {
email: account?.email,
subscriptionType: account?.subscriptionType,
tokenSource: account?.tokenSource,
apiProvider: account?.apiProvider,
slashCommands: parseClaudeInitializationCommands(init.commands),
} satisfies ClaudeCapabilitiesProbe;
const environmentConfigDir = claudeEnvironment.CLAUDE_CONFIG_DIR?.trim();
// Derive this from the exact environment passed to the CLI. In particular,
// an inherited CLAUDE_CONFIG_DIR remains effective when homePath is empty.
const environmentHomePath = resolveClaudeEnvironmentHomePath(claudeEnvironment);
const expandedEnvironmentConfigDir =
environmentConfigDir === "~"
? environmentHomePath
: environmentConfigDir?.startsWith(`~${path.sep}`)
? path.join(environmentHomePath, environmentConfigDir.slice(2))
: environmentConfigDir;
const claudeConfigDir = expandedEnvironmentConfigDir
? path.resolve(cwd ?? process.cwd(), expandedEnvironmentConfigDir)
: path.join(environmentHomePath, ".claude");
const q = claudeQuery({
// Never yield — we only need initialization data, not a conversation.
// This prevents any prompt from reaching the Anthropic API.
// oxlint-disable-next-line require-yield
prompt: (async function* (): AsyncGenerator<SDKUserMessage> {
await waitForAbortSignal(abort.signal);
})(),
options: buildClaudeCapabilitiesProbeQueryOptions({
executablePath,
abortController: abort,
environment: claudeEnvironment,
cwd,
}),
});
const initResult = yield* Effect.tryPromise(() => q.initializationResult()).pipe(
Effect.timeoutOption(timeouts.initializationMs ?? CAPABILITIES_PROBE_TIMEOUT_MS),
Effect.result,
);
if (Result.isFailure(initResult) || Option.isNone(initResult.success)) {
return undefined;
}
const init = initResult.success.value;
// `reloadSkills` is best-effort: older CLIs may not answer the control
// request (and the SDK never times it out). Give it a separate budget after
// initialization so it cannot discard already-resolved account and command
// capabilities.
const reloadedSkills = yield* Effect.tryPromise(() => q.reloadSkills()).pipe(
Effect.map((reloaded) => reloaded.skills),
Effect.timeoutOption(timeouts.skillsReloadMs ?? SKILLS_RELOAD_TIMEOUT_MS),
Effect.orElseSucceed(() => Option.none<ReadonlyArray<ClaudeSlashCommand>>()),
Effect.map(Option.getOrElse(() => [] as ReadonlyArray<ClaudeSlashCommand>)),
);
const skills = parseClaudeReloadedSkills(reloadedSkills, {
claudeConfigDir,
cwd,
pathExists: pathExistsSync,
});
const account = init.account as
| {
readonly email?: string;
readonly subscriptionType?: string;
readonly tokenSource?: string;
readonly apiProvider?: string;
}
| undefined;
return {
email: account?.email,
subscriptionType: account?.subscriptionType,
tokenSource: account?.tokenSource,
apiProvider: account?.apiProvider,
slashCommands: parseClaudeInitializationCommands(init.commands),
skills,
} satisfies ClaudeCapabilitiesProbe;
Comment thread
colonelpanic8 marked this conversation as resolved.
}).pipe(
Effect.ensuring(
Effect.sync(() => {
if (!abort.signal.aborted) abort.abort();
}),
),
Effect.timeoutOption(CAPABILITIES_PROBE_TIMEOUT_MS),
Effect.result,
Effect.map((result) => {
if (Result.isFailure(result)) return undefined;
return Option.isSome(result.success) ? result.success.value : undefined;
}),
);
};

Expand Down Expand Up @@ -847,6 +899,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
: undefined;
const slashCommands = capabilities?.slashCommands ?? [];
const dedupedSlashCommands = dedupeSlashCommands(slashCommands);
const skills = capabilities?.skills ?? [];

if (!capabilities) {
return buildServerProvider({
Expand All @@ -855,6 +908,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
checkedAt,
models,
slashCommands: dedupedSlashCommands,
skills,
probe: {
installed: true,
version: parsedVersion,
Expand All @@ -876,6 +930,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")(
checkedAt,
models,
slashCommands: dedupedSlashCommands,
skills,
probe: {
installed: true,
version: parsedVersion,
Expand Down Expand Up @@ -934,4 +989,4 @@ export const makePendingClaudeProvider = (
});
});

export { probeClaudeCapabilities };
export { probeClaudeCapabilities, resolveClaudeEnvironmentHomePath };
Loading
Loading