refactor: dedupe runtime and helper flows

This commit is contained in:
Peter Steinberger
2026-03-02 12:53:19 +00:00
parent 5d3f066bbd
commit b02b94673f
17 changed files with 819 additions and 610 deletions

View File

@@ -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<SingleTargetValue | CommandHandlerResult> {
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<CommandHandlerResult> {
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<CommandHandlerResult> {
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<CommandHandlerResult> {
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<CommandHandlerResult> {
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<CommandHandlerResult> {
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)}`,
),
});
}

View File

@@ -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;

View File

@@ -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<string>,
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 {

View File

@@ -89,6 +89,45 @@ function formatBindingConflicts(
);
}
function resolveParsedBindingsOrExit(params: {
runtime: RuntimeEnv;
cfg: NonNullable<Awaited<ReturnType<typeof requireValidConfig>>>;
agentId: string;
bindValues: string[] | undefined;
emptyMessage: string;
}): ReturnType<typeof parseBindingSpecs> | 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 <channel[:accountId]>.");
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 <channel[:accountId]>.",
});
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 <channel[:accountId]> 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 <channel[:accountId]> 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;
}

View File

@@ -28,6 +28,13 @@ type MemoryPluginStatus = {
type DeferredResult<T> = { ok: true; value: T } | { ok: false; error: unknown };
type GatewayProbeSnapshot = {
gatewayConnection: ReturnType<typeof buildGatewayConnectionDetails>;
remoteUrlMissing: boolean;
gatewayMode: "local" | "remote";
gatewayProbe: Awaited<ReturnType<typeof probeGateway>> | null;
};
function deferResult<T>(promise: Promise<T>): Promise<DeferredResult<T>> {
return promise.then(
(value) => ({ ok: true, value }),
@@ -54,6 +61,43 @@ function resolveMemoryPluginStatus(cfg: ReturnType<typeof loadConfig>): MemoryPl
return { enabled: true, slot: raw || "memory-core" };
}
async function resolveGatewayProbeSnapshot(params: {
cfg: ReturnType<typeof loadConfig>;
opts: { timeoutMs?: number; all?: boolean };
}): Promise<GatewayProbeSnapshot> {
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<typeof loadConfig>;
osSummary: ReturnType<typeof resolveOsSummary>;
@@ -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<Awaited<ReturnType<typeof probeGateway>> | 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();

View File

@@ -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 {

View File

@@ -240,29 +240,20 @@ export function buildServiceEnvironment(params: {
}): Record<string, string | undefined> {
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<string, string | undefined> {
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<string, string | undefined>,
platform: NodeJS.Platform,
): {
stateDir: string | undefined;
configPath: string | undefined;
tmpDir: string;
minimalPath: string;
proxyEnv: Record<string, string | undefined>;
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,
};
}

View File

@@ -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).
*

View File

@@ -344,6 +344,40 @@ async function moveToTrashBestEffort(pathname: string): Promise<void> {
}
}
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<ReturnType<typeof readLocalFileSafely>>;
@@ -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);

View File

@@ -284,6 +284,32 @@ async function closeAcpRuntimeForSession(params: {
return undefined;
}
async function cleanupSessionBeforeMutation(params: {
cfg: ReturnType<typeof loadConfig>;
key: string;
target: ReturnType<typeof resolveGatewaySessionStoreTarget>;
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]);

View File

@@ -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<BoundaryFileOpenResult> {
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<ResolvedBoundaryPath>;
}):
| ResolvedBoundaryFilePath
| BoundaryFileOpenResult
| Promise<ResolvedBoundaryFilePath | BoundaryFileOpenResult> {
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);
}
}

View File

@@ -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<fs.Stats | null> {
read: (cursor: string) => fs.Stats | Promise<fs.Stats>;
}): fs.Stats | null | Promise<fs.Stats | null> {
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<string>;
}): void | Promise<void> {
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<void> {
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<ResolvedBoundaryPath | null> {
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<string | undefined> {
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;

View File

@@ -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 {

View File

@@ -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);
}

View File

@@ -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<SafeLocalReadResult> {
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<SafeLocalReadResult> {
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;

View File

@@ -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[] {

View File

@@ -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<string | number> | null;
groupAllowFrom?: Array<string | number> | null;
storeAllowFrom?: Array<string | number> | 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<string | number> | null;
groupAllowFrom?: Array<string | number> | null;
storeAllowFrom?: Array<string | number> | 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<string | number> | null;
groupAllowFrom?: Array<string | number> | null;
storeAllowFrom?: Array<string | number> | 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[];