diff --git a/src/auto-reply/reply/commands-acp/runtime-options.ts b/src/auto-reply/reply/commands-acp/runtime-options.ts index 6407bcbb1ad..a3e7bb972a3 100644 --- a/src/auto-reply/reply/commands-acp/runtime-options.ts +++ b/src/auto-reply/reply/commands-acp/runtime-options.ts @@ -46,6 +46,33 @@ async function resolveOptionalSingleTargetOrStop(params: { return target.sessionKey; } +type SingleTargetValue = { + targetSessionKey: string; + value: string; +}; + +async function resolveSingleTargetValueOrStop(params: { + commandParams: HandleCommandsParams; + restTokens: string[]; + usage: string; +}): Promise { + const parsed = parseSingleValueCommandInput(params.restTokens, params.usage); + if (!parsed.ok) { + return stopWithText(`⚠️ ${parsed.error}`); + } + const target = await resolveAcpTargetSessionKey({ + commandParams: params.commandParams, + token: parsed.value.sessionToken, + }); + if (!target.ok) { + return stopWithText(`⚠️ ${target.error}`); + } + return { + targetSessionKey: target.sessionKey, + value: parsed.value.value, + }; +} + export async function handleAcpStatusAction( params: HandleCommandsParams, restTokens: string[], @@ -99,24 +126,22 @@ export async function handleAcpSetModeAction( params: HandleCommandsParams, restTokens: string[], ): Promise { - const parsed = parseSingleValueCommandInput(restTokens, ACP_SET_MODE_USAGE); - if (!parsed.ok) { - return stopWithText(`⚠️ ${parsed.error}`); - } - const target = await resolveAcpTargetSessionKey({ + const resolved = await resolveSingleTargetValueOrStop({ commandParams: params, - token: parsed.value.sessionToken, + restTokens, + usage: ACP_SET_MODE_USAGE, }); - if (!target.ok) { - return stopWithText(`⚠️ ${target.error}`); + if (!("targetSessionKey" in resolved)) { + return resolved; } + const { targetSessionKey, value } = resolved; return await withAcpCommandErrorBoundary({ run: async () => { - const runtimeMode = validateRuntimeModeInput(parsed.value.value); + const runtimeMode = validateRuntimeModeInput(value); const options = await getAcpSessionManager().setSessionRuntimeMode({ cfg: params.cfg, - sessionKey: target.sessionKey, + sessionKey: targetSessionKey, runtimeMode, }); return { @@ -128,7 +153,7 @@ export async function handleAcpSetModeAction( fallbackMessage: "Could not update ACP runtime mode.", onSuccess: ({ runtimeMode, options }) => stopWithText( - `✅ Updated ACP runtime mode for ${target.sessionKey}: ${runtimeMode}. Effective options: ${formatRuntimeOptionsText(options)}`, + `✅ Updated ACP runtime mode for ${targetSessionKey}: ${runtimeMode}. Effective options: ${formatRuntimeOptionsText(options)}`, ), }); } @@ -186,24 +211,22 @@ export async function handleAcpCwdAction( params: HandleCommandsParams, restTokens: string[], ): Promise { - const parsed = parseSingleValueCommandInput(restTokens, ACP_CWD_USAGE); - if (!parsed.ok) { - return stopWithText(`⚠️ ${parsed.error}`); - } - const target = await resolveAcpTargetSessionKey({ + const resolved = await resolveSingleTargetValueOrStop({ commandParams: params, - token: parsed.value.sessionToken, + restTokens, + usage: ACP_CWD_USAGE, }); - if (!target.ok) { - return stopWithText(`⚠️ ${target.error}`); + if (!("targetSessionKey" in resolved)) { + return resolved; } + const { targetSessionKey, value } = resolved; return await withAcpCommandErrorBoundary({ run: async () => { - const cwd = validateRuntimeCwdInput(parsed.value.value); + const cwd = validateRuntimeCwdInput(value); const options = await getAcpSessionManager().updateSessionRuntimeOptions({ cfg: params.cfg, - sessionKey: target.sessionKey, + sessionKey: targetSessionKey, patch: { cwd }, }); return { @@ -215,7 +238,7 @@ export async function handleAcpCwdAction( fallbackMessage: "Could not update ACP cwd.", onSuccess: ({ cwd, options }) => stopWithText( - `✅ Updated ACP cwd for ${target.sessionKey}: ${cwd}. Effective options: ${formatRuntimeOptionsText(options)}`, + `✅ Updated ACP cwd for ${targetSessionKey}: ${cwd}. Effective options: ${formatRuntimeOptionsText(options)}`, ), }); } @@ -224,23 +247,21 @@ export async function handleAcpPermissionsAction( params: HandleCommandsParams, restTokens: string[], ): Promise { - const parsed = parseSingleValueCommandInput(restTokens, ACP_PERMISSIONS_USAGE); - if (!parsed.ok) { - return stopWithText(`⚠️ ${parsed.error}`); - } - const target = await resolveAcpTargetSessionKey({ + const resolved = await resolveSingleTargetValueOrStop({ commandParams: params, - token: parsed.value.sessionToken, + restTokens, + usage: ACP_PERMISSIONS_USAGE, }); - if (!target.ok) { - return stopWithText(`⚠️ ${target.error}`); + if (!("targetSessionKey" in resolved)) { + return resolved; } + const { targetSessionKey, value } = resolved; return await withAcpCommandErrorBoundary({ run: async () => { - const permissionProfile = validateRuntimePermissionProfileInput(parsed.value.value); + const permissionProfile = validateRuntimePermissionProfileInput(value); const options = await getAcpSessionManager().setSessionConfigOption({ cfg: params.cfg, - sessionKey: target.sessionKey, + sessionKey: targetSessionKey, key: "approval_policy", value: permissionProfile, }); @@ -253,7 +274,7 @@ export async function handleAcpPermissionsAction( fallbackMessage: "Could not update ACP permissions profile.", onSuccess: ({ permissionProfile, options }) => stopWithText( - `✅ Updated ACP permissions profile for ${target.sessionKey}: ${permissionProfile}. Effective options: ${formatRuntimeOptionsText(options)}`, + `✅ Updated ACP permissions profile for ${targetSessionKey}: ${permissionProfile}. Effective options: ${formatRuntimeOptionsText(options)}`, ), }); } @@ -262,24 +283,22 @@ export async function handleAcpTimeoutAction( params: HandleCommandsParams, restTokens: string[], ): Promise { - const parsed = parseSingleValueCommandInput(restTokens, ACP_TIMEOUT_USAGE); - if (!parsed.ok) { - return stopWithText(`⚠️ ${parsed.error}`); - } - const target = await resolveAcpTargetSessionKey({ + const resolved = await resolveSingleTargetValueOrStop({ commandParams: params, - token: parsed.value.sessionToken, + restTokens, + usage: ACP_TIMEOUT_USAGE, }); - if (!target.ok) { - return stopWithText(`⚠️ ${target.error}`); + if (!("targetSessionKey" in resolved)) { + return resolved; } + const { targetSessionKey, value } = resolved; return await withAcpCommandErrorBoundary({ run: async () => { - const timeoutSeconds = parseRuntimeTimeoutSecondsInput(parsed.value.value); + const timeoutSeconds = parseRuntimeTimeoutSecondsInput(value); const options = await getAcpSessionManager().setSessionConfigOption({ cfg: params.cfg, - sessionKey: target.sessionKey, + sessionKey: targetSessionKey, key: "timeout", value: String(timeoutSeconds), }); @@ -292,7 +311,7 @@ export async function handleAcpTimeoutAction( fallbackMessage: "Could not update ACP timeout.", onSuccess: ({ timeoutSeconds, options }) => stopWithText( - `✅ Updated ACP timeout for ${target.sessionKey}: ${timeoutSeconds}s. Effective options: ${formatRuntimeOptionsText(options)}`, + `✅ Updated ACP timeout for ${targetSessionKey}: ${timeoutSeconds}s. Effective options: ${formatRuntimeOptionsText(options)}`, ), }); } @@ -301,23 +320,21 @@ export async function handleAcpModelAction( params: HandleCommandsParams, restTokens: string[], ): Promise { - const parsed = parseSingleValueCommandInput(restTokens, ACP_MODEL_USAGE); - if (!parsed.ok) { - return stopWithText(`⚠️ ${parsed.error}`); - } - const target = await resolveAcpTargetSessionKey({ + const resolved = await resolveSingleTargetValueOrStop({ commandParams: params, - token: parsed.value.sessionToken, + restTokens, + usage: ACP_MODEL_USAGE, }); - if (!target.ok) { - return stopWithText(`⚠️ ${target.error}`); + if (!("targetSessionKey" in resolved)) { + return resolved; } + const { targetSessionKey, value } = resolved; return await withAcpCommandErrorBoundary({ run: async () => { - const model = validateRuntimeModelInput(parsed.value.value); + const model = validateRuntimeModelInput(value); const options = await getAcpSessionManager().setSessionConfigOption({ cfg: params.cfg, - sessionKey: target.sessionKey, + sessionKey: targetSessionKey, key: "model", value: model, }); @@ -330,7 +347,7 @@ export async function handleAcpModelAction( fallbackMessage: "Could not update ACP model.", onSuccess: ({ model, options }) => stopWithText( - `✅ Updated ACP model for ${target.sessionKey}: ${model}. Effective options: ${formatRuntimeOptionsText(options)}`, + `✅ Updated ACP model for ${targetSessionKey}: ${model}. Effective options: ${formatRuntimeOptionsText(options)}`, ), }); } diff --git a/src/channels/thread-bindings-policy.ts b/src/channels/thread-bindings-policy.ts index 655a03c2e2c..15f3f5557fe 100644 --- a/src/channels/thread-bindings-policy.ts +++ b/src/channels/thread-bindings-policy.ts @@ -142,13 +142,7 @@ export function resolveThreadBindingIdleTimeoutMsForChannel(params: { channel: string; accountId?: string; }): number { - const channel = normalizeChannelId(params.channel); - const accountId = normalizeAccountId(params.accountId); - const { root, account } = resolveChannelThreadBindings({ - cfg: params.cfg, - channel, - accountId, - }); + const { root, account } = resolveThreadBindingChannelScope(params); return resolveThreadBindingIdleTimeoutMs({ channelIdleHoursRaw: account?.idleHours ?? root?.idleHours, sessionIdleHoursRaw: params.cfg.session?.threadBindings?.idleHours, @@ -160,19 +154,27 @@ export function resolveThreadBindingMaxAgeMsForChannel(params: { channel: string; accountId?: string; }): number { - const channel = normalizeChannelId(params.channel); - const accountId = normalizeAccountId(params.accountId); - const { root, account } = resolveChannelThreadBindings({ - cfg: params.cfg, - channel, - accountId, - }); + const { root, account } = resolveThreadBindingChannelScope(params); return resolveThreadBindingMaxAgeMs({ channelMaxAgeHoursRaw: account?.maxAgeHours ?? root?.maxAgeHours, sessionMaxAgeHoursRaw: params.cfg.session?.threadBindings?.maxAgeHours, }); } +function resolveThreadBindingChannelScope(params: { + cfg: OpenClawConfig; + channel: string; + accountId?: string; +}) { + const channel = normalizeChannelId(params.channel); + const accountId = normalizeAccountId(params.accountId); + return resolveChannelThreadBindings({ + cfg: params.cfg, + channel, + accountId, + }); +} + export function formatThreadBindingDisabledError(params: { channel: string; accountId: string; diff --git a/src/cli/argv.ts b/src/cli/argv.ts index d00cb23a778..ecc33d689e5 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -84,8 +84,16 @@ export function hasRootVersionAlias(argv: string[]): boolean { } export function isRootVersionInvocation(argv: string[]): boolean { + return isRootInvocationForFlags(argv, VERSION_FLAGS, { includeVersionAlias: true }); +} + +function isRootInvocationForFlags( + argv: string[], + targetFlags: Set, + options?: { includeVersionAlias?: boolean }, +): boolean { const args = argv.slice(2); - let hasVersion = false; + let hasTarget = false; for (let i = 0; i < args.length; i += 1) { const arg = args[i]; if (!arg) { @@ -94,8 +102,11 @@ export function isRootVersionInvocation(argv: string[]): boolean { if (arg === FLAG_TERMINATOR) { break; } - if (arg === ROOT_VERSION_ALIAS_FLAG || VERSION_FLAGS.has(arg)) { - hasVersion = true; + if ( + targetFlags.has(arg) || + (options?.includeVersionAlias === true && arg === ROOT_VERSION_ALIAS_FLAG) + ) { + hasTarget = true; continue; } if (ROOT_BOOLEAN_FLAGS.has(arg)) { @@ -111,46 +122,14 @@ export function isRootVersionInvocation(argv: string[]): boolean { } continue; } - if (arg.startsWith("-")) { - return false; - } + // Unknown flags and subcommand-scoped help/version should fall back to Commander. return false; } - return hasVersion; + return hasTarget; } export function isRootHelpInvocation(argv: string[]): boolean { - const args = argv.slice(2); - let hasHelp = false; - for (let i = 0; i < args.length; i += 1) { - const arg = args[i]; - if (!arg) { - continue; - } - if (arg === FLAG_TERMINATOR) { - break; - } - if (HELP_FLAGS.has(arg)) { - hasHelp = true; - continue; - } - if (ROOT_BOOLEAN_FLAGS.has(arg)) { - continue; - } - if (arg.startsWith("--profile=") || arg.startsWith("--log-level=")) { - continue; - } - if (ROOT_VALUE_FLAGS.has(arg)) { - const next = args[i + 1]; - if (isValueToken(next)) { - i += 1; - } - continue; - } - // Unknown flags and subcommand-scoped help should fall back to Commander. - return false; - } - return hasHelp; + return isRootInvocationForFlags(argv, HELP_FLAGS); } export function getFlagValue(argv: string[], name: string): string | null | undefined { diff --git a/src/commands/agents.commands.bind.ts b/src/commands/agents.commands.bind.ts index b3c7989f895..37862f4d00e 100644 --- a/src/commands/agents.commands.bind.ts +++ b/src/commands/agents.commands.bind.ts @@ -89,6 +89,45 @@ function formatBindingConflicts( ); } +function resolveParsedBindingsOrExit(params: { + runtime: RuntimeEnv; + cfg: NonNullable>>; + agentId: string; + bindValues: string[] | undefined; + emptyMessage: string; +}): ReturnType | null { + const specs = (params.bindValues ?? []).map((value) => value.trim()).filter(Boolean); + if (specs.length === 0) { + params.runtime.error(params.emptyMessage); + params.runtime.exit(1); + return null; + } + + const parsed = parseBindingSpecs({ agentId: params.agentId, specs, config: params.cfg }); + if (parsed.errors.length > 0) { + params.runtime.error(parsed.errors.join("\n")); + params.runtime.exit(1); + return null; + } + return parsed; +} + +function emitJsonPayload(params: { + runtime: RuntimeEnv; + json: boolean | undefined; + payload: unknown; + conflictCount?: number; +}): boolean { + if (!params.json) { + return false; + } + params.runtime.log(JSON.stringify(params.payload, null, 2)); + if ((params.conflictCount ?? 0) > 0) { + params.runtime.exit(1); + } + return true; +} + export async function agentsBindingsCommand( opts: AgentsBindingsListOptions, runtime: RuntimeEnv = defaultRuntime, @@ -157,17 +196,14 @@ export async function agentsBindCommand( return; } - const specs = (opts.bind ?? []).map((value) => value.trim()).filter(Boolean); - if (specs.length === 0) { - runtime.error("Provide at least one --bind ."); - runtime.exit(1); - return; - } - - const parsed = parseBindingSpecs({ agentId, specs, config: cfg }); - if (parsed.errors.length > 0) { - runtime.error(parsed.errors.join("\n")); - runtime.exit(1); + const parsed = resolveParsedBindingsOrExit({ + runtime, + cfg, + agentId, + bindValues: opts.bind, + emptyMessage: "Provide at least one --bind .", + }); + if (!parsed) { return; } @@ -186,11 +222,9 @@ export async function agentsBindCommand( skipped: result.skipped.map(describeBinding), conflicts: formatBindingConflicts(result.conflicts), }; - if (opts.json) { - runtime.log(JSON.stringify(payload, null, 2)); - if (result.conflicts.length > 0) { - runtime.exit(1); - } + if ( + emitJsonPayload({ runtime, json: opts.json, payload, conflictCount: result.conflicts.length }) + ) { return; } @@ -267,25 +301,21 @@ export async function agentsUnbindCommand( missing: [] as string[], conflicts: [] as string[], }; - if (opts.json) { - runtime.log(JSON.stringify(payload, null, 2)); + if (emitJsonPayload({ runtime, json: opts.json, payload })) { return; } runtime.log(`Removed ${removed.length} binding(s) for "${agentId}".`); return; } - const specs = (opts.bind ?? []).map((value) => value.trim()).filter(Boolean); - if (specs.length === 0) { - runtime.error("Provide at least one --bind or use --all."); - runtime.exit(1); - return; - } - - const parsed = parseBindingSpecs({ agentId, specs, config: cfg }); - if (parsed.errors.length > 0) { - runtime.error(parsed.errors.join("\n")); - runtime.exit(1); + const parsed = resolveParsedBindingsOrExit({ + runtime, + cfg, + agentId, + bindValues: opts.bind, + emptyMessage: "Provide at least one --bind or use --all.", + }); + if (!parsed) { return; } @@ -303,11 +333,9 @@ export async function agentsUnbindCommand( missing: result.missing.map(describeBinding), conflicts: formatBindingConflicts(result.conflicts), }; - if (opts.json) { - runtime.log(JSON.stringify(payload, null, 2)); - if (result.conflicts.length > 0) { - runtime.exit(1); - } + if ( + emitJsonPayload({ runtime, json: opts.json, payload, conflictCount: result.conflicts.length }) + ) { return; } diff --git a/src/commands/status.scan.ts b/src/commands/status.scan.ts index 818a7337de9..6436559ff6d 100644 --- a/src/commands/status.scan.ts +++ b/src/commands/status.scan.ts @@ -28,6 +28,13 @@ type MemoryPluginStatus = { type DeferredResult = { ok: true; value: T } | { ok: false; error: unknown }; +type GatewayProbeSnapshot = { + gatewayConnection: ReturnType; + remoteUrlMissing: boolean; + gatewayMode: "local" | "remote"; + gatewayProbe: Awaited> | null; +}; + function deferResult(promise: Promise): Promise> { return promise.then( (value) => ({ ok: true, value }), @@ -54,6 +61,43 @@ function resolveMemoryPluginStatus(cfg: ReturnType): MemoryPl return { enabled: true, slot: raw || "memory-core" }; } +async function resolveGatewayProbeSnapshot(params: { + cfg: ReturnType; + opts: { timeoutMs?: number; all?: boolean }; +}): Promise { + const gatewayConnection = buildGatewayConnectionDetails(); + const isRemoteMode = params.cfg.gateway?.mode === "remote"; + const remoteUrlRaw = + typeof params.cfg.gateway?.remote?.url === "string" ? params.cfg.gateway.remote.url : ""; + const remoteUrlMissing = isRemoteMode && !remoteUrlRaw.trim(); + const gatewayMode = isRemoteMode ? "remote" : "local"; + const gatewayProbe = remoteUrlMissing + ? null + : await probeGateway({ + url: gatewayConnection.url, + auth: resolveGatewayProbeAuth(params.cfg), + timeoutMs: Math.min(params.opts.all ? 5000 : 2500, params.opts.timeoutMs ?? 10_000), + }).catch(() => null); + return { gatewayConnection, remoteUrlMissing, gatewayMode, gatewayProbe }; +} + +async function resolveChannelsStatus(params: { + gatewayReachable: boolean; + opts: { timeoutMs?: number; all?: boolean }; +}) { + if (!params.gatewayReachable) { + return null; + } + return await callGateway({ + method: "channels.status", + params: { + probe: false, + timeoutMs: Math.min(8000, params.opts.timeoutMs ?? 10_000), + }, + timeoutMs: Math.min(params.opts.all ? 5000 : 2500, params.opts.timeoutMs ?? 10_000), + }).catch(() => null); +} + export type StatusScanResult = { cfg: ReturnType; osSummary: ReturnType; @@ -123,20 +167,9 @@ async function scanStatusJsonFast(opts: { runExec(cmd, args, { timeoutMs: 1200, maxBuffer: 200_000 }), ).catch(() => null); - const gatewayConnection = buildGatewayConnectionDetails(); - const isRemoteMode = cfg.gateway?.mode === "remote"; - const remoteUrlRaw = typeof cfg.gateway?.remote?.url === "string" ? cfg.gateway.remote.url : ""; - const remoteUrlMissing = isRemoteMode && !remoteUrlRaw.trim(); - const gatewayMode = isRemoteMode ? "remote" : "local"; - const gatewayProbePromise = remoteUrlMissing - ? Promise.resolve> | null>(null) - : probeGateway({ - url: gatewayConnection.url, - auth: resolveGatewayProbeAuth(cfg), - timeoutMs: Math.min(opts.all ? 5000 : 2500, opts.timeoutMs ?? 10_000), - }).catch(() => null); + const gatewayProbePromise = resolveGatewayProbeSnapshot({ cfg, opts }); - const [tailscaleDns, update, agentStatus, gatewayProbe, summary] = await Promise.all([ + const [tailscaleDns, update, agentStatus, gatewaySnapshot, summary] = await Promise.all([ tailscaleDnsPromise, updatePromise, agentStatusPromise, @@ -148,20 +181,12 @@ async function scanStatusJsonFast(opts: { ? `https://${tailscaleDns}${normalizeControlUiBasePath(cfg.gateway?.controlUi?.basePath)}` : null; + const { gatewayConnection, remoteUrlMissing, gatewayMode, gatewayProbe } = gatewaySnapshot; const gatewayReachable = gatewayProbe?.ok === true; const gatewaySelf = gatewayProbe?.presence ? pickGatewaySelfPresence(gatewayProbe.presence) : null; - const channelsStatusPromise = gatewayReachable - ? callGateway({ - method: "channels.status", - params: { - probe: false, - timeoutMs: Math.min(8000, opts.timeoutMs ?? 10_000), - }, - timeoutMs: Math.min(opts.all ? 5000 : 2500, opts.timeoutMs ?? 10_000), - }).catch(() => null) - : Promise.resolve(null); + const channelsStatusPromise = resolveChannelsStatus({ gatewayReachable, opts }); const memoryPlugin = resolveMemoryPluginStatus(cfg); const memoryPromise = resolveMemoryStatusSnapshot({ cfg, agentStatus, memoryPlugin }); const [channelsStatus, memory] = await Promise.all([channelsStatusPromise, memoryPromise]); @@ -246,19 +271,8 @@ export async function scanStatus( progress.tick(); progress.setLabel("Probing gateway…"); - const gatewayConnection = buildGatewayConnectionDetails(); - const isRemoteMode = cfg.gateway?.mode === "remote"; - const remoteUrlRaw = - typeof cfg.gateway?.remote?.url === "string" ? cfg.gateway.remote.url : ""; - const remoteUrlMissing = isRemoteMode && !remoteUrlRaw.trim(); - const gatewayMode = isRemoteMode ? "remote" : "local"; - const gatewayProbe = remoteUrlMissing - ? null - : await probeGateway({ - url: gatewayConnection.url, - auth: resolveGatewayProbeAuth(cfg), - timeoutMs: Math.min(opts.all ? 5000 : 2500, opts.timeoutMs ?? 10_000), - }).catch(() => null); + const { gatewayConnection, remoteUrlMissing, gatewayMode, gatewayProbe } = + await resolveGatewayProbeSnapshot({ cfg, opts }); const gatewayReachable = gatewayProbe?.ok === true; const gatewaySelf = gatewayProbe?.presence ? pickGatewaySelfPresence(gatewayProbe.presence) @@ -266,16 +280,7 @@ export async function scanStatus( progress.tick(); progress.setLabel("Querying channel status…"); - const channelsStatus = gatewayReachable - ? await callGateway({ - method: "channels.status", - params: { - probe: false, - timeoutMs: Math.min(8000, opts.timeoutMs ?? 10_000), - }, - timeoutMs: Math.min(opts.all ? 5000 : 2500, opts.timeoutMs ?? 10_000), - }).catch(() => null) - : null; + const channelsStatus = await resolveChannelsStatus({ gatewayReachable, opts }); const channelIssues = channelsStatus ? collectChannelStatusIssues(channelsStatus) : []; progress.tick(); diff --git a/src/config/sessions/paths.ts b/src/config/sessions/paths.ts index e3e9d10b6b7..6112fd6d31c 100644 --- a/src/config/sessions/paths.ts +++ b/src/config/sessions/paths.ts @@ -106,13 +106,24 @@ function resolveSiblingAgentSessionsDir( return path.join(rootDir, "agents", normalizeAgentId(agentId), "sessions"); } -function extractAgentIdFromAbsoluteSessionPath(candidateAbsPath: string): string | undefined { +function resolveAgentSessionsPathParts( + candidateAbsPath: string, +): { parts: string[]; sessionsIndex: number } | null { const normalized = path.normalize(path.resolve(candidateAbsPath)); const parts = normalized.split(path.sep).filter(Boolean); const sessionsIndex = parts.lastIndexOf("sessions"); if (sessionsIndex < 2 || parts[sessionsIndex - 2] !== "agents") { + return null; + } + return { parts, sessionsIndex }; +} + +function extractAgentIdFromAbsoluteSessionPath(candidateAbsPath: string): string | undefined { + const parsed = resolveAgentSessionsPathParts(candidateAbsPath); + if (!parsed) { return undefined; } + const { parts, sessionsIndex } = parsed; const agentId = parts[sessionsIndex - 1]; return agentId || undefined; } @@ -121,12 +132,11 @@ function resolveStructuralSessionFallbackPath( candidateAbsPath: string, expectedAgentId: string, ): string | undefined { - const normalized = path.normalize(path.resolve(candidateAbsPath)); - const parts = normalized.split(path.sep).filter(Boolean); - const sessionsIndex = parts.lastIndexOf("sessions"); - if (sessionsIndex < 2 || parts[sessionsIndex - 2] !== "agents") { + const parsed = resolveAgentSessionsPathParts(candidateAbsPath); + if (!parsed) { return undefined; } + const { parts, sessionsIndex } = parsed; const agentIdPart = parts[sessionsIndex - 1]; if (!agentIdPart) { return undefined; @@ -147,7 +157,7 @@ function resolveStructuralSessionFallbackPath( if (!fileName || fileName === "." || fileName === "..") { return undefined; } - return normalized; + return path.normalize(path.resolve(candidateAbsPath)); } function safeRealpathSync(filePath: string): string | undefined { diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index 2ab274e7f74..9de5981df80 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -240,29 +240,20 @@ export function buildServiceEnvironment(params: { }): Record { const { env, port, token, launchdLabel } = params; const platform = params.platform ?? process.platform; + const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform); const profile = env.OPENCLAW_PROFILE; const resolvedLaunchdLabel = launchdLabel || (platform === "darwin" ? resolveGatewayLaunchAgentLabel(profile) : undefined); const systemdUnit = `${resolveGatewaySystemdServiceName(profile)}.service`; - const stateDir = env.OPENCLAW_STATE_DIR; - const configPath = env.OPENCLAW_CONFIG_PATH; - // Keep a usable temp directory for supervised services even when the host env omits TMPDIR. - const tmpDir = env.TMPDIR?.trim() || os.tmpdir(); - const proxyEnv = readServiceProxyEnvironment(env); - // On macOS, launchd services don't inherit the shell environment, so Node's undici/fetch - // cannot locate the system CA bundle. Default to /etc/ssl/cert.pem so TLS verification - // works correctly when running as a LaunchAgent without extra user configuration. - const nodeCaCerts = - env.NODE_EXTRA_CA_CERTS ?? (platform === "darwin" ? "/etc/ssl/cert.pem" : undefined); return { HOME: env.HOME, - TMPDIR: tmpDir, - PATH: buildMinimalServicePath({ env }), - ...proxyEnv, - NODE_EXTRA_CA_CERTS: nodeCaCerts, + TMPDIR: sharedEnv.tmpDir, + PATH: sharedEnv.minimalPath, + ...sharedEnv.proxyEnv, + NODE_EXTRA_CA_CERTS: sharedEnv.nodeCaCerts, OPENCLAW_PROFILE: profile, - OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_CONFIG_PATH: configPath, + OPENCLAW_STATE_DIR: sharedEnv.stateDir, + OPENCLAW_CONFIG_PATH: sharedEnv.configPath, OPENCLAW_GATEWAY_PORT: String(port), OPENCLAW_GATEWAY_TOKEN: token, OPENCLAW_LAUNCHD_LABEL: resolvedLaunchdLabel, @@ -279,25 +270,17 @@ export function buildNodeServiceEnvironment(params: { }): Record { const { env } = params; const platform = params.platform ?? process.platform; + const sharedEnv = resolveSharedServiceEnvironmentFields(env, platform); const gatewayToken = env.OPENCLAW_GATEWAY_TOKEN?.trim() || env.CLAWDBOT_GATEWAY_TOKEN?.trim() || undefined; - const stateDir = env.OPENCLAW_STATE_DIR; - const configPath = env.OPENCLAW_CONFIG_PATH; - const tmpDir = env.TMPDIR?.trim() || os.tmpdir(); - const proxyEnv = readServiceProxyEnvironment(env); - // On macOS, launchd services don't inherit the shell environment, so Node's undici/fetch - // cannot locate the system CA bundle. Default to /etc/ssl/cert.pem so TLS verification - // works correctly when running as a LaunchAgent without extra user configuration. - const nodeCaCerts = - env.NODE_EXTRA_CA_CERTS ?? (platform === "darwin" ? "/etc/ssl/cert.pem" : undefined); return { HOME: env.HOME, - TMPDIR: tmpDir, - PATH: buildMinimalServicePath({ env }), - ...proxyEnv, - NODE_EXTRA_CA_CERTS: nodeCaCerts, - OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_CONFIG_PATH: configPath, + TMPDIR: sharedEnv.tmpDir, + PATH: sharedEnv.minimalPath, + ...sharedEnv.proxyEnv, + NODE_EXTRA_CA_CERTS: sharedEnv.nodeCaCerts, + OPENCLAW_STATE_DIR: sharedEnv.stateDir, + OPENCLAW_CONFIG_PATH: sharedEnv.configPath, OPENCLAW_GATEWAY_TOKEN: gatewayToken, OPENCLAW_LAUNCHD_LABEL: resolveNodeLaunchAgentLabel(), OPENCLAW_SYSTEMD_UNIT: resolveNodeSystemdServiceName(), @@ -309,3 +292,34 @@ export function buildNodeServiceEnvironment(params: { OPENCLAW_SERVICE_VERSION: VERSION, }; } + +function resolveSharedServiceEnvironmentFields( + env: Record, + platform: NodeJS.Platform, +): { + stateDir: string | undefined; + configPath: string | undefined; + tmpDir: string; + minimalPath: string; + proxyEnv: Record; + nodeCaCerts: string | undefined; +} { + const stateDir = env.OPENCLAW_STATE_DIR; + const configPath = env.OPENCLAW_CONFIG_PATH; + // Keep a usable temp directory for supervised services even when the host env omits TMPDIR. + const tmpDir = env.TMPDIR?.trim() || os.tmpdir(); + const proxyEnv = readServiceProxyEnvironment(env); + // On macOS, launchd services don't inherit the shell environment, so Node's undici/fetch + // cannot locate the system CA bundle. Default to /etc/ssl/cert.pem so TLS verification + // works correctly when running as a LaunchAgent without extra user configuration. + const nodeCaCerts = + env.NODE_EXTRA_CA_CERTS ?? (platform === "darwin" ? "/etc/ssl/cert.pem" : undefined); + return { + stateDir, + configPath, + tmpDir, + minimalPath: buildMinimalServicePath({ env }), + proxyEnv, + nodeCaCerts, + }; +} diff --git a/src/gateway/net.ts b/src/gateway/net.ts index 5bc6083a8d4..b4d647a487e 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -322,16 +322,14 @@ export function isValidIPv4(host: string): boolean { * Note: 0.0.0.0 and :: are NOT loopback - they bind to all interfaces. */ export function isLoopbackHost(host: string): boolean { - if (!host) { + const parsed = parseHostForAddressChecks(host); + if (!parsed) { return false; } - const h = host.trim().toLowerCase(); - if (h === "localhost") { + if (parsed.isLocalhost) { return true; } - // Handle bracketed IPv6 addresses like [::1] - const unbracket = h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h; - return isLoopbackAddress(unbracket); + return isLoopbackAddress(parsed.unbracketedHost); } /** @@ -353,16 +351,14 @@ export function isLocalishHost(hostHeader?: string): boolean { * RFC 1918, link-local, CGNAT, and IPv6 ULA/link-local addresses. */ export function isPrivateOrLoopbackHost(host: string): boolean { - if (!host) { + const parsed = parseHostForAddressChecks(host); + if (!parsed) { return false; } - const h = host.trim().toLowerCase(); - if (h === "localhost") { + if (parsed.isLocalhost) { return true; } - // Handle bracketed IPv6 addresses like [::1] - const unbracket = h.startsWith("[") && h.endsWith("]") ? h.slice(1, -1) : h; - const normalized = normalizeIp(unbracket); + const normalized = normalizeIp(parsed.unbracketedHost); if (!normalized || !isPrivateOrLoopbackAddress(normalized)) { return false; } @@ -381,6 +377,26 @@ export function isPrivateOrLoopbackHost(host: string): boolean { return true; } +function parseHostForAddressChecks( + host: string, +): { isLocalhost: boolean; unbracketedHost: string } | null { + if (!host) { + return null; + } + const normalizedHost = host.trim().toLowerCase(); + if (normalizedHost === "localhost") { + return { isLocalhost: true, unbracketedHost: normalizedHost }; + } + return { + isLocalhost: false, + // Handle bracketed IPv6 addresses like [::1] + unbracketedHost: + normalizedHost.startsWith("[") && normalizedHost.endsWith("]") + ? normalizedHost.slice(1, -1) + : normalizedHost, + }; +} + /** * Security check for WebSocket URLs (CWE-319: Cleartext Transmission of Sensitive Information). * diff --git a/src/gateway/server-methods/agents.ts b/src/gateway/server-methods/agents.ts index a59b689a27d..61d8be8a8a7 100644 --- a/src/gateway/server-methods/agents.ts +++ b/src/gateway/server-methods/agents.ts @@ -344,6 +344,40 @@ async function moveToTrashBestEffort(pathname: string): Promise { } } +function respondWorkspaceFileInvalid(respond: RespondFn, name: string, reason: string): void { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unsafe workspace file "${name}" (${reason})`), + ); +} + +function respondWorkspaceFileUnsafe(respond: RespondFn, name: string): void { + respond( + false, + undefined, + errorShape(ErrorCodes.INVALID_REQUEST, `unsafe workspace file "${name}"`), + ); +} + +function respondWorkspaceFileMissing(params: { + respond: RespondFn; + agentId: string; + workspaceDir: string; + name: string; + filePath: string; +}): void { + params.respond( + true, + { + agentId: params.agentId, + workspace: params.workspaceDir, + file: { name: params.name, path: params.filePath, missing: true }, + }, + undefined, + ); +} + export const agentsHandlers: GatewayRequestHandlers = { "agents.list": ({ params, respond }) => { if (!validateAgentsListParams(params)) { @@ -601,26 +635,11 @@ export const agentsHandlers: GatewayRequestHandlers = { allowMissing: true, }); if (resolvedPath.kind === "invalid") { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `unsafe workspace file "${name}" (${resolvedPath.reason})`, - ), - ); + respondWorkspaceFileInvalid(respond, name, resolvedPath.reason); return; } if (resolvedPath.kind === "missing") { - respond( - true, - { - agentId, - workspace: workspaceDir, - file: { name, path: filePath, missing: true }, - }, - undefined, - ); + respondWorkspaceFileMissing({ respond, agentId, workspaceDir, name, filePath }); return; } let safeRead: Awaited>; @@ -628,22 +647,10 @@ export const agentsHandlers: GatewayRequestHandlers = { safeRead = await readLocalFileSafely({ filePath: resolvedPath.ioPath }); } catch (err) { if (err instanceof SafeOpenError && err.code === "not-found") { - respond( - true, - { - agentId, - workspace: workspaceDir, - file: { name, path: filePath, missing: true }, - }, - undefined, - ); + respondWorkspaceFileMissing({ respond, agentId, workspaceDir, name, filePath }); return; } - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `unsafe workspace file "${name}"`), - ); + respondWorkspaceFileUnsafe(respond, name); return; } respond( @@ -690,14 +697,7 @@ export const agentsHandlers: GatewayRequestHandlers = { allowMissing: true, }); if (resolvedPath.kind === "invalid") { - respond( - false, - undefined, - errorShape( - ErrorCodes.INVALID_REQUEST, - `unsafe workspace file "${name}" (${resolvedPath.reason})`, - ), - ); + respondWorkspaceFileInvalid(respond, name, resolvedPath.reason); return; } const content = String(params.content ?? ""); @@ -709,11 +709,7 @@ export const agentsHandlers: GatewayRequestHandlers = { encoding: "utf8", }); } catch { - respond( - false, - undefined, - errorShape(ErrorCodes.INVALID_REQUEST, `unsafe workspace file "${name}"`), - ); + respondWorkspaceFileUnsafe(respond, name); return; } const meta = await statFileSafely(resolvedPath.ioPath); diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts index bba4f6658a9..69d49aab348 100644 --- a/src/gateway/server-methods/sessions.ts +++ b/src/gateway/server-methods/sessions.ts @@ -284,6 +284,32 @@ async function closeAcpRuntimeForSession(params: { return undefined; } +async function cleanupSessionBeforeMutation(params: { + cfg: ReturnType; + key: string; + target: ReturnType; + entry: SessionEntry | undefined; + legacyKey?: string; + canonicalKey?: string; + reason: "session-reset" | "session-delete"; +}) { + const cleanupError = await ensureSessionRuntimeCleanup({ + cfg: params.cfg, + key: params.key, + target: params.target, + sessionId: params.entry?.sessionId, + }); + if (cleanupError) { + return cleanupError; + } + return await closeAcpRuntimeForSession({ + cfg: params.cfg, + sessionKey: params.legacyKey ?? params.canonicalKey ?? params.target.canonicalKey ?? params.key, + entry: params.entry, + reason: params.reason, + }); +} + export const sessionsHandlers: GatewayRequestHandlers = { "sessions.list": ({ params, respond }) => { if (!assertValidParams(params, validateSessionsListParams, "sessions.list", respond)) { @@ -445,20 +471,17 @@ export const sessionsHandlers: GatewayRequestHandlers = { }, ); await triggerInternalHook(hookEvent); - const sessionId = entry?.sessionId; - const cleanupError = await ensureSessionRuntimeCleanup({ cfg, key, target, sessionId }); - if (cleanupError) { - respond(false, undefined, cleanupError); - return; - } - const acpCleanupError = await closeAcpRuntimeForSession({ + const mutationCleanupError = await cleanupSessionBeforeMutation({ cfg, - sessionKey: legacyKey ?? canonicalKey ?? target.canonicalKey ?? key, + key, + target, entry, + legacyKey, + canonicalKey, reason: "session-reset", }); - if (acpCleanupError) { - respond(false, undefined, acpCleanupError); + if (mutationCleanupError) { + respond(false, undefined, mutationCleanupError); return; } let oldSessionId: string | undefined; @@ -542,22 +565,20 @@ export const sessionsHandlers: GatewayRequestHandlers = { const deleteTranscript = typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true; const { entry, legacyKey, canonicalKey } = loadSessionEntry(key); - const sessionId = entry?.sessionId; - const cleanupError = await ensureSessionRuntimeCleanup({ cfg, key, target, sessionId }); - if (cleanupError) { - respond(false, undefined, cleanupError); - return; - } - const acpCleanupError = await closeAcpRuntimeForSession({ + const mutationCleanupError = await cleanupSessionBeforeMutation({ cfg, - sessionKey: legacyKey ?? canonicalKey ?? target.canonicalKey ?? key, + key, + target, entry, + legacyKey, + canonicalKey, reason: "session-delete", }); - if (acpCleanupError) { - respond(false, undefined, acpCleanupError); + if (mutationCleanupError) { + respond(false, undefined, mutationCleanupError); return; } + const sessionId = entry?.sessionId; const deleted = await updateSessionStore(storePath, (store) => { const { primaryKey } = migrateAndPruneSessionStoreKey({ cfg, key, store }); const hadEntry = Boolean(store[primaryKey]); diff --git a/src/infra/boundary-file-read.ts b/src/infra/boundary-file-read.ts index fdd39fc8d9c..eea0cc66cb3 100644 --- a/src/infra/boundary-file-read.ts +++ b/src/infra/boundary-file-read.ts @@ -1,6 +1,10 @@ import fs from "node:fs"; import path from "node:path"; -import { resolveBoundaryPath, resolveBoundaryPathSync } from "./boundary-path.js"; +import { + resolveBoundaryPath, + resolveBoundaryPathSync, + type ResolvedBoundaryPath, +} from "./boundary-path.js"; import type { PathAliasPolicy } from "./path-alias-guards.js"; import { openVerifiedFileSync, @@ -41,6 +45,12 @@ export type OpenBoundaryFileParams = OpenBoundaryFileSyncParams & { aliasPolicy?: PathAliasPolicy; }; +type ResolvedBoundaryFilePath = { + absolutePath: string; + resolvedPath: string; + rootRealPath: string; +}; + export function canUseBoundaryFileOpen(ioFs: typeof fs): boolean { return ( typeof ioFs.openSync === "function" && @@ -56,28 +66,27 @@ export function canUseBoundaryFileOpen(ioFs: typeof fs): boolean { export function openBoundaryFileSync(params: OpenBoundaryFileSyncParams): BoundaryFileOpenResult { const ioFs = params.ioFs ?? fs; - const absolutePath = path.resolve(params.absolutePath); - - let resolvedPath: string; - let rootRealPath: string; - try { - const resolved = resolveBoundaryPathSync({ - absolutePath, - rootPath: params.rootPath, - rootCanonicalPath: params.rootRealPath, - boundaryLabel: params.boundaryLabel, - skipLexicalRootCheck: params.skipLexicalRootCheck, - }); - resolvedPath = resolved.canonicalPath; - rootRealPath = resolved.rootCanonicalPath; - } catch (error) { - return { ok: false, reason: "validation", error }; + const resolved = resolveBoundaryFilePathGeneric({ + absolutePath: params.absolutePath, + resolve: (absolutePath) => + resolveBoundaryPathSync({ + absolutePath, + rootPath: params.rootPath, + rootCanonicalPath: params.rootRealPath, + boundaryLabel: params.boundaryLabel, + skipLexicalRootCheck: params.skipLexicalRootCheck, + }), + }); + if (resolved instanceof Promise) { + return toBoundaryValidationError(new Error("Unexpected async boundary resolution")); + } + if ("ok" in resolved) { + return resolved; } - return openBoundaryFileResolved({ - absolutePath, - resolvedPath, - rootRealPath, + absolutePath: resolved.absolutePath, + resolvedPath: resolved.resolvedPath, + rootRealPath: resolved.rootRealPath, maxBytes: params.maxBytes, rejectHardlinks: params.rejectHardlinks, allowedType: params.allowedType, @@ -118,30 +127,65 @@ export async function openBoundaryFile( params: OpenBoundaryFileParams, ): Promise { const ioFs = params.ioFs ?? fs; - const absolutePath = path.resolve(params.absolutePath); - let resolvedPath: string; - let rootRealPath: string; - try { - const resolved = await resolveBoundaryPath({ - absolutePath, - rootPath: params.rootPath, - rootCanonicalPath: params.rootRealPath, - boundaryLabel: params.boundaryLabel, - policy: params.aliasPolicy, - skipLexicalRootCheck: params.skipLexicalRootCheck, - }); - resolvedPath = resolved.canonicalPath; - rootRealPath = resolved.rootCanonicalPath; - } catch (error) { - return { ok: false, reason: "validation", error }; + const maybeResolved = resolveBoundaryFilePathGeneric({ + absolutePath: params.absolutePath, + resolve: (absolutePath) => + resolveBoundaryPath({ + absolutePath, + rootPath: params.rootPath, + rootCanonicalPath: params.rootRealPath, + boundaryLabel: params.boundaryLabel, + policy: params.aliasPolicy, + skipLexicalRootCheck: params.skipLexicalRootCheck, + }), + }); + const resolved = maybeResolved instanceof Promise ? await maybeResolved : maybeResolved; + if ("ok" in resolved) { + return resolved; } return openBoundaryFileResolved({ - absolutePath, - resolvedPath, - rootRealPath, + absolutePath: resolved.absolutePath, + resolvedPath: resolved.resolvedPath, + rootRealPath: resolved.rootRealPath, maxBytes: params.maxBytes, rejectHardlinks: params.rejectHardlinks, allowedType: params.allowedType, ioFs, }); } + +function toBoundaryValidationError(error: unknown): BoundaryFileOpenResult { + return { ok: false, reason: "validation", error }; +} + +function mapResolvedBoundaryPath( + absolutePath: string, + resolved: ResolvedBoundaryPath, +): ResolvedBoundaryFilePath { + return { + absolutePath, + resolvedPath: resolved.canonicalPath, + rootRealPath: resolved.rootCanonicalPath, + }; +} + +function resolveBoundaryFilePathGeneric(params: { + absolutePath: string; + resolve: (absolutePath: string) => ResolvedBoundaryPath | Promise; +}): + | ResolvedBoundaryFilePath + | BoundaryFileOpenResult + | Promise { + const absolutePath = path.resolve(params.absolutePath); + try { + const resolved = params.resolve(absolutePath); + if (resolved instanceof Promise) { + return resolved + .then((value) => mapResolvedBoundaryPath(absolutePath, value)) + .catch((error) => toBoundaryValidationError(error)); + } + return mapResolvedBoundaryPath(absolutePath, resolved); + } catch (error) { + return toBoundaryValidationError(error); + } +} diff --git a/src/infra/boundary-path.ts b/src/infra/boundary-path.ts index 9225e41f0b0..9a9629cb146 100644 --- a/src/infra/boundary-path.ts +++ b/src/infra/boundary-path.ts @@ -52,47 +52,30 @@ export async function resolveBoundaryPath( const rootCanonicalPath = params.rootCanonicalPath ? path.resolve(params.rootCanonicalPath) : await resolvePathViaExistingAncestor(rootPath); - const lexicalInside = isPathInside(rootPath, absolutePath); - const outsideLexicalCanonicalPath = lexicalInside - ? undefined - : await resolvePathViaExistingAncestor(absolutePath); - const canonicalOutsideLexicalPath = resolveCanonicalOutsideLexicalPath({ - absolutePath, - outsideLexicalCanonicalPath, - }); - assertLexicalBoundaryOrCanonicalAlias({ - skipLexicalRootCheck: params.skipLexicalRootCheck, - lexicalInside, - canonicalOutsideLexicalPath, - rootCanonicalPath, - boundaryLabel: params.boundaryLabel, + const context = createBoundaryResolutionContext({ + resolveParams: params, rootPath, absolutePath, + rootCanonicalPath, + outsideLexicalCanonicalPath: await resolveOutsideLexicalCanonicalPathAsync({ + rootPath, + absolutePath, + }), }); - if (!lexicalInside) { - const canonicalPath = canonicalOutsideLexicalPath; - assertInsideBoundary({ - boundaryLabel: params.boundaryLabel, - rootCanonicalPath, - candidatePath: canonicalPath, - absolutePath, - }); - const kind = await getPathKind(absolutePath, false); - return buildResolvedBoundaryPath({ - absolutePath, - canonicalPath, - rootPath, - rootCanonicalPath, - kind, - }); + const outsideResult = await resolveOutsideBoundaryPathAsync({ + boundaryLabel: params.boundaryLabel, + context, + }); + if (outsideResult) { + return outsideResult; } return resolveBoundaryPathLexicalAsync({ params, - absolutePath, - rootPath, - rootCanonicalPath, + absolutePath: context.absolutePath, + rootPath: context.rootPath, + rootCanonicalPath: context.rootCanonicalPath, }); } @@ -102,47 +85,30 @@ export function resolveBoundaryPathSync(params: ResolveBoundaryPathParams): Reso const rootCanonicalPath = params.rootCanonicalPath ? path.resolve(params.rootCanonicalPath) : resolvePathViaExistingAncestorSync(rootPath); - const lexicalInside = isPathInside(rootPath, absolutePath); - const outsideLexicalCanonicalPath = lexicalInside - ? undefined - : resolvePathViaExistingAncestorSync(absolutePath); - const canonicalOutsideLexicalPath = resolveCanonicalOutsideLexicalPath({ - absolutePath, - outsideLexicalCanonicalPath, - }); - assertLexicalBoundaryOrCanonicalAlias({ - skipLexicalRootCheck: params.skipLexicalRootCheck, - lexicalInside, - canonicalOutsideLexicalPath, - rootCanonicalPath, - boundaryLabel: params.boundaryLabel, + const context = createBoundaryResolutionContext({ + resolveParams: params, rootPath, absolutePath, + rootCanonicalPath, + outsideLexicalCanonicalPath: resolveOutsideLexicalCanonicalPathSync({ + rootPath, + absolutePath, + }), }); - if (!lexicalInside) { - const canonicalPath = canonicalOutsideLexicalPath; - assertInsideBoundary({ - boundaryLabel: params.boundaryLabel, - rootCanonicalPath, - candidatePath: canonicalPath, - absolutePath, - }); - const kind = getPathKindSync(absolutePath, false); - return buildResolvedBoundaryPath({ - absolutePath, - canonicalPath, - rootPath, - rootCanonicalPath, - kind, - }); + const outsideResult = resolveOutsideBoundaryPathSync({ + boundaryLabel: params.boundaryLabel, + context, + }); + if (outsideResult) { + return outsideResult; } return resolveBoundaryPathLexicalSync({ params, - absolutePath, - rootPath, - rootCanonicalPath, + absolutePath: context.absolutePath, + rootPath: context.rootPath, + rootCanonicalPath: context.rootCanonicalPath, }); } @@ -154,6 +120,14 @@ type LexicalTraversalState = { preserveFinalSymlink: boolean; }; +type BoundaryResolutionContext = { + rootPath: string; + absolutePath: string; + rootCanonicalPath: string; + lexicalInside: boolean; + canonicalOutsideLexicalPath: string; +}; + function createLexicalTraversalState(params: { params: ResolveBoundaryPathParams; rootPath: string; @@ -261,6 +235,29 @@ function handleLexicalLstatFailure(params: { return true; } +function handleLexicalStatReadFailure(params: { + error: unknown; + state: LexicalTraversalState; + missingFromIndex: number; + rootCanonicalPath: string; + resolveParams: ResolveBoundaryPathParams; + absolutePath: string; +}): null { + if ( + handleLexicalLstatFailure({ + error: params.error, + state: params.state, + missingFromIndex: params.missingFromIndex, + rootCanonicalPath: params.rootCanonicalPath, + resolveParams: params.resolveParams, + absolutePath: params.absolutePath, + }) + ) { + return null; + } + throw params.error; +} + function handleLexicalStatDisposition(params: { state: LexicalTraversalState; isSymbolicLink: boolean; @@ -313,79 +310,45 @@ function applyResolvedSymlinkHop(params: { params.state.lexicalCursor = params.linkCanonical; } -async function readLexicalStatAsync(params: { +function readLexicalStat(params: { state: LexicalTraversalState; missingFromIndex: number; rootCanonicalPath: string; resolveParams: ResolveBoundaryPathParams; absolutePath: string; -}): Promise { + read: (cursor: string) => fs.Stats | Promise; +}): fs.Stats | null | Promise { try { - return await fsp.lstat(params.state.lexicalCursor); - } catch (error) { - if ( - handleLexicalLstatFailure({ - error, - state: params.state, - missingFromIndex: params.missingFromIndex, - rootCanonicalPath: params.rootCanonicalPath, - resolveParams: params.resolveParams, - absolutePath: params.absolutePath, - }) - ) { - return null; + const stat = params.read(params.state.lexicalCursor); + if (stat instanceof Promise) { + return stat.catch((error) => handleLexicalStatReadFailure({ ...params, error })); } - throw error; + return stat; + } catch (error) { + return handleLexicalStatReadFailure({ ...params, error }); } } -function readLexicalStatSync(params: { +function resolveAndApplySymlinkHop(params: { state: LexicalTraversalState; - missingFromIndex: number; rootCanonicalPath: string; - resolveParams: ResolveBoundaryPathParams; - absolutePath: string; -}): fs.Stats | null { - try { - return fs.lstatSync(params.state.lexicalCursor); - } catch (error) { - if ( - handleLexicalLstatFailure({ - error, + boundaryLabel: string; + resolveLinkCanonical: (cursor: string) => string | Promise; +}): void | Promise { + const linkCanonical = params.resolveLinkCanonical(params.state.lexicalCursor); + if (linkCanonical instanceof Promise) { + return linkCanonical.then((value) => + applyResolvedSymlinkHop({ state: params.state, - missingFromIndex: params.missingFromIndex, + linkCanonical: value, rootCanonicalPath: params.rootCanonicalPath, - resolveParams: params.resolveParams, - absolutePath: params.absolutePath, - }) - ) { - return null; - } - throw error; + boundaryLabel: params.boundaryLabel, + }), + ); } -} - -async function resolveAndApplySymlinkHopAsync(params: { - state: LexicalTraversalState; - rootCanonicalPath: string; - boundaryLabel: string; -}): Promise { applyResolvedSymlinkHop({ state: params.state, - linkCanonical: await resolveSymlinkHopPath(params.state.lexicalCursor), - rootCanonicalPath: params.rootCanonicalPath, - boundaryLabel: params.boundaryLabel, - }); -} - -function resolveAndApplySymlinkHopSync(params: { - state: LexicalTraversalState; - rootCanonicalPath: string; - boundaryLabel: string; -}): void { - applyResolvedSymlinkHop({ - state: params.state, - linkCanonical: resolveSymlinkHopPathSync(params.state.lexicalCursor), + linkCanonical, rootCanonicalPath: params.rootCanonicalPath, boundaryLabel: params.boundaryLabel, }); @@ -421,7 +384,11 @@ async function resolveBoundaryPathLexicalAsync(params: { }; for (const { idx, segment, isLast } of iterateLexicalTraversal(state)) { - const stat = await readLexicalStatAsync({ ...sharedStepParams, missingFromIndex: idx }); + const stat = await readLexicalStat({ + ...sharedStepParams, + missingFromIndex: idx, + read: (cursor) => fsp.lstat(cursor), + }); if (!stat) { break; } @@ -439,10 +406,11 @@ async function resolveBoundaryPathLexicalAsync(params: { break; } - await resolveAndApplySymlinkHopAsync({ + await resolveAndApplySymlinkHop({ state, rootCanonicalPath: params.rootCanonicalPath, boundaryLabel: params.params.boundaryLabel, + resolveLinkCanonical: (cursor) => resolveSymlinkHopPath(cursor), }); } @@ -461,24 +429,34 @@ function resolveBoundaryPathLexicalSync(params: { rootCanonicalPath: string; }): ResolvedBoundaryPath { const state = createLexicalTraversalState(params); - const sharedStepParams = { - state, - rootCanonicalPath: params.rootCanonicalPath, - resolveParams: params.params, - absolutePath: params.absolutePath, - }; - - for (const { idx, segment, isLast } of iterateLexicalTraversal(state)) { - const stat = readLexicalStatSync({ ...sharedStepParams, missingFromIndex: idx }); + for (let idx = 0; idx < state.segments.length; idx += 1) { + const segment = state.segments[idx] ?? ""; + const isLast = idx === state.segments.length - 1; + state.lexicalCursor = path.join(state.lexicalCursor, segment); + const maybeStat = readLexicalStat({ + state, + missingFromIndex: idx, + rootCanonicalPath: params.rootCanonicalPath, + resolveParams: params.params, + absolutePath: params.absolutePath, + read: (cursor) => fs.lstatSync(cursor), + }); + if (maybeStat instanceof Promise) { + throw new Error("Unexpected async lexical stat"); + } + const stat = maybeStat; if (!stat) { break; } const disposition = handleLexicalStatDisposition({ - ...sharedStepParams, + state, isSymbolicLink: stat.isSymbolicLink(), segment, isLast, + rootCanonicalPath: params.rootCanonicalPath, + resolveParams: params.params, + absolutePath: params.absolutePath, }); if (disposition === "continue") { continue; @@ -487,11 +465,15 @@ function resolveBoundaryPathLexicalSync(params: { break; } - resolveAndApplySymlinkHopSync({ + const maybeApplied = resolveAndApplySymlinkHop({ state, rootCanonicalPath: params.rootCanonicalPath, boundaryLabel: params.params.boundaryLabel, + resolveLinkCanonical: (cursor) => resolveSymlinkHopPathSync(cursor), }); + if (maybeApplied instanceof Promise) { + throw new Error("Unexpected async symlink resolution"); + } } const kind = getPathKindSync(params.absolutePath, state.preserveFinalSymlink); @@ -509,6 +491,115 @@ function resolveCanonicalOutsideLexicalPath(params: { return params.outsideLexicalCanonicalPath ?? params.absolutePath; } +function createBoundaryResolutionContext(params: { + resolveParams: ResolveBoundaryPathParams; + rootPath: string; + absolutePath: string; + rootCanonicalPath: string; + outsideLexicalCanonicalPath?: string; +}): BoundaryResolutionContext { + const lexicalInside = isPathInside(params.rootPath, params.absolutePath); + const canonicalOutsideLexicalPath = resolveCanonicalOutsideLexicalPath({ + absolutePath: params.absolutePath, + outsideLexicalCanonicalPath: params.outsideLexicalCanonicalPath, + }); + assertLexicalBoundaryOrCanonicalAlias({ + skipLexicalRootCheck: params.resolveParams.skipLexicalRootCheck, + lexicalInside, + canonicalOutsideLexicalPath, + rootCanonicalPath: params.rootCanonicalPath, + boundaryLabel: params.resolveParams.boundaryLabel, + rootPath: params.rootPath, + absolutePath: params.absolutePath, + }); + return { + rootPath: params.rootPath, + absolutePath: params.absolutePath, + rootCanonicalPath: params.rootCanonicalPath, + lexicalInside, + canonicalOutsideLexicalPath, + }; +} + +async function resolveOutsideBoundaryPathAsync(params: { + boundaryLabel: string; + context: BoundaryResolutionContext; +}): Promise { + if (params.context.lexicalInside) { + return null; + } + const kind = await getPathKind(params.context.absolutePath, false); + return buildOutsideLexicalBoundaryPath({ + boundaryLabel: params.boundaryLabel, + rootCanonicalPath: params.context.rootCanonicalPath, + absolutePath: params.context.absolutePath, + canonicalOutsideLexicalPath: params.context.canonicalOutsideLexicalPath, + rootPath: params.context.rootPath, + kind, + }); +} + +function resolveOutsideBoundaryPathSync(params: { + boundaryLabel: string; + context: BoundaryResolutionContext; +}): ResolvedBoundaryPath | null { + if (params.context.lexicalInside) { + return null; + } + const kind = getPathKindSync(params.context.absolutePath, false); + return buildOutsideLexicalBoundaryPath({ + boundaryLabel: params.boundaryLabel, + rootCanonicalPath: params.context.rootCanonicalPath, + absolutePath: params.context.absolutePath, + canonicalOutsideLexicalPath: params.context.canonicalOutsideLexicalPath, + rootPath: params.context.rootPath, + kind, + }); +} + +async function resolveOutsideLexicalCanonicalPathAsync(params: { + rootPath: string; + absolutePath: string; +}): Promise { + if (isPathInside(params.rootPath, params.absolutePath)) { + return undefined; + } + return await resolvePathViaExistingAncestor(params.absolutePath); +} + +function resolveOutsideLexicalCanonicalPathSync(params: { + rootPath: string; + absolutePath: string; +}): string | undefined { + if (isPathInside(params.rootPath, params.absolutePath)) { + return undefined; + } + return resolvePathViaExistingAncestorSync(params.absolutePath); +} + +function buildOutsideLexicalBoundaryPath(params: { + boundaryLabel: string; + rootCanonicalPath: string; + absolutePath: string; + canonicalOutsideLexicalPath: string; + rootPath: string; + kind: { exists: boolean; kind: ResolvedBoundaryPathKind }; +}): ResolvedBoundaryPath { + assertInsideBoundary({ + boundaryLabel: params.boundaryLabel, + rootCanonicalPath: params.rootCanonicalPath, + candidatePath: params.canonicalOutsideLexicalPath, + absolutePath: params.absolutePath, + }); + return buildResolvedBoundaryPath({ + absolutePath: params.absolutePath, + canonicalPath: params.canonicalOutsideLexicalPath, + rootPath: params.rootPath, + rootCanonicalPath: params.rootCanonicalPath, + kind: params.kind, + }); +} + function assertLexicalBoundaryOrCanonicalAlias(params: { skipLexicalRootCheck?: boolean; lexicalInside: boolean; diff --git a/src/infra/exec-command-resolution.ts b/src/infra/exec-command-resolution.ts index d69edbf113f..2c02983705b 100644 --- a/src/infra/exec-command-resolution.ts +++ b/src/infra/exec-command-resolution.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; import type { ExecAllowlistEntry } from "./exec-approvals.js"; import { resolveDispatchWrapperExecutionPlan } from "./exec-wrapper-resolution.js"; +import { resolveExecutablePath as resolveExecutableCandidatePath } from "./executable-path.js"; import { expandHomePrefix } from "./home-dir.js"; export const DEFAULT_SAFE_BINS = ["jq", "cut", "uniq", "head", "tail", "tr", "wc"]; @@ -17,21 +18,6 @@ export type CommandResolution = { blockedWrapper?: string; }; -function isExecutableFile(filePath: string): boolean { - try { - const stat = fs.statSync(filePath); - if (!stat.isFile()) { - return false; - } - if (process.platform !== "win32") { - fs.accessSync(filePath, fs.constants.X_OK); - } - return true; - } catch { - return false; - } -} - function parseFirstToken(command: string): string | null { const trimmed = command.trim(); if (!trimmed) { @@ -49,44 +35,6 @@ function parseFirstToken(command: string): string | null { return match ? match[0] : null; } -function resolveExecutablePath(rawExecutable: string, cwd?: string, env?: NodeJS.ProcessEnv) { - const expanded = rawExecutable.startsWith("~") ? expandHomePrefix(rawExecutable) : rawExecutable; - if (expanded.includes("/") || expanded.includes("\\")) { - if (path.isAbsolute(expanded)) { - return isExecutableFile(expanded) ? expanded : undefined; - } - const base = cwd && cwd.trim() ? cwd.trim() : process.cwd(); - const candidate = path.resolve(base, expanded); - return isExecutableFile(candidate) ? candidate : undefined; - } - const envPath = env?.PATH ?? env?.Path ?? process.env.PATH ?? process.env.Path ?? ""; - const entries = envPath.split(path.delimiter).filter(Boolean); - const hasExtension = process.platform === "win32" && path.extname(expanded).length > 0; - const extensions = - process.platform === "win32" - ? hasExtension - ? [""] - : ( - env?.PATHEXT ?? - env?.Pathext ?? - process.env.PATHEXT ?? - process.env.Pathext ?? - ".EXE;.CMD;.BAT;.COM" - ) - .split(";") - .map((ext) => ext.toLowerCase()) - : [""]; - for (const entry of entries) { - for (const ext of extensions) { - const candidate = path.join(entry, expanded + ext); - if (isExecutableFile(candidate)) { - return candidate; - } - } - } - return undefined; -} - function tryResolveRealpath(filePath: string | undefined): string | undefined { if (!filePath) { return undefined; @@ -107,7 +55,7 @@ export function resolveCommandResolution( if (!rawExecutable) { return null; } - const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env); + const resolvedPath = resolveExecutableCandidatePath(rawExecutable, { cwd, env }); const resolvedRealPath = tryResolveRealpath(resolvedPath); const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable; return { @@ -132,7 +80,7 @@ export function resolveCommandResolutionFromArgv( if (!rawExecutable) { return null; } - const resolvedPath = resolveExecutablePath(rawExecutable, cwd, env); + const resolvedPath = resolveExecutableCandidatePath(rawExecutable, { cwd, env }); const resolvedRealPath = tryResolveRealpath(resolvedPath); const executableName = resolvedPath ? path.basename(resolvedPath) : rawExecutable; return { diff --git a/src/infra/executable-path.ts b/src/infra/executable-path.ts new file mode 100644 index 00000000000..b25231a4a50 --- /dev/null +++ b/src/infra/executable-path.ts @@ -0,0 +1,75 @@ +import fs from "node:fs"; +import path from "node:path"; +import { expandHomePrefix } from "./home-dir.js"; + +function resolveWindowsExecutableExtensions( + executable: string, + env: NodeJS.ProcessEnv | undefined, +): string[] { + if (process.platform !== "win32") { + return [""]; + } + if (path.extname(executable).length > 0) { + return [""]; + } + return ( + env?.PATHEXT ?? + env?.Pathext ?? + process.env.PATHEXT ?? + process.env.Pathext ?? + ".EXE;.CMD;.BAT;.COM" + ) + .split(";") + .map((ext) => ext.toLowerCase()); +} + +export function isExecutableFile(filePath: string): boolean { + try { + const stat = fs.statSync(filePath); + if (!stat.isFile()) { + return false; + } + if (process.platform !== "win32") { + fs.accessSync(filePath, fs.constants.X_OK); + } + return true; + } catch { + return false; + } +} + +export function resolveExecutableFromPathEnv( + executable: string, + pathEnv: string, + env?: NodeJS.ProcessEnv, +): string | undefined { + const entries = pathEnv.split(path.delimiter).filter(Boolean); + const extensions = resolveWindowsExecutableExtensions(executable, env); + for (const entry of entries) { + for (const ext of extensions) { + const candidate = path.join(entry, executable + ext); + if (isExecutableFile(candidate)) { + return candidate; + } + } + } + return undefined; +} + +export function resolveExecutablePath( + rawExecutable: string, + options?: { cwd?: string; env?: NodeJS.ProcessEnv }, +): string | undefined { + const expanded = rawExecutable.startsWith("~") ? expandHomePrefix(rawExecutable) : rawExecutable; + if (expanded.includes("/") || expanded.includes("\\")) { + if (path.isAbsolute(expanded)) { + return isExecutableFile(expanded) ? expanded : undefined; + } + const base = options?.cwd && options.cwd.trim() ? options.cwd.trim() : process.cwd(); + const candidate = path.resolve(base, expanded); + return isExecutableFile(candidate) ? candidate : undefined; + } + const envPath = + options?.env?.PATH ?? options?.env?.Path ?? process.env.PATH ?? process.env.Path ?? ""; + return resolveExecutableFromPathEnv(expanded, envPath, options?.env); +} diff --git a/src/infra/fs-safe.ts b/src/infra/fs-safe.ts index 43012c5973e..bc9d38c6d17 100644 --- a/src/infra/fs-safe.ts +++ b/src/infra/fs-safe.ts @@ -210,18 +210,7 @@ export async function readFileWithinRoot(params: { rejectHardlinks: params.rejectHardlinks, }); try { - if (params.maxBytes !== undefined && opened.stat.size > params.maxBytes) { - throw new SafeOpenError( - "too-large", - `file exceeds limit of ${params.maxBytes} bytes (got ${opened.stat.size})`, - ); - } - const buffer = await opened.handle.readFile(); - return { - buffer, - realPath: opened.realPath, - stat: opened.stat, - }; + return await readOpenedFileSafely({ opened, maxBytes: params.maxBytes }); } finally { await opened.handle.close().catch(() => {}); } @@ -269,19 +258,30 @@ export async function readLocalFileSafely(params: { }): Promise { const opened = await openVerifiedLocalFile(params.filePath); try { - if (params.maxBytes !== undefined && opened.stat.size > params.maxBytes) { - throw new SafeOpenError( - "too-large", - `file exceeds limit of ${params.maxBytes} bytes (got ${opened.stat.size})`, - ); - } - const buffer = await opened.handle.readFile(); - return { buffer, realPath: opened.realPath, stat: opened.stat }; + return await readOpenedFileSafely({ opened, maxBytes: params.maxBytes }); } finally { await opened.handle.close().catch(() => {}); } } +async function readOpenedFileSafely(params: { + opened: SafeOpenResult; + maxBytes?: number; +}): Promise { + if (params.maxBytes !== undefined && params.opened.stat.size > params.maxBytes) { + throw new SafeOpenError( + "too-large", + `file exceeds limit of ${params.maxBytes} bytes (got ${params.opened.stat.size})`, + ); + } + const buffer = await params.opened.handle.readFile(); + return { + buffer, + realPath: params.opened.realPath, + stat: params.opened.stat, + }; +} + export async function writeFileWithinRoot(params: { rootDir: string; relativePath: string; diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index e3b593f61ba..27b76a40ef1 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -1,10 +1,9 @@ -import fs from "node:fs"; -import path from "node:path"; import { resolveBrowserConfig } from "../browser/config.js"; import { loadConfig } from "../config/config.js"; import { GatewayClient } from "../gateway/client.js"; import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import type { SkillBinTrustEntry } from "../infra/exec-approvals.js"; +import { resolveExecutableFromPathEnv } from "../infra/executable-path.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; import { NODE_BROWSER_PROXY_COMMAND, @@ -35,43 +34,11 @@ type NodeHostRunOptions = { const DEFAULT_NODE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"; -function isExecutableFile(filePath: string): boolean { - try { - const stat = fs.statSync(filePath); - if (!stat.isFile()) { - return false; - } - if (process.platform !== "win32") { - fs.accessSync(filePath, fs.constants.X_OK); - } - return true; - } catch { - return false; - } -} - function resolveExecutablePathFromEnv(bin: string, pathEnv: string): string | null { if (bin.includes("/") || bin.includes("\\")) { return null; } - const hasExtension = process.platform === "win32" && path.extname(bin).length > 0; - const extensions = - process.platform === "win32" - ? hasExtension - ? [""] - : (process.env.PATHEXT ?? process.env.PathExt ?? ".EXE;.CMD;.BAT;.COM") - .split(";") - .map((ext) => ext.toLowerCase()) - : [""]; - for (const dir of pathEnv.split(path.delimiter).filter(Boolean)) { - for (const ext of extensions) { - const candidate = path.join(dir, bin + ext); - if (isExecutableFile(candidate)) { - return candidate; - } - } - } - return null; + return resolveExecutableFromPathEnv(bin, pathEnv) ?? null; } function resolveSkillBinTrustEntries(bins: string[], pathEnv: string): SkillBinTrustEntry[] { diff --git a/src/security/dm-policy-shared.ts b/src/security/dm-policy-shared.ts index 35c9fceaf74..27325e985b3 100644 --- a/src/security/dm-policy-shared.ts +++ b/src/security/dm-policy-shared.ts @@ -50,6 +50,17 @@ export const DM_GROUP_ACCESS_REASON = { export type DmGroupAccessReasonCode = (typeof DM_GROUP_ACCESS_REASON)[keyof typeof DM_GROUP_ACCESS_REASON]; +type DmGroupAccessInputParams = { + isGroup: boolean; + dmPolicy?: string | null; + groupPolicy?: string | null; + allowFrom?: Array | null; + groupAllowFrom?: Array | null; + storeAllowFrom?: Array | null; + groupAllowFromFallbackToAllowFrom?: boolean | null; + isSenderAllowed: (allowFrom: string[]) => boolean; +}; + export async function readStoreAllowFromForDmPolicy(params: { provider: ChannelId; accountId: string; @@ -150,16 +161,7 @@ export function resolveDmGroupAccessDecision(params: { }; } -export function resolveDmGroupAccessWithLists(params: { - isGroup: boolean; - dmPolicy?: string | null; - groupPolicy?: string | null; - allowFrom?: Array | null; - groupAllowFrom?: Array | null; - storeAllowFrom?: Array | null; - groupAllowFromFallbackToAllowFrom?: boolean | null; - isSenderAllowed: (allowFrom: string[]) => boolean; -}): { +export function resolveDmGroupAccessWithLists(params: DmGroupAccessInputParams): { decision: DmGroupAccessDecision; reasonCode: DmGroupAccessReasonCode; reason: string; @@ -188,21 +190,15 @@ export function resolveDmGroupAccessWithLists(params: { }; } -export function resolveDmGroupAccessWithCommandGate(params: { - isGroup: boolean; - dmPolicy?: string | null; - groupPolicy?: string | null; - allowFrom?: Array | null; - groupAllowFrom?: Array | null; - storeAllowFrom?: Array | null; - groupAllowFromFallbackToAllowFrom?: boolean | null; - isSenderAllowed: (allowFrom: string[]) => boolean; - command?: { - useAccessGroups: boolean; - allowTextCommands: boolean; - hasControlCommand: boolean; - }; -}): { +export function resolveDmGroupAccessWithCommandGate( + params: DmGroupAccessInputParams & { + command?: { + useAccessGroups: boolean; + allowTextCommands: boolean; + hasControlCommand: boolean; + }; + }, +): { decision: DmGroupAccessDecision; reason: string; effectiveAllowFrom: string[];