mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-09 05:19:32 +08:00
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 <noreply@anthropic.com> * 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 <reyifeijun@gmail.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Gustavo Madeira Santana <gumadeiras@gmail.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<EmbeddedPiRunResult> {
|
||||
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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -20,6 +20,7 @@ export type ClientToolDefinition = {
|
||||
export type RunEmbeddedPiAgentParams = {
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
messageChannel?: string;
|
||||
messageProvider?: string;
|
||||
agentAccountId?: string;
|
||||
|
||||
@@ -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;
|
||||
|
||||
139
src/agents/workspace-run.test.ts
Normal file
139
src/agents/workspace-run.test.ts
Normal file
@@ -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));
|
||||
});
|
||||
});
|
||||
106
src/agents/workspace-run.ts
Normal file
106
src/agents/workspace-run.ts
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -319,6 +319,7 @@ async function probeTarget(params: {
|
||||
await runEmbeddedPiAgent({
|
||||
sessionId,
|
||||
sessionFile,
|
||||
agentId,
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: cfg,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
14
src/logging/redact-identifier.ts
Normal file
14
src/logging/redact-identifier.ts
Normal file
@@ -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)}`;
|
||||
}
|
||||
25
src/routing/session-key.test.ts
Normal file
25
src/routing/session-key.test.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user