From 421644940517ae1857281bddf8603aeef9cebf1c Mon Sep 17 00:00:00 2001 From: Yida-Dev <92713555+Yida-Dev@users.noreply.github.com> Date: Sat, 7 Feb 2026 01:16:58 +0700 Subject: [PATCH] fix: guard resolveUserPath against undefined input (#10176) * fix: guard resolveUserPath against undefined input When subagent spawner omits workspaceDir, resolveUserPath receives undefined and crashes on .trim(). Add a falsy guard that falls back to process.cwd(), matching the behavior callers already expect. Closes #10089 Co-Authored-By: Claude Opus 4.6 * fix: harden runner workspace fallback (#10176) (thanks @Yida-Dev) * fix: harden workspace fallback scoping (#10176) (thanks @Yida-Dev) * refactor: centralize workspace fallback classification and redaction (#10176) (thanks @Yida-Dev) * test: remove explicit any from utils mock (#10176) (thanks @Yida-Dev) * security: reject malformed agent session keys for workspace resolution (#10176) (thanks @Yida-Dev) --------- Co-authored-by: Yida-Dev Co-authored-by: Claude Opus 4.6 Co-authored-by: Gustavo Madeira Santana --- CHANGELOG.md | 1 + src/agents/cli-runner.test.ts | 83 +++++++++++ src/agents/cli-runner.ts | 21 ++- src/agents/pi-embedded-runner.test.ts | 69 +++++++++ src/agents/pi-embedded-runner/run.ts | 23 ++- src/agents/pi-embedded-runner/run/attempt.ts | 13 +- src/agents/pi-embedded-runner/run/params.ts | 1 + src/agents/pi-embedded-runner/run/types.ts | 1 + src/agents/workspace-run.test.ts | 139 ++++++++++++++++++ src/agents/workspace-run.ts | 106 +++++++++++++ .../reply/agent-runner-execution.ts | 2 + src/auto-reply/reply/agent-runner-memory.ts | 1 + src/auto-reply/reply/followup-runner.ts | 1 + src/commands/agent.ts | 2 + src/commands/models/list.probe.ts | 1 + src/commands/status-all/channels.ts | 8 +- src/cron/isolated-agent/run.ts | 2 + src/hooks/llm-slug-generator.ts | 1 + src/logging/redact-identifier.ts | 14 ++ src/routing/session-key.test.ts | 25 ++++ src/routing/session-key.ts | 12 ++ src/utils.test.ts | 20 +-- 22 files changed, 522 insertions(+), 24 deletions(-) create mode 100644 src/agents/workspace-run.test.ts create mode 100644 src/agents/workspace-run.ts create mode 100644 src/logging/redact-identifier.ts create mode 100644 src/routing/session-key.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index e83528b4e0..83a5192d08 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai - Control UI: add hardened fallback for asset resolution in global npm installs. (#4855) Thanks @anapivirtua. - Update: remove dead restore control-ui step that failed on gitignored dist/ output. - Update: avoid wiping prebuilt Control UI assets during dev auto-builds (`tsdown --no-clean`), run update doctor via `openclaw.mjs`, and auto-restore missing UI assets after doctor. (#10146) Thanks @gumadeiras. +- Agents: harden embedded and CLI runner workspace resolution for missing/blank runtime inputs by falling back to per-agent workspace defaults (not CWD), preventing `sessions_spawn` early crashes. (#10176) Thanks @Yida-Dev. - Models: add forward-compat fallback for `openai-codex/gpt-5.3-codex` when model registry hasn't discovered it yet. (#9989) Thanks @w1kke. - Auto-reply/Docs: normalize `extra-high` (and spaced variants) to `xhigh` for Codex thinking levels, and align Codex 5.3 FAQ examples. (#9976) Thanks @slonce70. - Compaction: remove orphaned `tool_result` messages during history pruning to prevent session corruption from aborted tool calls. (#9868, fixes #9769, #9724, #9672) diff --git a/src/agents/cli-runner.test.ts b/src/agents/cli-runner.test.ts index 2293648e2e..b5f5e5ba52 100644 --- a/src/agents/cli-runner.test.ts +++ b/src/agents/cli-runner.test.ts @@ -1,4 +1,8 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; import type { CliBackendConfig } from "../config/types.js"; import { runCliAgent } from "./cli-runner.js"; import { cleanupSuspendedCliProcesses } from "./cli-runner/helpers.js"; @@ -58,6 +62,85 @@ describe("runCliAgent resume cleanup", () => { expect(pkillArgs[1]).toContain("resume"); expect(pkillArgs[1]).toContain("thread-123"); }); + + it("falls back to per-agent workspace when workspaceDir is missing", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-runner-")); + const fallbackWorkspace = path.join(tempDir, "workspace-main"); + await fs.mkdir(fallbackWorkspace, { recursive: true }); + const cfg = { + agents: { + defaults: { + workspace: fallbackWorkspace, + }, + }, + } satisfies OpenClawConfig; + + runExecMock.mockResolvedValue({ stdout: "", stderr: "" }); + runCommandWithTimeoutMock.mockResolvedValueOnce({ + stdout: "ok", + stderr: "", + code: 0, + signal: null, + killed: false, + }); + + try { + await runCliAgent({ + sessionId: "s1", + sessionKey: "agent:main:subagent:missing-workspace", + sessionFile: "/tmp/session.jsonl", + workspaceDir: undefined as unknown as string, + config: cfg, + prompt: "hi", + provider: "codex-cli", + model: "gpt-5.2-codex", + timeoutMs: 1_000, + runId: "run-1", + }); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + + const options = runCommandWithTimeoutMock.mock.calls[0]?.[1] as { cwd?: string }; + expect(options.cwd).toBe(path.resolve(fallbackWorkspace)); + }); + + it("throws when sessionKey is malformed", async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-cli-runner-")); + const mainWorkspace = path.join(tempDir, "workspace-main"); + const researchWorkspace = path.join(tempDir, "workspace-research"); + await fs.mkdir(mainWorkspace, { recursive: true }); + await fs.mkdir(researchWorkspace, { recursive: true }); + const cfg = { + agents: { + defaults: { + workspace: mainWorkspace, + }, + list: [{ id: "research", workspace: researchWorkspace }], + }, + } satisfies OpenClawConfig; + + try { + await expect( + runCliAgent({ + sessionId: "s1", + sessionKey: "agent::broken", + agentId: "research", + sessionFile: "/tmp/session.jsonl", + workspaceDir: undefined as unknown as string, + config: cfg, + prompt: "hi", + provider: "codex-cli", + model: "gpt-5.2-codex", + timeoutMs: 1_000, + runId: "run-2", + }), + ).rejects.toThrow("Malformed agent session key"); + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + expect(runCommandWithTimeoutMock).not.toHaveBeenCalled(); + }); }); describe("cleanupSuspendedCliProcesses", () => { diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 4b4c108e41..68dbf0d5c2 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -7,7 +7,6 @@ import { shouldLogVerbose } from "../globals.js"; import { isTruthyEnvValue } from "../infra/env.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { runCommandWithTimeout } from "../process/exec.js"; -import { resolveUserPath } from "../utils.js"; import { resolveSessionAgentIds } from "./agent-scope.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "./bootstrap-files.js"; import { resolveCliBackendConfig } from "./cli-backends.js"; @@ -29,12 +28,14 @@ import { import { resolveOpenClawDocsPath } from "./docs-path.js"; import { FailoverError, resolveFailoverStatus } from "./failover-error.js"; import { classifyFailoverReason, isFailoverErrorMessage } from "./pi-embedded-helpers.js"; +import { redactRunIdentifier, resolveRunWorkspaceDir } from "./workspace-run.js"; const log = createSubsystemLogger("agent/claude-cli"); export async function runCliAgent(params: { sessionId: string; sessionKey?: string; + agentId?: string; sessionFile: string; workspaceDir: string; config?: OpenClawConfig; @@ -51,7 +52,21 @@ export async function runCliAgent(params: { images?: ImageContent[]; }): Promise { const started = Date.now(); - const resolvedWorkspace = resolveUserPath(params.workspaceDir); + const workspaceResolution = resolveRunWorkspaceDir({ + workspaceDir: params.workspaceDir, + sessionKey: params.sessionKey, + agentId: params.agentId, + config: params.config, + }); + const resolvedWorkspace = workspaceResolution.workspaceDir; + const redactedSessionId = redactRunIdentifier(params.sessionId); + const redactedSessionKey = redactRunIdentifier(params.sessionKey); + const redactedWorkspace = redactRunIdentifier(resolvedWorkspace); + if (workspaceResolution.usedFallback) { + log.warn( + `[workspace-fallback] caller=runCliAgent reason=${workspaceResolution.fallbackReason} run=${params.runId} session=${redactedSessionId} sessionKey=${redactedSessionKey} agent=${workspaceResolution.agentId} workspace=${redactedWorkspace}`, + ); + } const workspaceDir = resolvedWorkspace; const backendResolved = resolveCliBackendConfig(params.provider, params.config); @@ -311,6 +326,7 @@ export async function runCliAgent(params: { export async function runClaudeCliAgent(params: { sessionId: string; sessionKey?: string; + agentId?: string; sessionFile: string; workspaceDir: string; config?: OpenClawConfig; @@ -328,6 +344,7 @@ export async function runClaudeCliAgent(params: { return runCliAgent({ sessionId: params.sessionId, sessionKey: params.sessionKey, + agentId: params.agentId, sessionFile: params.sessionFile, workspaceDir: params.workspaceDir, config: params.config, diff --git a/src/agents/pi-embedded-runner.test.ts b/src/agents/pi-embedded-runner.test.ts index 8db5994d99..698bc8466d 100644 --- a/src/agents/pi-embedded-runner.test.ts +++ b/src/agents/pi-embedded-runner.test.ts @@ -219,6 +219,75 @@ describe("runEmbeddedPiAgent", () => { await expect(fs.stat(path.join(agentDir, "models.json"))).resolves.toBeTruthy(); }); + it("falls back to per-agent workspace when runtime workspaceDir is missing", async () => { + const sessionFile = nextSessionFile(); + const fallbackWorkspace = path.join(tempRoot ?? os.tmpdir(), "workspace-fallback-main"); + const cfg = { + ...makeOpenAiConfig(["mock-1"]), + agents: { + defaults: { + workspace: fallbackWorkspace, + }, + }, + } satisfies OpenClawConfig; + await ensureModels(cfg); + + const result = await runEmbeddedPiAgent({ + sessionId: "session:test-fallback", + sessionKey: "agent:main:subagent:fallback-workspace", + sessionFile, + workspaceDir: undefined as unknown as string, + config: cfg, + prompt: "hello", + provider: "openai", + model: "mock-1", + timeoutMs: 5_000, + agentDir, + runId: "run-fallback-workspace", + enqueue: immediateEnqueue, + }); + + expect(result.payloads?.[0]?.text).toBe("ok"); + await expect(fs.stat(fallbackWorkspace)).resolves.toBeTruthy(); + }); + + it("throws when sessionKey is malformed", async () => { + const sessionFile = nextSessionFile(); + const cfg = { + ...makeOpenAiConfig(["mock-1"]), + agents: { + defaults: { + workspace: path.join(tempRoot ?? os.tmpdir(), "workspace-fallback-main"), + }, + list: [ + { + id: "research", + workspace: path.join(tempRoot ?? os.tmpdir(), "workspace-fallback-research"), + }, + ], + }, + } satisfies OpenClawConfig; + await ensureModels(cfg); + + await expect( + runEmbeddedPiAgent({ + sessionId: "session:test-fallback-malformed", + sessionKey: "agent::broken", + agentId: "research", + sessionFile, + workspaceDir: undefined as unknown as string, + config: cfg, + prompt: "hello", + provider: "openai", + model: "mock-1", + timeoutMs: 5_000, + agentDir, + runId: "run-fallback-workspace-malformed", + enqueue: immediateEnqueue, + }), + ).rejects.toThrow("Malformed agent session key"); + }); + itIfNotWin32( "persists the first user message before assistant output", { timeout: 120_000 }, diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index d7fb2693d7..c8ca9b5a19 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -3,7 +3,6 @@ import type { ThinkLevel } from "../../auto-reply/thinking.js"; import type { RunEmbeddedPiAgentParams } from "./run/params.js"; import type { EmbeddedPiAgentMeta, EmbeddedPiRunResult } from "./types.js"; import { enqueueCommandInLane } from "../../process/command-queue.js"; -import { resolveUserPath } from "../../utils.js"; import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js"; import { resolveOpenClawAgentDir } from "../agent-paths.js"; import { @@ -46,6 +45,7 @@ import { type FailoverReason, } from "../pi-embedded-helpers.js"; import { normalizeUsage, type UsageLike } from "../usage.js"; +import { redactRunIdentifier, resolveRunWorkspaceDir } from "../workspace-run.js"; import { compactEmbeddedPiSessionDirect } from "./compact.js"; import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; import { log } from "./logger.js"; @@ -92,7 +92,21 @@ export async function runEmbeddedPiAgent( return enqueueSession(() => enqueueGlobal(async () => { const started = Date.now(); - const resolvedWorkspace = resolveUserPath(params.workspaceDir); + const workspaceResolution = resolveRunWorkspaceDir({ + workspaceDir: params.workspaceDir, + sessionKey: params.sessionKey, + agentId: params.agentId, + config: params.config, + }); + const resolvedWorkspace = workspaceResolution.workspaceDir; + const redactedSessionId = redactRunIdentifier(params.sessionId); + const redactedSessionKey = redactRunIdentifier(params.sessionKey); + const redactedWorkspace = redactRunIdentifier(resolvedWorkspace); + if (workspaceResolution.usedFallback) { + log.warn( + `[workspace-fallback] caller=runEmbeddedPiAgent reason=${workspaceResolution.fallbackReason} run=${params.runId} session=${redactedSessionId} sessionKey=${redactedSessionKey} agent=${workspaceResolution.agentId} workspace=${redactedWorkspace}`, + ); + } const prevCwd = process.cwd(); const provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER; @@ -333,7 +347,7 @@ export async function runEmbeddedPiAgent( replyToMode: params.replyToMode, hasRepliedRef: params.hasRepliedRef, sessionFile: params.sessionFile, - workspaceDir: params.workspaceDir, + workspaceDir: resolvedWorkspace, agentDir, config: params.config, skillsSnapshot: params.skillsSnapshot, @@ -345,6 +359,7 @@ export async function runEmbeddedPiAgent( model, authStorage, modelRegistry, + agentId: workspaceResolution.agentId, thinkLevel, verboseLevel: params.verboseLevel, reasoningLevel: params.reasoningLevel, @@ -401,7 +416,7 @@ export async function runEmbeddedPiAgent( agentAccountId: params.agentAccountId, authProfileId: lastProfileId, sessionFile: params.sessionFile, - workspaceDir: params.workspaceDir, + workspaceDir: resolvedWorkspace, agentDir, config: params.config, skillsSnapshot: params.skillsSnapshot, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 83fe17cfb1..2e6c702929 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -10,7 +10,7 @@ import { resolveChannelCapabilities } from "../../../config/channel-capabilities import { getMachineDisplayName } from "../../../infra/machine-name.js"; import { MAX_IMAGE_BYTES } from "../../../media/constants.js"; import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js"; -import { isSubagentSessionKey } from "../../../routing/session-key.js"; +import { isSubagentSessionKey, normalizeAgentId } from "../../../routing/session-key.js"; import { resolveSignalReactionLevel } from "../../../signal/reaction-level.js"; import { resolveTelegramInlineButtonsScope } from "../../../telegram/inline-buttons.js"; import { resolveTelegramReactionLevel } from "../../../telegram/reaction-level.js"; @@ -705,6 +705,13 @@ export async function runEmbeddedAttempt( // Get hook runner once for both before_agent_start and agent_end hooks const hookRunner = getGlobalHookRunner(); + const hookAgentId = + typeof params.agentId === "string" && params.agentId.trim() + ? normalizeAgentId(params.agentId) + : resolveSessionAgentIds({ + sessionKey: params.sessionKey, + config: params.config, + }).sessionAgentId; let promptError: unknown = null; try { @@ -720,7 +727,7 @@ export async function runEmbeddedAttempt( messages: activeSession.messages, }, { - agentId: params.sessionKey?.split(":")[0] ?? "main", + agentId: hookAgentId, sessionKey: params.sessionKey, workspaceDir: params.workspaceDir, messageProvider: params.messageProvider ?? undefined, @@ -850,7 +857,7 @@ export async function runEmbeddedAttempt( durationMs: Date.now() - promptStartedAt, }, { - agentId: params.sessionKey?.split(":")[0] ?? "main", + agentId: hookAgentId, sessionKey: params.sessionKey, workspaceDir: params.workspaceDir, messageProvider: params.messageProvider ?? undefined, diff --git a/src/agents/pi-embedded-runner/run/params.ts b/src/agents/pi-embedded-runner/run/params.ts index 93f5c5c927..f56f3ecac2 100644 --- a/src/agents/pi-embedded-runner/run/params.ts +++ b/src/agents/pi-embedded-runner/run/params.ts @@ -20,6 +20,7 @@ export type ClientToolDefinition = { export type RunEmbeddedPiAgentParams = { sessionId: string; sessionKey?: string; + agentId?: string; messageChannel?: string; messageProvider?: string; agentAccountId?: string; diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index 931afcd24c..181a42c9f9 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -14,6 +14,7 @@ import type { ClientToolDefinition } from "./params.js"; export type EmbeddedRunAttemptParams = { sessionId: string; sessionKey?: string; + agentId?: string; messageChannel?: string; messageProvider?: string; agentAccountId?: string; diff --git a/src/agents/workspace-run.test.ts b/src/agents/workspace-run.test.ts new file mode 100644 index 0000000000..bb99f51778 --- /dev/null +++ b/src/agents/workspace-run.test.ts @@ -0,0 +1,139 @@ +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveRunWorkspaceDir } from "./workspace-run.js"; +import { DEFAULT_AGENT_WORKSPACE_DIR } from "./workspace.js"; + +describe("resolveRunWorkspaceDir", () => { + it("resolves explicit workspace values without fallback", () => { + const explicit = path.join(process.cwd(), "tmp", "workspace-run-explicit"); + const result = resolveRunWorkspaceDir({ + workspaceDir: explicit, + sessionKey: "agent:main:subagent:test", + }); + + expect(result.usedFallback).toBe(false); + expect(result.agentId).toBe("main"); + expect(result.workspaceDir).toBe(path.resolve(explicit)); + }); + + it("falls back to configured per-agent workspace when input is missing", () => { + const defaultWorkspace = path.join(process.cwd(), "tmp", "workspace-default-main"); + const researchWorkspace = path.join(process.cwd(), "tmp", "workspace-research"); + const cfg = { + agents: { + defaults: { workspace: defaultWorkspace }, + list: [{ id: "research", workspace: researchWorkspace }], + }, + } satisfies OpenClawConfig; + + const result = resolveRunWorkspaceDir({ + workspaceDir: undefined, + sessionKey: "agent:research:subagent:test", + config: cfg, + }); + + expect(result.usedFallback).toBe(true); + expect(result.fallbackReason).toBe("missing"); + expect(result.agentId).toBe("research"); + expect(result.workspaceDir).toBe(path.resolve(researchWorkspace)); + }); + + it("falls back to default workspace for blank strings", () => { + const defaultWorkspace = path.join(process.cwd(), "tmp", "workspace-default-main"); + const cfg = { + agents: { + defaults: { workspace: defaultWorkspace }, + }, + } satisfies OpenClawConfig; + + const result = resolveRunWorkspaceDir({ + workspaceDir: " ", + sessionKey: "agent:main:subagent:test", + config: cfg, + }); + + expect(result.usedFallback).toBe(true); + expect(result.fallbackReason).toBe("blank"); + expect(result.agentId).toBe("main"); + expect(result.workspaceDir).toBe(path.resolve(defaultWorkspace)); + }); + + it("falls back to built-in main workspace when config is unavailable", () => { + const result = resolveRunWorkspaceDir({ + workspaceDir: null, + sessionKey: "agent:main:subagent:test", + config: undefined, + }); + + expect(result.usedFallback).toBe(true); + expect(result.fallbackReason).toBe("missing"); + expect(result.agentId).toBe("main"); + expect(result.workspaceDir).toBe(path.resolve(DEFAULT_AGENT_WORKSPACE_DIR)); + }); + + it("throws for malformed agent session keys", () => { + expect(() => + resolveRunWorkspaceDir({ + workspaceDir: undefined, + sessionKey: "agent::broken", + config: undefined, + }), + ).toThrow("Malformed agent session key"); + }); + + it("uses explicit agent id for per-agent fallback when config is unavailable", () => { + const result = resolveRunWorkspaceDir({ + workspaceDir: undefined, + sessionKey: "definitely-not-a-valid-session-key", + agentId: "research", + config: undefined, + }); + + expect(result.agentId).toBe("research"); + expect(result.agentIdSource).toBe("explicit"); + expect(result.workspaceDir).toBe(path.resolve(os.homedir(), ".openclaw", "workspace-research")); + }); + + it("throws for malformed agent session keys even when config has a default agent", () => { + const mainWorkspace = path.join(process.cwd(), "tmp", "workspace-main-default"); + const researchWorkspace = path.join(process.cwd(), "tmp", "workspace-research-default"); + const cfg = { + agents: { + defaults: { workspace: mainWorkspace }, + list: [ + { id: "main", workspace: mainWorkspace }, + { id: "research", workspace: researchWorkspace, default: true }, + ], + }, + } satisfies OpenClawConfig; + + expect(() => + resolveRunWorkspaceDir({ + workspaceDir: undefined, + sessionKey: "agent::broken", + config: cfg, + }), + ).toThrow("Malformed agent session key"); + }); + + it("treats non-agent legacy keys as default, not malformed", () => { + const fallbackWorkspace = path.join(process.cwd(), "tmp", "workspace-default-legacy"); + const cfg = { + agents: { + defaults: { workspace: fallbackWorkspace }, + }, + } satisfies OpenClawConfig; + + const result = resolveRunWorkspaceDir({ + workspaceDir: undefined, + sessionKey: "custom-main-key", + config: cfg, + }); + + expect(result.agentId).toBe("main"); + expect(result.agentIdSource).toBe("default"); + expect(result.workspaceDir).toBe(path.resolve(fallbackWorkspace)); + }); +}); diff --git a/src/agents/workspace-run.ts b/src/agents/workspace-run.ts new file mode 100644 index 0000000000..1061a0344e --- /dev/null +++ b/src/agents/workspace-run.ts @@ -0,0 +1,106 @@ +import type { OpenClawConfig } from "../config/config.js"; +import { redactIdentifier } from "../logging/redact-identifier.js"; +import { + classifySessionKeyShape, + DEFAULT_AGENT_ID, + normalizeAgentId, + parseAgentSessionKey, +} from "../routing/session-key.js"; +import { resolveUserPath } from "../utils.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js"; + +export type WorkspaceFallbackReason = "missing" | "blank" | "invalid_type"; +type AgentIdSource = "explicit" | "session_key" | "default"; + +export type ResolveRunWorkspaceResult = { + workspaceDir: string; + usedFallback: boolean; + fallbackReason?: WorkspaceFallbackReason; + agentId: string; + agentIdSource: AgentIdSource; +}; + +function resolveRunAgentId(params: { + sessionKey?: string; + agentId?: string; + config?: OpenClawConfig; +}): { + agentId: string; + agentIdSource: AgentIdSource; +} { + const rawSessionKey = params.sessionKey?.trim() ?? ""; + const shape = classifySessionKeyShape(rawSessionKey); + if (shape === "malformed_agent") { + throw new Error("Malformed agent session key; refusing workspace resolution."); + } + + const explicit = + typeof params.agentId === "string" && params.agentId.trim() + ? normalizeAgentId(params.agentId) + : undefined; + if (explicit) { + return { agentId: explicit, agentIdSource: "explicit" }; + } + + const defaultAgentId = resolveDefaultAgentId(params.config ?? {}); + if (shape === "missing" || shape === "legacy_or_alias") { + return { + agentId: defaultAgentId || DEFAULT_AGENT_ID, + agentIdSource: "default", + }; + } + + const parsed = parseAgentSessionKey(rawSessionKey); + if (parsed?.agentId) { + return { + agentId: normalizeAgentId(parsed.agentId), + agentIdSource: "session_key", + }; + } + + // Defensive fallback, should be unreachable for non-malformed shapes. + return { + agentId: defaultAgentId || DEFAULT_AGENT_ID, + agentIdSource: "default", + }; +} + +export function redactRunIdentifier(value: string | undefined): string { + return redactIdentifier(value, { len: 12 }); +} + +export function resolveRunWorkspaceDir(params: { + workspaceDir: unknown; + sessionKey?: string; + agentId?: string; + config?: OpenClawConfig; +}): ResolveRunWorkspaceResult { + const requested = params.workspaceDir; + const { agentId, agentIdSource } = resolveRunAgentId({ + sessionKey: params.sessionKey, + agentId: params.agentId, + config: params.config, + }); + if (typeof requested === "string") { + const trimmed = requested.trim(); + if (trimmed) { + return { + workspaceDir: resolveUserPath(trimmed), + usedFallback: false, + agentId, + agentIdSource, + }; + } + } + + const fallbackReason: WorkspaceFallbackReason = + requested == null ? "missing" : typeof requested === "string" ? "blank" : "invalid_type"; + const fallbackWorkspace = resolveAgentWorkspaceDir(params.config ?? {}, agentId); + return { + workspaceDir: resolveUserPath(fallbackWorkspace), + usedFallback: true, + fallbackReason, + agentId, + agentIdSource, + }; +} diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 3bdc5dde39..372db8b303 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -178,6 +178,7 @@ export async function runAgentTurnWithFallback(params: { const result = await runCliAgent({ sessionId: params.followupRun.run.sessionId, sessionKey: params.sessionKey, + agentId: params.followupRun.run.agentId, sessionFile: params.followupRun.run.sessionFile, workspaceDir: params.followupRun.run.workspaceDir, config: params.followupRun.run.config, @@ -255,6 +256,7 @@ export async function runAgentTurnWithFallback(params: { return runEmbeddedPiAgent({ sessionId: params.followupRun.run.sessionId, sessionKey: params.sessionKey, + agentId: params.followupRun.run.agentId, messageProvider: params.sessionCtx.Provider?.trim().toLowerCase() || undefined, agentAccountId: params.sessionCtx.AccountId, messageTo: params.sessionCtx.OriginatingTo ?? params.sessionCtx.To, diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index 867ba42f92..f73c5c60dd 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -113,6 +113,7 @@ export async function runMemoryFlushIfNeeded(params: { return runEmbeddedPiAgent({ sessionId: params.followupRun.run.sessionId, sessionKey: params.sessionKey, + agentId: params.followupRun.run.agentId, messageProvider: params.sessionCtx.Provider?.trim().toLowerCase() || undefined, agentAccountId: params.sessionCtx.AccountId, messageTo: params.sessionCtx.OriginatingTo ?? params.sessionCtx.To, diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 1ca51d0f4b..e4c23aa043 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -140,6 +140,7 @@ export function createFollowupRunner(params: { return runEmbeddedPiAgent({ sessionId: queued.run.sessionId, sessionKey: queued.run.sessionKey, + agentId: queued.run.agentId, messageProvider: queued.run.messageProvider, agentAccountId: queued.run.agentAccountId, messageTo: queued.originatingTo, diff --git a/src/commands/agent.ts b/src/commands/agent.ts index a426775513..4c08d75df6 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -398,6 +398,7 @@ export async function agentCommand( return runCliAgent({ sessionId, sessionKey, + agentId: sessionAgentId, sessionFile, workspaceDir, config: cfg, @@ -418,6 +419,7 @@ export async function agentCommand( return runEmbeddedPiAgent({ sessionId, sessionKey, + agentId: sessionAgentId, messageChannel, agentAccountId: runContext.accountId, messageTo: opts.replyTo ?? opts.to, diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts index ee7a874fe8..1c30a92eb5 100644 --- a/src/commands/models/list.probe.ts +++ b/src/commands/models/list.probe.ts @@ -319,6 +319,7 @@ async function probeTarget(params: { await runEmbeddedPiAgent({ sessionId, sessionFile, + agentId, workspaceDir, agentDir, config: cfg, diff --git a/src/commands/status-all/channels.ts b/src/commands/status-all/channels.ts index d7be6ad75c..0919211612 100644 --- a/src/commands/status-all/channels.ts +++ b/src/commands/status-all/channels.ts @@ -1,4 +1,3 @@ -import crypto from "node:crypto"; import fs from "node:fs"; import type { ChannelAccountSnapshot, @@ -8,6 +7,7 @@ import type { import type { OpenClawConfig } from "../../config/config.js"; import { resolveChannelDefaultAccountId } from "../../channels/plugins/helpers.js"; import { listChannelPlugins } from "../../channels/plugins/index.js"; +import { sha256HexPrefix } from "../../logging/redact-identifier.js"; import { formatAge } from "./format.js"; export type ChannelRow = { @@ -57,17 +57,13 @@ function existsSyncMaybe(p: string | undefined): boolean | null { } } -function sha256HexPrefix(value: string, len = 8): string { - return crypto.createHash("sha256").update(value).digest("hex").slice(0, len); -} - function formatTokenHint(token: string, opts: { showSecrets: boolean }): string { const t = token.trim(); if (!t) { return "empty"; } if (!opts.showSecrets) { - return `sha256:${sha256HexPrefix(t)} · len ${t.length}`; + return `sha256:${sha256HexPrefix(t, 8)} · len ${t.length}`; } const head = t.slice(0, 4); const tail = t.slice(-4); diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index 6a557db34d..3273cb8f9b 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -356,6 +356,7 @@ export async function runCronIsolatedAgentTurn(params: { return runCliAgent({ sessionId: cronSession.sessionEntry.sessionId, sessionKey: agentSessionKey, + agentId, sessionFile, workspaceDir, config: cfgWithAgentDefaults, @@ -371,6 +372,7 @@ export async function runCronIsolatedAgentTurn(params: { return runEmbeddedPiAgent({ sessionId: cronSession.sessionEntry.sessionId, sessionKey: agentSessionKey, + agentId, messageChannel, agentAccountId: resolvedDelivery.accountId, sessionFile, diff --git a/src/hooks/llm-slug-generator.ts b/src/hooks/llm-slug-generator.ts index 95161b66b4..67fdfe4c83 100644 --- a/src/hooks/llm-slug-generator.ts +++ b/src/hooks/llm-slug-generator.ts @@ -41,6 +41,7 @@ Reply with ONLY the slug, nothing else. Examples: "vendor-pitch", "api-design", const result = await runEmbeddedPiAgent({ sessionId: `slug-generator-${Date.now()}`, sessionKey: "temp:slug-generator", + agentId, sessionFile: tempSessionFile, workspaceDir, agentDir, diff --git a/src/logging/redact-identifier.ts b/src/logging/redact-identifier.ts new file mode 100644 index 0000000000..0ffdfb55d5 --- /dev/null +++ b/src/logging/redact-identifier.ts @@ -0,0 +1,14 @@ +import crypto from "node:crypto"; + +export function sha256HexPrefix(value: string, len = 12): string { + const safeLen = Number.isFinite(len) ? Math.max(1, Math.floor(len)) : 12; + return crypto.createHash("sha256").update(value).digest("hex").slice(0, safeLen); +} + +export function redactIdentifier(value: string | undefined, opts?: { len?: number }): string { + const trimmed = value?.trim(); + if (!trimmed) { + return "-"; + } + return `sha256:${sha256HexPrefix(trimmed, opts?.len ?? 12)}`; +} diff --git a/src/routing/session-key.test.ts b/src/routing/session-key.test.ts new file mode 100644 index 0000000000..6c3539f73d --- /dev/null +++ b/src/routing/session-key.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { classifySessionKeyShape } from "./session-key.js"; + +describe("classifySessionKeyShape", () => { + it("classifies empty keys as missing", () => { + expect(classifySessionKeyShape(undefined)).toBe("missing"); + expect(classifySessionKeyShape(" ")).toBe("missing"); + }); + + it("classifies valid agent keys", () => { + expect(classifySessionKeyShape("agent:main:main")).toBe("agent"); + expect(classifySessionKeyShape("agent:research:subagent:worker")).toBe("agent"); + }); + + it("classifies malformed agent keys", () => { + expect(classifySessionKeyShape("agent::broken")).toBe("malformed_agent"); + expect(classifySessionKeyShape("agent:main")).toBe("malformed_agent"); + }); + + it("treats non-agent legacy or alias keys as non-malformed", () => { + expect(classifySessionKeyShape("main")).toBe("legacy_or_alias"); + expect(classifySessionKeyShape("custom-main")).toBe("legacy_or_alias"); + expect(classifySessionKeyShape("subagent:worker")).toBe("legacy_or_alias"); + }); +}); diff --git a/src/routing/session-key.ts b/src/routing/session-key.ts index 8f2b4ab0da..ad1d16431a 100644 --- a/src/routing/session-key.ts +++ b/src/routing/session-key.ts @@ -10,6 +10,7 @@ export { export const DEFAULT_AGENT_ID = "main"; export const DEFAULT_MAIN_KEY = "main"; export const DEFAULT_ACCOUNT_ID = "default"; +export type SessionKeyShape = "missing" | "agent" | "legacy_or_alias" | "malformed_agent"; // Pre-compiled regex const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i; @@ -58,6 +59,17 @@ export function resolveAgentIdFromSessionKey(sessionKey: string | undefined | nu return normalizeAgentId(parsed?.agentId ?? DEFAULT_AGENT_ID); } +export function classifySessionKeyShape(sessionKey: string | undefined | null): SessionKeyShape { + const raw = (sessionKey ?? "").trim(); + if (!raw) { + return "missing"; + } + if (parseAgentSessionKey(raw)) { + return "agent"; + } + return raw.toLowerCase().startsWith("agent:") ? "malformed_agent" : "legacy_or_alias"; +} + export function normalizeAgentId(value: string | undefined | null): string { const trimmed = (value ?? "").trim(); if (!trimmed) { diff --git a/src/utils.test.ts b/src/utils.test.ts index 2b0d95e6bf..3ae0be47c0 100644 --- a/src/utils.test.ts +++ b/src/utils.test.ts @@ -79,15 +79,12 @@ describe("jidToE164", () => { it("maps @lid using reverse mapping file", () => { const mappingPath = path.join(CONFIG_DIR, "credentials", "lid-mapping-123_reverse.json"); const original = fs.readFileSync; - const spy = vi - .spyOn(fs, "readFileSync") - // oxlint-disable-next-line typescript/no-explicit-any - .mockImplementation((path: any, encoding?: any) => { - if (path === mappingPath) { - return `"5551234"`; - } - return original(path, encoding); - }); + const spy = vi.spyOn(fs, "readFileSync").mockImplementation((...args) => { + if (args[0] === mappingPath) { + return `"5551234"`; + } + return original(...args); + }); expect(jidToE164("123@lid")).toBe("+5551234"); spy.mockRestore(); }); @@ -167,4 +164,9 @@ describe("resolveUserPath", () => { it("resolves relative paths", () => { expect(resolveUserPath("tmp/dir")).toBe(path.resolve("tmp/dir")); }); + + it("keeps blank paths blank", () => { + expect(resolveUserPath("")).toBe(""); + expect(resolveUserPath(" ")).toBe(""); + }); });