mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 15:30:39 +00:00
refactor: dedupe runtime and helper flows
This commit is contained in:
@@ -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)}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
*
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
75
src/infra/executable-path.ts
Normal file
75
src/infra/executable-path.ts
Normal 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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user