diff --git a/src/cli/command-catalog.ts b/src/cli/command-catalog.ts index 56870c1f630..c58f8b86209 100644 --- a/src/cli/command-catalog.ts +++ b/src/cli/command-catalog.ts @@ -55,7 +55,14 @@ export const cliCommandCatalog: readonly CliCommandCatalogEntry[] = [ { commandPath: ["gateway", "status"], exact: true, - policy: { routeConfigGuard: "always" }, + policy: { + routeConfigGuard: "always", + // `gateway status` is a built-in daemon/RPC health path. Loading the + // full plugin registry here eagerly scans and validates every channel + // plugin before the command can even connect to the already-running + // gateway, which makes this frequently-used status check painfully slow. + loadPlugins: "never", + }, route: { id: "gateway-status" }, }, { diff --git a/src/cli/daemon-cli/probe.ts b/src/cli/daemon-cli/probe.ts index fd0f53e4463..455addcd67c 100644 --- a/src/cli/daemon-cli/probe.ts +++ b/src/cli/daemon-cli/probe.ts @@ -42,32 +42,8 @@ export async function probeGatewayStatus(opts: { enabled: opts.json !== true, }, async () => { - if (opts.requireRpc) { - const { callGateway } = await import("../../gateway/call.js"); - await callGateway({ - url: opts.url, - token: opts.token, - password: opts.password, - tlsFingerprint: opts.tlsFingerprint, - method: "status", - timeoutMs: opts.timeoutMs, - ...(opts.configPath ? { configPath: opts.configPath } : {}), - }); - const { probeGateway } = await loadProbeGatewayModule(); - const authProbe = await probeGateway({ - url: opts.url, - auth: { - token: opts.token, - password: opts.password, - }, - tlsFingerprint: opts.tlsFingerprint, - timeoutMs: opts.timeoutMs, - includeDetails: false, - }).catch(() => null); - return { ok: true as const, authProbe }; - } const { probeGateway } = await loadProbeGatewayModule(); - return await probeGateway({ + const probe = await probeGateway({ url: opts.url, auth: { token: opts.token, @@ -75,12 +51,13 @@ export async function probeGatewayStatus(opts: { }, tlsFingerprint: opts.tlsFingerprint, timeoutMs: opts.timeoutMs, - includeDetails: false, + includeDetails: opts.requireRpc === true, + detailLevel: opts.requireRpc === true ? "full" : "none", }); + return probe; }, ); - const auth = - "auth" in result ? result.auth : "authProbe" in result ? result.authProbe?.auth : undefined; + const auth = result.auth; if (result.ok) { return { ok: true, @@ -89,8 +66,8 @@ export async function probeGatewayStatus(opts: { kind === "read" ? auth?.capability && auth.capability !== "unknown" ? auth.capability - : // The status RPC proves read access even when a follow-up hello probe - // cannot recover richer scope metadata. + : // A successful detailed probe performs read RPCs, so it proves read access + // even when hello metadata cannot recover richer scope metadata. "read_only" : auth?.capability, auth, diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index 5d09407f3fd..96a0b902716 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -207,6 +207,7 @@ describe("gatherDaemonStatus", () => { expect(status.gateway?.probeUrl).toBe("wss://127.0.0.1:19001"); expect(status.rpc?.url).toBe("wss://127.0.0.1:19001"); expect(status.rpc?.ok).toBe(true); + expect(inspectGatewayRestart).not.toHaveBeenCalled(); }); it("forwards requireRpc and configPath to the daemon probe", async () => { @@ -542,7 +543,12 @@ describe("gatherDaemonStatus", () => { expect(status.rpc).toBeUndefined(); }); - it("surfaces stale gateway listener pids from restart health inspection", async () => { + it("surfaces stale gateway listener pids from restart health inspection when probe fails", async () => { + callGatewayStatusProbe.mockResolvedValueOnce({ + ok: false, + url: "ws://127.0.0.1:19001", + error: "timeout", + }); inspectGatewayRestart.mockResolvedValueOnce({ runtime: { status: "running", pid: 8000 }, portUsage: { diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index b19d0b1640e..657c8b81ae8 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -207,13 +207,18 @@ async function loadDaemonConfigContext( resolveStateDir(mergedDaemonEnv as NodeJS.ProcessEnv), ); - const cliIO = createConfigIO({ env: process.env, configPath: cliConfigPath }); + const cliIO = createConfigIO({ + env: process.env, + configPath: cliConfigPath, + pluginValidation: "skip", + }); const sharesDaemonConfigContext = !serviceEnv && cliConfigPath === daemonConfigPath; const daemonIO = sharesDaemonConfigContext ? cliIO : createConfigIO({ env: mergedDaemonEnv, configPath: daemonConfigPath, + pluginValidation: "skip", }); const cliSnapshotPromise = cliIO.readConfigFileSnapshot().catch(() => null); @@ -444,7 +449,7 @@ export async function gatherDaemonStatus( rpcAuthWarning = undefined; } const health = - opts.probe && loaded + opts.probe && loaded && rpc?.ok !== true ? await loadRestartHealthModule() .then(({ inspectGatewayRestart }) => inspectGatewayRestart({ diff --git a/src/config/io.ts b/src/config/io.ts index df539ffafcd..ebf9c197e8c 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -1144,7 +1144,9 @@ async function finalizeReadConfigSnapshotInternalResult( return result; } -export function createConfigIO(overrides: ConfigIoDeps = {}) { +export function createConfigIO( + overrides: ConfigIoDeps & { pluginValidation?: "full" | "skip" } = {}, +) { const deps = normalizeDeps(overrides); const configPath = resolveConfigPathForDeps(deps); @@ -1260,7 +1262,10 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { if (preValidationDuplicates.length > 0) { throw new DuplicateAgentDirError(preValidationDuplicates); } - const validated = validateConfigObjectWithPlugins(effectiveConfigRaw, { env: deps.env }); + const validated = validateConfigObjectWithPlugins(effectiveConfigRaw, { + env: deps.env, + pluginValidation: overrides.pluginValidation, + }); if (!validated.ok) { observeLoadConfigSnapshot({ ...createConfigFileSnapshot({ @@ -1436,7 +1441,10 @@ export function createConfigIO(overrides: ConfigIoDeps = {}) { const legacyResolution = resolveLegacyConfigForRead(resolvedConfigRaw, effectiveParsed); const effectiveConfigRaw = legacyResolution.effectiveConfigRaw; fallbackSourceConfig = coerceConfig(effectiveConfigRaw); - const validated = validateConfigObjectWithPlugins(effectiveConfigRaw, { env: deps.env }); + const validated = validateConfigObjectWithPlugins(effectiveConfigRaw, { + env: deps.env, + pluginValidation: overrides.pluginValidation, + }); if (!validated.ok) { return await finalizeReadConfigSnapshotInternalResult(deps, { snapshot: createConfigFileSnapshot({ diff --git a/src/config/validation.ts b/src/config/validation.ts index 1278cb8f641..89ae9eea326 100644 --- a/src/config/validation.ts +++ b/src/config/validation.ts @@ -708,21 +708,29 @@ type ValidateConfigWithPluginsResult = export function validateConfigObjectWithPlugins( raw: unknown, - params?: { env?: NodeJS.ProcessEnv }, + params?: { env?: NodeJS.ProcessEnv; pluginValidation?: "full" | "skip" }, ): ValidateConfigWithPluginsResult { - return validateConfigObjectWithPluginsBase(raw, { applyDefaults: true, env: params?.env }); + return validateConfigObjectWithPluginsBase(raw, { + applyDefaults: true, + env: params?.env, + pluginValidation: params?.pluginValidation ?? "full", + }); } export function validateConfigObjectRawWithPlugins( raw: unknown, - params?: { env?: NodeJS.ProcessEnv }, + params?: { env?: NodeJS.ProcessEnv; pluginValidation?: "full" | "skip" }, ): ValidateConfigWithPluginsResult { - return validateConfigObjectWithPluginsBase(raw, { applyDefaults: false, env: params?.env }); + return validateConfigObjectWithPluginsBase(raw, { + applyDefaults: false, + env: params?.env, + pluginValidation: params?.pluginValidation ?? "full", + }); } function validateConfigObjectWithPluginsBase( raw: unknown, - opts: { applyDefaults: boolean; env?: NodeJS.ProcessEnv }, + opts: { applyDefaults: boolean; env?: NodeJS.ProcessEnv; pluginValidation?: "full" | "skip" }, ): ValidateConfigWithPluginsResult { const base = opts.applyDefaults ? validateConfigObject(raw) : validateConfigObjectRaw(raw); if (!base.ok) { @@ -730,6 +738,14 @@ function validateConfigObjectWithPluginsBase( } const config = base.config; + if (opts.pluginValidation === "skip") { + return { + ok: true, + config, + warnings: [], + }; + } + const issues: ConfigValidationIssue[] = []; const warnings: ConfigValidationIssue[] = []; const hasExplicitPluginsConfig =