fix(sessions): prevent lifecycle races and unsafe session management (#114477)

* fix(sessions): harden lifecycle, ownership, and subagent spawning

* test(agents): refresh session tool prompt snapshots

* fix(sessions): await rebuilding transcript projections

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Peter Steinberger
2026-07-27 06:17:51 -04:00
committed by GitHub
parent 7b29256e38
commit 9f5ba09a0d
32 changed files with 1214 additions and 102 deletions

View File

@@ -13,7 +13,7 @@ OpenClaw gives agents tools to work across sessions, inspect status, and orchest
| Tool | What it does |
| -------------------- | --------------------------------------------------------------------------- |
| `sessions` | Patch visible session settings and manage the global session-group catalog |
| `sessions` | Patch, reset, or delete visible sessions and manage session groups |
| `sessions_list` | List sessions with optional filters (kind, label, agent, archive, preview) |
| `sessions_search` | Search visible session transcripts and return matching excerpts |
| `sessions_history` | Read the transcript of a specific session |
@@ -58,11 +58,15 @@ Use [`sessions_search`](/concepts/session-search) for exact full-text recall acr
## Managing session settings and groups
The owner-gated `sessions` tool exposes two bounded self-service surfaces:
The owner-gated `sessions` tool exposes bounded self-service surfaces:
- `action: "patch"` changes the current session by default, or another visible session selected by `sessionKey`. It can set the label, sidebar icon, pin/archive state, model, and thinking level. It does not expose reset, delete, or compact actions.
- `action: "patch"` changes the current session by default, or another visible session selected by `sessionKey`. It can set the label, sidebar icon, pin/archive state, model, and thinking level.
- `action: "reset"` resets another visible session selected by `sessionKey`.
- `action: "delete"` first archives and then deletes the exact same generation of another visible session selected by `sessionKey`. By default its transcript is retained as a deleted archive; pass `deleteTranscript: false` to leave the transcript state untouched. Resetting or deleting the session currently running the tool is rejected.
- `group_list`, `group_set`, `group_rename`, and `group_delete` manage the global ordered session-group catalog. `group_set` replaces the ordered name list rather than patching one entry.
Use `sessions_spawn` with `visible: true` to create a persistent dashboard session. This keeps session creation on the controlled spawn path, which enforces the parent's tool policy, sandbox, concurrency limits, and run timeout.
An agent-selected model patch stays reversible until that selection completes a successful run. If the selected model is definitively unusable because of authentication, billing, or model-not-found failure, OpenClaw restores the previous model and writes a visible system note. Transient rate-limit, overload, timeout, network, and server failures do not undo the selection.
## Sessions versus conversations
@@ -120,6 +124,7 @@ Key options:
- `runtime: "subagent"` (default) or `"acp"` for external harness agents.
- `model` and `thinking` overrides for the child session.
- `runTimeoutSeconds` to override the configured child-run timeout; `0` disables it.
- `thread: true` to bind the spawn to a chat thread (Discord, Slack, etc.).
- `sandbox: "require"` to enforce sandboxing on the child.
- `context: "fork"` for native sub-agents when the child needs the current requester transcript; omit it or use `context: "isolated"` for a clean child. `context: "fork"` is only valid with `runtime: "subagent"`. Thread-bound native sub-agents default to `context: "fork"` unless `threadBindings.defaultSpawnContext` says otherwise.

View File

@@ -147,7 +147,7 @@ session to confirm the effective tool list.
- **Model:** native sub-agents inherit the caller unless you set `agents.defaults.subagents.model` (or per-agent `agents.entries.*.subagents.model`). ACP runtime spawns use the same configured subagent model when present; otherwise the ACP harness keeps its own default. An explicit `sessions_spawn.model` still wins.
- **Thinking:** native sub-agents inherit the caller unless you set `agents.defaults.subagents.thinking` (or per-agent `agents.entries.*.subagents.thinking`). ACP runtime spawns also apply `agents.defaults.models["provider/model"].params.thinking` for the selected model. An explicit `sessions_spawn.thinking` still wins.
- **Run timeout:** OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` when set; otherwise it falls back to `0` (no timeout). `sessions_spawn` does not accept per-call timeout overrides.
- **Run timeout:** pass `runTimeoutSeconds` to set a timeout for a specific native, ACP, or visible sub-agent run. When omitted, OpenClaw uses `agents.defaults.subagents.runTimeoutSeconds` if configured; otherwise it falls back to `0` (no timeout). An explicit `0` disables the timeout for that run.
- **Process lifetime:** a detached OpenClaw sub-agent has its own run lifecycle. A background task created inside an external CLI backend is different: it shares the parent CLI subprocess and stops if that parent reaches `agents.defaults.timeoutSeconds`.
- **Task delivery:** native sub-agents receive the delegated task in their first visible `[Subagent Task]` message. The sub-agent system prompt carries runtime rules and routing context, not a hidden duplicate of the task.
@@ -212,6 +212,9 @@ Per-agent override: `agents.entries.*.subagents.delegationMode`.
<ParamField path="model" type="string">
Override the sub-agent model. Invalid values are skipped and the sub-agent runs on the default model with a warning in the tool result.
</ParamField>
<ParamField path="runTimeoutSeconds" type="integer">
Override the configured run timeout for this child. Must be a non-negative integer; `0` disables the timeout. Applies to native, ACP, and visible sessions.
</ParamField>
<ParamField path="thinking" type="string">
Override thinking level for the sub-agent run. Not available with `visible: true`.
</ParamField>

View File

@@ -535,6 +535,23 @@ describe("createOpenClawCodingTools", () => {
expect(inheritedAllow?.includes("exec")).toBe(false);
});
it("keeps restricted spawn inheritance in the caller-owned runtime snapshot", () => {
const createOpenClawToolsMock = vi.mocked(createOpenClawTools);
createOpenClawToolsMock.mockClear();
const inheritedToolAllowlistRef: string[] = [];
createOpenClawCodingTools({
config: { tools: { allow: ["read", "sessions_spawn"] } },
inheritedToolAllowlistRef,
});
expect(latestCreateOpenClawToolsOptions().inheritedToolAllowlist).toBe(
inheritedToolAllowlistRef,
);
expectListIncludes(inheritedToolAllowlistRef, ["read", "sessions_spawn"]);
expect(inheritedToolAllowlistRef).not.toContain("exec");
});
it("preserves runtime-allowed message through restrictive profiles", () => {
const tools = createOpenClawCodingTools({
config: { tools: { profile: "minimal" } },

View File

@@ -404,6 +404,8 @@ type OpenClawCodingToolsOptions = {
runtimeToolAllowlist?: string[];
/** True when runtimeToolAllowlist is real parent authority that child sessions inherit. */
inheritRuntimeToolAllowlist?: boolean;
/** Mutable spawn capability snapshot refreshed after late-bound runtime tools are authorized. */
inheritedToolAllowlistRef?: string[];
/** Mutable cron creator cap ref for callers that append final runtime tools later. */
cronCreatorToolAllowlistRef?: CronCreatorToolAllowlistEntry[];
/** If true, the model has native vision capability */
@@ -805,7 +807,7 @@ function createOpenClawCodingToolsInternal(options?: OpenClawCodingToolsOptions)
const inheritedToolDenylist = [...pluginToolDenylist];
// Passed by reference to sessions_spawn and populated after the final policy
// pass so child sessions inherit the actual parent tool surface.
const inheritedToolAllowlist: string[] = [];
const inheritedToolAllowlist = options?.inheritedToolAllowlistRef ?? [];
const toolPolicyInheritanceSources = capabilityProfile.policy.inheritancePolicies;
const shouldInheritEffectiveToolAllowlist =
toolPolicyInheritanceSources.some(hasRestrictiveAllowPolicy);

View File

@@ -17,7 +17,15 @@ vi.mock("../../agent-bundle-mcp-tools.js", () => ({
}));
vi.mock("../../runtime-plan/tools.js", () => ({
normalizeAgentRuntimeTools: vi.fn(() => []),
normalizeAgentRuntimeTools: vi.fn(({ tools }: { tools: unknown[] }) => tools),
}));
vi.mock("../../local-model-lean.js", () => ({
filterLocalModelLeanTools: vi.fn(({ tools }: { tools: unknown[] }) => tools),
}));
vi.mock("../../tool-schema-projection.js", () => ({
filterRuntimeCompatibleTools: vi.fn((tools: unknown[]) => ({ tools, diagnostics: [] })),
}));
vi.mock("../effective-tool-policy.js", () => ({
@@ -35,6 +43,74 @@ import { prepareEmbeddedAttemptBundleTools } from "./attempt-bundle-tools.js";
describe("prepareEmbeddedAttemptBundleTools", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.createBundleLspToolRuntime.mockReset().mockResolvedValue(undefined);
mocks.getOrCreateSessionMcpRuntime.mockReset().mockResolvedValue(undefined);
mocks.materializeBundleMcpToolsForRun.mockReset().mockResolvedValue(undefined);
mocks.applyFinalEffectiveToolPolicy
.mockReset()
.mockImplementation(({ bundledTools }: { bundledTools: unknown[] }) => bundledTools);
});
function createInput(inheritedToolAllowlist: string[], toolsRaw: unknown[]) {
return {
agentDir: "/tmp/agent",
attempt: {
config: {},
model: {},
modelId: "model",
provider: "provider",
runId: "run",
runtimePlan: {},
sessionId: "session",
},
effectiveWorkspace: "/tmp/workspace",
getCurrentAttemptPluginMetadataSnapshot: () => undefined,
getProviderRuntimeHandle: () => undefined,
isRawModelRun: false,
preparedToolBase: {
cronCreatorToolAllowlist: [],
effectiveToolsAllow: undefined,
inheritedToolAllowlist,
localModelLeanPreserveToolNames: [],
runtimeCapabilityProfile: undefined,
toolsEnabled: true,
toolsRaw,
},
sessionAgentId: "main",
} as unknown as Parameters<typeof prepareEmbeddedAttemptBundleTools>[0];
}
it("refreshes spawned-child inheritance after authorized MCP tools materialize", async () => {
const inheritedToolAllowlist = ["sessions_spawn"];
mocks.getOrCreateSessionMcpRuntime.mockResolvedValue({});
mocks.materializeBundleMcpToolsForRun.mockResolvedValue({
tools: [{ name: "server__read" }],
});
await prepareEmbeddedAttemptBundleTools(
createInput(inheritedToolAllowlist, [{ name: "sessions_spawn" }]),
);
expect(inheritedToolAllowlist).toEqual(["sessions_spawn", "server__read"]);
});
it("never adds policy-denied bundled tools to spawned-child inheritance", async () => {
const inheritedToolAllowlist = ["sessions_spawn"];
mocks.getOrCreateSessionMcpRuntime.mockResolvedValue({});
mocks.materializeBundleMcpToolsForRun.mockResolvedValue({
tools: [{ name: "server__read" }, { name: "server__delete" }],
});
mocks.applyFinalEffectiveToolPolicy.mockImplementation(
({ bundledTools }: { bundledTools: Array<{ name: string }> }) =>
bundledTools.filter((tool) => tool.name !== "server__delete"),
);
await prepareEmbeddedAttemptBundleTools(
createInput(inheritedToolAllowlist, [{ name: "sessions_spawn" }]),
);
expect(inheritedToolAllowlist).toEqual(["sessions_spawn", "server__read"]);
expect(inheritedToolAllowlist).not.toContain("server__delete");
});
it("disposes prepared bundle runtimes when later policy setup fails", async () => {

View File

@@ -6,6 +6,7 @@ import {
} from "../../agent-bundle-mcp-tools.js";
import { filterLocalModelLeanTools } from "../../local-model-lean.js";
import { normalizeAgentRuntimeTools } from "../../runtime-plan/tools.js";
import { replaceWithEffectiveToolAllowlist } from "../../tool-policy.js";
import { filterRuntimeCompatibleTools } from "../../tool-schema-projection.js";
import { logRuntimeToolSchemaQuarantine } from "../../tool-schema-quarantine.js";
import { replaceWithEffectiveCronCreatorToolAllowlist } from "../../tools/cron-tool.js";
@@ -36,6 +37,7 @@ export async function prepareEmbeddedAttemptBundleTools(params: {
const {
cronCreatorToolAllowlist,
effectiveToolsAllow,
inheritedToolAllowlist,
localModelLeanPreserveToolNames,
runtimeCapabilityProfile,
toolsEnabled,
@@ -199,6 +201,12 @@ export async function prepareEmbeddedAttemptBundleTools(params: {
);
}
const schemaProjection = filterRuntimeCompatibleTools(projectedTools);
if (inheritedToolAllowlist?.length) {
// Spawn tools close over this ref before MCP/LSP materialize. Refresh it
// only after final policy and schema projection so children inherit the
// parent's complete authorized surface, never denied bundled tools.
replaceWithEffectiveToolAllowlist(inheritedToolAllowlist, schemaProjection.tools);
}
logRuntimeToolSchemaQuarantine({
diagnostics: schemaProjection.diagnostics,
tools: projectedTools,

View File

@@ -118,6 +118,7 @@ export function prepareEmbeddedAttemptToolBase(params: {
: undefined;
const toolSearchTargetTranscriptProjections: ToolSearchTargetTranscriptProjection[] = [];
const cronCreatorToolAllowlist: CronCreatorToolAllowlistEntry[] = [];
const inheritedToolAllowlist: string[] = [];
const spawnWorkspaceDir =
params.effectiveCwd !== params.effectiveWorkspace
? params.resolvedWorkspace
@@ -303,6 +304,7 @@ export function prepareEmbeddedAttemptToolBase(params: {
enableHeartbeatTool: attempt.enableHeartbeatTool,
forceHeartbeatTool: attempt.forceHeartbeatTool,
runtimeToolAllowlist: effectiveToolsAllow,
inheritedToolAllowlistRef: inheritedToolAllowlist,
cronCreatorToolAllowlistRef: cronCreatorToolAllowlist,
authProfileStore: attempt.authProfileStore,
recordToolPrepStage: params.markCoreToolStage,
@@ -335,6 +337,7 @@ export function prepareEmbeddedAttemptToolBase(params: {
computerContextEpoch,
cronCreatorToolAllowlist,
effectiveToolsAllow,
inheritedToolAllowlist,
localModelLeanEnabled,
localModelLeanPreserveToolNames,
replaySafetyOptions,

View File

@@ -71,7 +71,7 @@ export function hasRestrictiveAllowPolicy(policy?: { allow?: string[] }): boolea
/** Replaces an allowlist with the normalized names of an effective tool array. */
export function replaceWithEffectiveToolAllowlist(
target: string[],
tools: Array<{ name: string }>,
tools: ReadonlyArray<{ name: string }>,
): void {
target.length = 0;
const seen = new Set<string>();

View File

@@ -250,16 +250,17 @@ describe("sessions_spawn tool", () => {
expect(schema.properties?.runtime?.enum).toEqual(["subagent", "acp"]);
});
it("does not expose timeout override fields to the model", () => {
it("exposes the canonical per-run timeout without advertising a wait-timeout alias", () => {
const tool = createSessionsSpawnTool();
const schema = tool.parameters as {
properties?: {
runTimeoutSeconds?: unknown;
runTimeoutSeconds?: { type?: string; minimum?: number; description?: string };
timeoutSeconds?: unknown;
};
};
expect(schema.properties?.runTimeoutSeconds).toBeUndefined();
expect(schema.properties?.runTimeoutSeconds).toMatchObject({ type: "integer", minimum: 0 });
expect(schema.properties?.runTimeoutSeconds?.description).toContain("configured subagent");
expect(schema.properties?.timeoutSeconds).toBeUndefined();
});
@@ -494,6 +495,38 @@ describe("sessions_spawn tool", () => {
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
});
it("applies a per-run timeout to visible dashboard sessions", async () => {
const callGateway = vi.fn(async () => ({
key: "agent:main:dashboard:timed-child",
runStarted: true,
runId: "run-visible-timed",
}));
const registerRun = vi.fn();
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
config: {
agents: {
defaults: { subagents: { runTimeoutSeconds: 120 } },
list: [{ id: "main" }],
},
},
callGateway: callGateway as never,
registerRun,
countActiveRuns: () => 0,
});
const result = await tool.execute("visible-timeout", {
task: "inspect issue",
visible: true,
runTimeoutSeconds: 7,
});
expect(result.details).toMatchObject({ status: "accepted", runId: "run-visible-timed" });
expect(registerRun).toHaveBeenCalledWith(
expect.objectContaining({ runId: "run-visible-timed", runTimeoutSeconds: 7 }),
);
});
it("uses the target agent model for cross-agent visible sessions", async () => {
const callGateway = vi.fn(async () => ({
key: "agent:reviewer:dashboard:child",
@@ -1234,28 +1267,68 @@ describe("sessions_spawn tool", () => {
expect(result.details).not.toHaveProperty("role");
});
it.each([
"runTimeoutSeconds",
"timeoutSeconds",
"run_timeout_seconds",
"timeout_seconds",
] as const)("rejects stale timeout override argument %s", async (timeoutParam) => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
});
it.each(["runTimeoutSeconds", "run_timeout_seconds"] as const)(
"forwards native per-run timeout argument %s",
async (timeoutParam) => {
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:main" });
await expect(
tool.execute("call-stale-timeout-override", {
await tool.execute("call-run-timeout", {
task: "do thing",
[timeoutParam]: 2,
}),
).rejects.toThrow(
`sessions_spawn does not support per-call "${timeoutParam}". Configure agents.defaults.subagents.runTimeoutSeconds instead.`,
);
});
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith(
expect.objectContaining({ task: "do thing", runTimeoutSeconds: 2 }),
expect.any(Object),
);
},
);
it("forwards a zero native timeout so a child can explicitly disable the default", async () => {
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:main" });
await tool.execute("call-no-timeout", { task: "do thing", runTimeoutSeconds: 0 });
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith(
expect.objectContaining({ task: "do thing", runTimeoutSeconds: 0 }),
expect.any(Object),
);
});
it.each([-1, 0.5, Number.NaN, Number.POSITIVE_INFINITY, "not-a-number"])(
"rejects invalid native timeout %s before spawning",
async (runTimeoutSeconds) => {
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:main" });
await expect(
tool.execute("call-invalid-timeout", { task: "do thing", runTimeoutSeconds }),
).rejects.toThrow("runTimeoutSeconds must be a non-negative integer");
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
expect(hoisted.spawnAcpDirectMock).not.toHaveBeenCalled();
},
);
it.each(["timeoutSeconds", "timeout_seconds"] as const)(
"rejects ambiguous wait-timeout argument %s",
async (timeoutParam) => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
});
await expect(
tool.execute("call-stale-timeout-override", {
task: "do thing",
[timeoutParam]: 2,
}),
).rejects.toThrow(
`sessions_spawn does not support "${timeoutParam}". Use "runTimeoutSeconds" for a per-run timeout.`,
);
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
},
);
it("passes inherited workspaceDir from tool context, not from tool args", async () => {
const tool = createSessionsSpawnTool({
agentSessionKey: "agent:main:main",
@@ -1491,6 +1564,27 @@ describe("sessions_spawn tool", () => {
expect(spawnArgs.model).toBe("github-copilot/claude-sonnet-4.6");
});
it("forwards a per-run timeout to ACP runtime spawns", async () => {
registerAcpBackendForTest();
const tool = createSessionsSpawnTool({ agentSessionKey: "agent:main:main" });
await tool.execute("call-acp-timeout", {
runtime: "acp",
task: "investigate the failing CI run",
agentId: "codex",
runTimeoutSeconds: 45,
});
expect(hoisted.spawnAcpDirectMock).toHaveBeenCalledWith(
expect.objectContaining({
task: "investigate the failing CI run",
agentId: "codex",
runTimeoutSeconds: 45,
}),
expect.any(Object),
);
});
it("adds requested role to forwarded ACP failures", async () => {
registerAcpBackendForTest();
hoisted.spawnAcpDirectMock.mockResolvedValueOnce({

View File

@@ -44,6 +44,7 @@ import type { AnyAgentTool } from "./common.js";
import {
jsonResult,
normalizeToolModelOverride,
readNonNegativeIntegerParam,
readStringParam,
ToolInputError,
} from "./common.js";
@@ -67,11 +68,6 @@ const UNSUPPORTED_SESSIONS_SPAWN_PARAM_KEYS = [
"replyTo",
"reply_to",
] as const;
const UNSUPPORTED_SESSIONS_SPAWN_TIMEOUT_PARAM_KEYS = [
"runTimeoutSeconds",
"timeoutSeconds",
] as const;
type AcpSpawnModule = typeof import("../acp-spawn.js");
const acpSpawnModuleLoader = createLazyImportLoader<AcpSpawnModule>(
@@ -151,6 +147,13 @@ function createSessionsSpawnToolSchema(params: {
),
agentId: Type.Optional(Type.String()),
model: Type.Optional(Type.String()),
runTimeoutSeconds: Type.Optional(
Type.Integer({
minimum: 0,
description:
"Per-run timeout in seconds; overrides the configured subagent default. Zero disables the timeout.",
}),
),
thinking: Type.Optional(
Type.String({ description: "Thinking override; unavailable with visible=true." }),
),
@@ -324,17 +327,14 @@ export function createSessionsSpawnTool(
`sessions_spawn does not support "${unsupportedParam}". Use "message" or "sessions_send" for channel delivery.`,
);
}
const unsupportedTimeoutParam = UNSUPPORTED_SESSIONS_SPAWN_TIMEOUT_PARAM_KEYS.find((key) =>
resolveSnakeCaseParamKey(params, key),
);
const unsupportedTimeoutParam = resolveSnakeCaseParamKey(params, "timeoutSeconds");
if (unsupportedTimeoutParam) {
const providedTimeoutParam =
resolveSnakeCaseParamKey(params, unsupportedTimeoutParam) ?? unsupportedTimeoutParam;
throw new ToolInputError(
`sessions_spawn does not support per-call "${providedTimeoutParam}". Configure agents.defaults.subagents.runTimeoutSeconds instead.`,
`sessions_spawn does not support "${unsupportedTimeoutParam}". Use "runTimeoutSeconds" for a per-run timeout.`,
);
}
const task = readStringParam(params, "task", { required: true });
const runTimeoutSeconds = readNonNegativeIntegerParam(params, "runTimeoutSeconds");
const taskNameResult = normalizeSubagentTaskName(params.taskName);
if (taskNameResult.error) {
return jsonResult({
@@ -370,6 +370,7 @@ export function createSessionsSpawnTool(
label,
runtime,
requestedAgentId,
runTimeoutSeconds,
sandbox,
options: opts,
});
@@ -445,6 +446,7 @@ export function createSessionsSpawnTool(
resumeSessionId,
model: modelOverride,
thinking: thinkingOverrideRaw,
...(runTimeoutSeconds !== undefined ? { runTimeoutSeconds } : {}),
cwd,
mode: mode === "run" || mode === "session" ? mode : undefined,
thread,
@@ -485,6 +487,7 @@ export function createSessionsSpawnTool(
agentId: requestedAgentId,
model: modelOverride,
thinking: thinkingOverrideRaw,
...(runTimeoutSeconds !== undefined ? { runTimeoutSeconds } : {}),
collect: hasCollectParam ? collect : undefined,
outputSchema:
params.outputSchema && typeof params.outputSchema === "object"

View File

@@ -92,6 +92,7 @@ export async function maybeSpawnVisibleSession(params: {
label: string;
runtime: "subagent" | "acp";
requestedAgentId?: string;
runTimeoutSeconds?: number;
sandbox: "inherit" | "require";
options?: VisibleSessionsSpawnOptions;
}): Promise<Record<string, unknown> | undefined> {
@@ -227,7 +228,10 @@ export async function maybeSpawnVisibleSession(params: {
}
const resolvedModel =
modelOverride ?? resolveSubagentSpawnModelSelection({ cfg, agentId: targetAgentId });
const runTimeoutSeconds = resolveConfiguredSubagentRunTimeoutSeconds({ cfg });
const runTimeoutSeconds = resolveConfiguredSubagentRunTimeoutSeconds({
cfg,
runTimeoutSeconds: params.runTimeoutSeconds,
});
const requesterRuntime = resolveSandboxRuntimeStatus({ cfg, sessionKey: requesterKey });
const childRuntime = resolveSandboxRuntimeStatus({
cfg,

View File

@@ -45,8 +45,17 @@ describe("sessions tool", () => {
properties: {
action: {
type: "string",
enum: ["patch", "group_list", "group_set", "group_rename", "group_delete"],
enum: [
"patch",
"reset",
"delete",
"group_list",
"group_set",
"group_rename",
"group_delete",
],
},
deleteTranscript: { type: "boolean" },
label: { type: "string", description: expect.stringContaining("Empty string clears") },
statusNote: { type: "string", maxLength: 120 },
attention: {
@@ -57,6 +66,135 @@ describe("sessions tool", () => {
archived: { type: "boolean", description: expect.stringContaining("without deleting") },
},
});
expect(tool.parameters).not.toHaveProperty("properties.message");
});
it("does not expose direct session creation outside controlled spawning", async () => {
const callGateway = vi.fn();
const tool = createSessionsTool({
agentSessionKey: "agent:main:main",
config: {},
callGateway,
});
await expect(tool.execute("create-uncontrolled-session", { action: "create" })).rejects.toThrow(
"Unknown action: create",
);
expect(tool.parameters).not.toHaveProperty("properties.parentSessionKey");
expect(tool.parameters).not.toHaveProperty("properties.agentId");
expect(tool.parameters).not.toHaveProperty("properties.fork");
expect(callGateway).not.toHaveBeenCalled();
});
it("archives a visible target before write-scoped session deletion", async () => {
const sessionKey = "agent:main:dashboard:finished";
const sessionId = "finished-session";
const lifecycleRevision = "finished-revision";
const callGateway = vi.fn(async (method: string) =>
method === "sessions.patch"
? { ok: true, entry: { sessionId, lifecycleRevision } }
: { ok: true, deleted: true },
);
const tool = createSessionsTool({
agentSessionKey: "agent:main:main",
config: { tools: { sessions: { visibility: "agent" } } },
callGateway: callGateway as never,
});
await tool.execute("delete-session", { action: "delete", sessionKey });
expect(callGateway.mock.calls).toEqual([
["sessions.patch", { key: sessionKey, archived: true }],
[
"sessions.delete",
{
key: sessionKey,
archivedOnly: true,
expectedSessionId: sessionId,
expectedLifecycleRevision: lifecycleRevision,
deleteTranscript: true,
},
],
]);
});
it("forwards an explicit transcript-preservation choice on deletion", async () => {
const sessionKey = "agent:main:dashboard:finished";
const sessionId = "finished-session";
const callGateway = vi.fn(async (method: string) =>
method === "sessions.patch"
? { ok: true, entry: { sessionId } }
: { ok: true, deleted: true },
);
const tool = createSessionsTool({
agentSessionKey: "agent:main:main",
config: { tools: { sessions: { visibility: "agent" } } },
callGateway: callGateway as never,
});
await tool.execute("delete-preserve", {
action: "delete",
sessionKey,
deleteTranscript: false,
});
expect(callGateway).toHaveBeenLastCalledWith("sessions.delete", {
key: sessionKey,
archivedOnly: true,
expectedSessionId: sessionId,
deleteTranscript: false,
});
});
it("does not delete a session when archive cannot identify its generation", async () => {
const sessionKey = "agent:main:dashboard:finished";
const callGateway = vi.fn(async () => ({ ok: true }));
const tool = createSessionsTool({
agentSessionKey: "agent:main:main",
config: { tools: { sessions: { visibility: "agent" } } },
callGateway: callGateway as never,
});
await expect(
tool.execute("delete-missing-generation", { action: "delete", sessionKey }),
).rejects.toThrow("archive did not return its session identity");
expect(callGateway).toHaveBeenCalledTimes(1);
expect(callGateway).toHaveBeenCalledWith("sessions.patch", {
key: sessionKey,
archived: true,
});
});
it("resets another visible session through the canonical gateway method", async () => {
const sessionKey = "agent:main:dashboard:reset-me";
const callGateway = vi.fn(async () => ({ ok: true }));
const tool = createSessionsTool({
agentSessionKey: "agent:main:main",
config: { tools: { sessions: { visibility: "agent" } } },
callGateway: callGateway as never,
});
await tool.execute("reset-session", { action: "reset", sessionKey });
expect(callGateway).toHaveBeenCalledWith("sessions.reset", {
key: sessionKey,
reason: "reset",
});
});
it.each(["delete", "reset"])("refuses to %s its currently running session", async (action) => {
const callGateway = vi.fn();
const tool = createSessionsTool({
agentSessionKey: "agent:main:main",
config: {},
callGateway,
});
await expect(
tool.execute(`self-${action}`, { action, sessionKey: "agent:main:main" }),
).rejects.toThrow(`Cannot ${action} the session running this tool`);
expect(callGateway).not.toHaveBeenCalled();
});
it("patches its session, then reverts a failed agent-selected model", async () => {

View File

@@ -23,7 +23,15 @@ import {
import { resolveSessionToolContext } from "./sessions-helpers.js";
import { resolveSessionReference } from "./sessions-resolution.js";
const ACTIONS = ["patch", "group_list", "group_set", "group_rename", "group_delete"] as const;
const ACTIONS = [
"patch",
"reset",
"delete",
"group_list",
"group_set",
"group_rename",
"group_delete",
] as const;
const GROUP_NAME_MAX_LENGTH = 512;
const GROUP_NAMES_MAX_ITEMS = 200;
@@ -31,6 +39,9 @@ const SessionsToolSchema = Type.Object(
{
action: stringEnum(ACTIONS, { description: "Action" }),
sessionKey: Type.Optional(Type.String({ description: "Target session. Default: current" })),
deleteTranscript: Type.Optional(
Type.Boolean({ description: "Archive the deleted session transcript. Default: true." }),
),
label: Type.Optional(
Type.String({ description: "Sidebar title override. Empty string clears it." }),
),
@@ -185,11 +196,49 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool
label: "Sessions",
name: "sessions",
description:
"Session settings and groups: patch label/icon/status, pin, archive/restore, model/thinking override; group_list/group_set/group_rename/group_delete.",
"Session settings, reset, delete, and groups: patch label/icon/status, pin, archive/restore, model/thinking override; reset/delete visible sessions; group_list/group_set/group_rename/group_delete.",
parameters: SessionsToolSchema,
execute: async (_toolCallId, rawArgs) => {
const params = rawArgs as Record<string, unknown>;
const action = readStringParam(params, "action", { required: true });
if (action === "reset" || action === "delete") {
const rawKey = readStringParam(params, "sessionKey", { required: true });
const { key } = await resolvePatchTarget(
{ ...opts, config: opts.config ?? getRuntimeConfig() },
rawKey,
);
const context = resolveSessionToolContext({
...opts,
config: opts.config ?? getRuntimeConfig(),
});
if (key === context.effectiveRequesterKey) {
throw new ToolInputError(`Cannot ${action} the session running this tool`);
}
if (action === "reset") {
return jsonResult(await gatewayCall("sessions.reset", { key, reason: "reset" }));
}
// Archive returns the exact row generation. Carry it into the locked
// delete so a concurrent reset cannot delete a replacement session.
const archived = await gatewayCall<{
entry?: { sessionId?: string; lifecycleRevision?: string };
}>("sessions.patch", { key, archived: true });
const expectedSessionId = normalizeOptionalString(archived.entry?.sessionId);
if (!expectedSessionId) {
throw new ToolInputError("Session archive did not return its session identity");
}
const expectedLifecycleRevision = normalizeOptionalString(
archived.entry?.lifecycleRevision,
);
return jsonResult(
await gatewayCall("sessions.delete", {
key,
archivedOnly: true,
expectedSessionId,
...(expectedLifecycleRevision ? { expectedLifecycleRevision } : {}),
deleteTranscript: readBoolean(params, "deleteTranscript") ?? true,
}),
);
}
if (action === "group_list") {
return jsonResult(await gatewayCall("sessions.groups.list", {}));
}

View File

@@ -27,7 +27,10 @@ import {
toDatabaseOptions,
} from "./session-accessor.sqlite-scope.js";
import type { SessionTranscriptProjectionState } from "./session-transcript-index.js";
import { startSessionTranscriptIndexReconcile } from "./session-transcript-reconcile.js";
import {
startSessionTranscriptIndexReconcile,
waitForSessionTranscriptIndexReconcile,
} from "./session-transcript-reconcile.js";
type ActiveTranscriptDatabase = Pick<
OpenClawAgentKyselyDatabase,
@@ -83,6 +86,14 @@ export function isSessionTranscriptProjectionUnavailableError(
return error instanceof SessionTranscriptProjectionUnavailableError;
}
/** Waits for a projection rebuild already scheduled by a failed transcript read. */
export async function waitForSessionTranscriptProjection(
scope: SessionTranscriptReadScope,
): Promise<void> {
const resolved = resolveSqliteTranscriptReadScope(scope);
await waitForSessionTranscriptIndexReconcile(toDatabaseOptions(resolved));
}
type CurrentProjection = {
database: OpenClawAgentDatabase;
resolved: ReturnType<typeof resolveSqliteTranscriptReadScope>;

View File

@@ -216,6 +216,7 @@ export {
readSessionTranscriptMessageEvents,
readSessionTranscriptVisibleMessageDelta,
SessionTranscriptProjectionUnavailableError,
waitForSessionTranscriptProjection,
} from "./session-accessor.sqlite-active-events.js";
export type {
SessionTranscriptMessageAnchorPage,

View File

@@ -360,6 +360,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
resetMainWhenUnspecified: !hasInitialTurn,
commandSource: "webchat",
creation: resolveOperatorSessionCreation(client, { allowTrustedHint: true }),
authorizedPluginId: normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId),
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
afterCreate: hasInitialTurn
? async ({ key, agentId, entry, storePath }) => {

View File

@@ -32,7 +32,7 @@ import { emitSessionsChanged } from "./session-change-event.js";
import {
loadAccessorSessionEntryForGatewayTarget,
loadSessionsRuntimeModule,
rejectPluginRuntimeDeleteMismatch,
rejectPluginRuntimeSessionOwnershipMismatch,
requireSessionKey,
resolveGatewaySessionTargetFromKey,
resolveSessionWorkerPlacementMutationError,
@@ -181,7 +181,8 @@ export const sessionDeleteHandlers: GatewayRequestHandlers = {
return;
}
if (
rejectPluginRuntimeDeleteMismatch({
rejectPluginRuntimeSessionOwnershipMismatch({
action: "delete",
client,
key: target.canonicalKey ?? key,
entry: initialDeleteEntry,
@@ -304,7 +305,8 @@ export const sessionDeleteHandlers: GatewayRequestHandlers = {
return undefined;
}
if (
rejectPluginRuntimeDeleteMismatch({
rejectPluginRuntimeSessionOwnershipMismatch({
action: "delete",
client,
key: canonicalKey ?? key,
entry,

View File

@@ -45,6 +45,7 @@ import { resolveOperatorSessionCreation } from "./session-creation-provenance.js
import {
isAgentMainSessionKey,
loadSessionsRuntimeModule,
rejectPluginRuntimeSessionOwnershipMismatch,
requireSessionKey,
resolveGatewaySessionTargetFromKey,
resolveSessionWorkerPlacementPatchError,
@@ -76,6 +77,17 @@ export const sessionMutationHandlers: GatewayRequestHandlers = {
});
const canonicalKey = target.canonicalKey ?? key;
const lifecycleEntry = loadSessionEntry(key, { agentId: requestedAgentId }).entry;
if (
rejectPluginRuntimeSessionOwnershipMismatch({
action: "patch",
client,
key: canonicalKey,
entry: lifecycleEntry,
respond,
})
) {
return;
}
const missingHarnessSessionError = resolveMissingAgentHarnessSessionError(
canonicalKey,
lifecycleEntry,
@@ -126,6 +138,19 @@ export const sessionMutationHandlers: GatewayRequestHandlers = {
};
const applyPatch = async () => {
const currentLifecycleEntry = loadSessionEntry(key, { agentId: requestedAgentId }).entry;
// Recheck inside the lifecycle lock so a replaced row cannot switch
// plugin ownership between authorization and the committed patch.
if (
rejectPluginRuntimeSessionOwnershipMismatch({
action: "patch",
client,
key: canonicalKey,
entry: currentLifecycleEntry,
respond,
})
) {
return null;
}
// A reset queued ahead of archive can rotate the row before this mutation starts.
// Never apply stale destructive intent to the replacement session identity.
const lifecycleEntryRemoved =
@@ -450,6 +475,7 @@ export const sessionMutationHandlers: GatewayRequestHandlers = {
reason,
commandSource: "gateway:sessions.reset",
creation: resolveOperatorSessionCreation(client),
authorizedPluginId: normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId),
assertAuthorizedInstance: sessionMutationAuthorization?.assertCurrent,
});
if (!result.ok) {

View File

@@ -13,6 +13,10 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
import { createLazyRuntimeModule } from "../../shared/lazy-runtime.js";
import {
resolvePluginSessionOwnershipError,
type PluginSessionOwnershipAction,
} from "../session-plugin-ownership.js";
import { resolveSessionStoreAgentId, resolveSessionStoreKey } from "../session-store-key.js";
import {
resolveFreshestSessionEntryFromStoreKeys,
@@ -161,27 +165,23 @@ export function requireSessionKey(key: unknown, respond: RespondFn): string | nu
return normalized;
}
export function rejectPluginRuntimeDeleteMismatch(params: {
export function rejectPluginRuntimeSessionOwnershipMismatch(params: {
action: PluginSessionOwnershipAction;
client: GatewayClient | null;
key: string;
entry: SessionEntry | undefined;
respond: RespondFn;
}): boolean {
const pluginOwnerId = normalizeOptionalString(params.client?.internal?.pluginRuntimeOwnerId);
if (!pluginOwnerId || !params.entry) {
const error = resolvePluginSessionOwnershipError({
action: params.action,
entry: params.entry,
key: params.key,
pluginOwnerId: params.client?.internal?.pluginRuntimeOwnerId,
});
if (!error) {
return false;
}
if (normalizeOptionalString(params.entry.pluginOwnerId) === pluginOwnerId) {
return false;
}
params.respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`Plugin "${pluginOwnerId}" cannot delete session "${params.key}" because it did not create it.`,
),
);
params.respond(false, undefined, error);
return true;
}

View File

@@ -62,6 +62,72 @@ const { createSessionStoreDir, createSelectedGlobalSessionStore, openClient } =
const execFileAsync = promisify(execFile);
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
test("sessions.create and sessions.delete preserve every concurrent session lifecycle", async () => {
const { storePath } = await createSessionStoreDir();
const sessionCount = 24;
const created = await Promise.all(
Array.from({ length: sessionCount }, (_, index) =>
directSessionReq<{ key: string; sessionId: string }>("sessions.create", {
agentId: "main",
label: `Concurrent session ${index}`,
}),
),
);
expect(created.every((result) => result.ok)).toBe(true);
const sessionKeys = created.map((result) =>
requireNonEmptyString(result.payload?.key, "concurrent session key"),
);
const sessionIds = created.map((result) =>
requireNonEmptyString(result.payload?.sessionId, "concurrent session id"),
);
expect(new Set(sessionKeys).size).toBe(sessionCount);
expect(new Set(sessionIds).size).toBe(sessionCount);
for (const [index, sessionKey] of sessionKeys.entries()) {
expect(loadSessionEntry({ sessionKey, storePath })).toMatchObject({
sessionId: sessionIds[index],
label: `Concurrent session ${index}`,
});
}
const deleted = await Promise.all(
sessionKeys.map((key) =>
directSessionReq<{ deleted: boolean }>("sessions.delete", {
key,
deleteTranscript: false,
}),
),
);
expect(deleted.every((result) => result.ok && result.payload?.deleted === true)).toBe(true);
for (const sessionKey of sessionKeys) {
expect(loadSessionEntry({ sessionKey, storePath })).toBeUndefined();
}
});
test("concurrent sessions.create requests adopt one canonical keyed session", async () => {
const { storePath } = await createSessionStoreDir();
const key = "agent:main:dashboard:concurrent-keyed-session";
const created = await Promise.all(
Array.from({ length: 16 }, () =>
directSessionReq<{ key: string; sessionId: string }>("sessions.create", {
agentId: "main",
key,
}),
),
);
expect(created.every((result) => result.ok)).toBe(true);
expect(new Set(created.map((result) => result.payload?.key))).toEqual(new Set([key]));
const sessionIds = new Set(created.map((result) => result.payload?.sessionId));
expect(sessionIds.size).toBe(1);
expect(loadSessionEntry({ sessionKey: key, storePath })?.sessionId).toBe(
created[0]?.payload?.sessionId,
);
});
test("sessions.create keeps incognito rows process-local through list, spawn, reset, and delete", async () => {
const { storePath } = await createSessionStoreDir();
try {
@@ -2549,6 +2615,125 @@ test("sessions.create sends selected global initial tasks to the requested agent
ws.close();
});
test("sessions.create gives plugin runtimes an owned root without linking operator sessions", async () => {
const { storePath } = await createSessionStoreDir();
await writeSessionStore({
entries: { main: sessionStoreEntry("operator-owned-main") },
});
const pluginClient = {
connect: { scopes: ["operator.write"] },
internal: { pluginRuntimeOwnerId: "memory-core" },
} as never;
const created = await directSessionReq<{
key: string;
entry: { parentSessionKey?: string; pluginOwnerId?: string };
}>("sessions.create", { agentId: "main" }, { client: pluginClient });
expect(created.ok, JSON.stringify(created.error)).toBe(true);
const key = requireNonEmptyString(created.payload?.key, "plugin-owned root session key");
expect(created.payload?.entry).toMatchObject({ pluginOwnerId: "memory-core" });
expect(created.payload?.entry.parentSessionKey).toBeUndefined();
expect(loadSessionEntry({ sessionKey: key, storePath })).toMatchObject({
pluginOwnerId: "memory-core",
});
const patched = await directSessionReq(
"sessions.patch",
{ key, label: "Plugin-owned root" },
{ client: pluginClient },
);
expect(patched.ok, JSON.stringify(patched.error)).toBe(true);
expect(loadSessionEntry({ sessionKey: key, storePath })).toMatchObject({
label: "Plugin-owned root",
pluginOwnerId: "memory-core",
});
});
test.each([
{
name: "forking a foreign plugin transcript",
params: { parentSessionKey: "agent:main:dashboard:foreign-owned", fork: true },
action: "fork",
target: "agent:main:dashboard:foreign-owned",
},
{
name: "linking a foreign plugin parent",
params: { parentSessionKey: "agent:main:dashboard:foreign-owned" },
action: "link",
target: "agent:main:dashboard:foreign-owned",
},
{
name: "forking an operator-owned transcript",
params: { parentSessionKey: "agent:main:dashboard:operator-owned", fork: true },
action: "fork",
target: "agent:main:dashboard:operator-owned",
},
{
name: "adopting a foreign plugin session",
params: { key: "agent:main:dashboard:foreign-owned" },
action: "adopt",
target: "agent:main:dashboard:foreign-owned",
},
{
name: "adopting an operator-owned session",
params: { key: "agent:main:dashboard:operator-owned" },
action: "adopt",
target: "agent:main:dashboard:operator-owned",
},
])("sessions.create prevents plugin runtimes from $name", async ({ params, action, target }) => {
const { storePath } = await createSessionStoreDir();
await writeSessionStore({
entries: {
"agent:main:dashboard:foreign-owned": sessionStoreEntry("foreign-session", {
pluginOwnerId: "other-plugin",
}),
"agent:main:dashboard:operator-owned": sessionStoreEntry("operator-session"),
},
});
const pluginClient = {
connect: { scopes: ["operator.write"] },
internal: { pluginRuntimeOwnerId: "memory-core" },
} as never;
const created = await directSessionReq("sessions.create", params, { client: pluginClient });
expect(created.ok).toBe(false);
expect(created.error).toMatchObject({
code: "INVALID_REQUEST",
message: `Plugin "memory-core" cannot ${action} session "${target}" because it did not create it.`,
});
expect(loadSessionEntry({ sessionKey: target, storePath })).toBeDefined();
});
test("sessions.create allows plugin runtimes to link their own parent session", async () => {
const { storePath } = await createSessionStoreDir();
const parentSessionKey = "agent:main:dashboard:memory-core-parent";
await writeSessionStore({
entries: {
[parentSessionKey]: sessionStoreEntry("memory-core-parent", {
pluginOwnerId: "memory-core",
}),
},
});
const pluginClient = {
connect: { scopes: ["operator.write"] },
internal: { pluginRuntimeOwnerId: "memory-core" },
} as never;
const created = await directSessionReq<{
key: string;
entry: { parentSessionKey?: string };
}>("sessions.create", { parentSessionKey }, { client: pluginClient });
expect(created.ok, JSON.stringify(created.error)).toBe(true);
expect(created.payload?.entry.parentSessionKey).toBe(parentSessionKey);
expect(loadSessionEntry({ sessionKey: parentSessionKey, storePath })?.pluginOwnerId).toBe(
"memory-core",
);
});
test("sessions.create rejects unknown parentSessionKey", async () => {
await createSessionStoreDir();

View File

@@ -0,0 +1,286 @@
import { expect, test } from "vitest";
import {
loadSessionEntry,
replaceSessionEntry,
replaceSessionEntrySync,
} from "../config/sessions/session-accessor.js";
import { runExclusiveSessionLifecycleMutation } from "../sessions/session-lifecycle-admission.js";
import { writeSessionStore } from "./test-helpers.js";
import {
directSessionReq,
sessionStoreEntry,
setupGatewaySessionsTestHarness,
} from "./test/server-sessions.test-helpers.js";
const { createSessionStoreDir } = setupGatewaySessionsTestHarness();
test.each([
{
name: "another plugin's session",
key: "agent:main:dreaming-narrative-foreign",
entry: sessionStoreEntry("foreign-plugin-session", { pluginOwnerId: "other-plugin" }),
},
{
name: "an operator-owned session",
key: "agent:main:dashboard:operator-owned",
entry: sessionStoreEntry("operator-owned-session"),
},
])("sessions.reset prevents a plugin from resetting $name", async ({ key, entry }) => {
const { storePath } = await createSessionStoreDir();
await writeSessionStore({ entries: { [key]: entry } });
const pluginClient = {
connect: { scopes: ["operator.write"] },
internal: { pluginRuntimeOwnerId: "memory-core" },
} as never;
const reset = await directSessionReq("sessions.reset", { key }, { client: pluginClient });
expect(reset.ok).toBe(false);
expect(reset.error).toMatchObject({
code: "INVALID_REQUEST",
message: `Plugin "memory-core" cannot reset session "${key}" because it did not create it.`,
});
expect(loadSessionEntry({ sessionKey: key, storePath })).toMatchObject({
sessionId: entry.sessionId,
...(entry.pluginOwnerId ? { pluginOwnerId: entry.pluginOwnerId } : {}),
});
});
test("sessions.reset preserves ownership when a plugin resets its own session", async () => {
const { storePath } = await createSessionStoreDir();
const sessionKey = "agent:main:dreaming-narrative-owned";
await writeSessionStore({
entries: {
[sessionKey]: sessionStoreEntry("owned-plugin-session", { pluginOwnerId: "memory-core" }),
},
});
const pluginClient = {
connect: { scopes: ["operator.write"] },
internal: { pluginRuntimeOwnerId: "memory-core" },
} as never;
const reset = await directSessionReq(
"sessions.reset",
{ key: sessionKey },
{ client: pluginClient },
);
expect(reset.ok, JSON.stringify(reset.error)).toBe(true);
expect(loadSessionEntry({ sessionKey, storePath })).toMatchObject({
pluginOwnerId: "memory-core",
});
});
test("sessions.reset stamps plugin ownership when it materializes a missing session", async () => {
const { storePath } = await createSessionStoreDir();
const sessionKey = "agent:main:dreaming-narrative-new";
const pluginClient = {
connect: { scopes: ["operator.write"] },
internal: { pluginRuntimeOwnerId: "memory-core" },
} as never;
const reset = await directSessionReq(
"sessions.reset",
{ key: sessionKey },
{ client: pluginClient },
);
expect(reset.ok, JSON.stringify(reset.error)).toBe(true);
expect(loadSessionEntry({ sessionKey, storePath })).toMatchObject({
pluginOwnerId: "memory-core",
});
const patched = await directSessionReq(
"sessions.patch",
{ key: sessionKey, label: "Reset-created plugin session" },
{ client: pluginClient },
);
expect(patched.ok, JSON.stringify(patched.error)).toBe(true);
});
test("sessions.reset rechecks plugin ownership inside lifecycle admission", async () => {
const { storePath } = await createSessionStoreDir();
const sessionKey = "agent:main:dreaming-narrative-owned";
const sessionId = "owned-plugin-session";
await writeSessionStore({
entries: {
[sessionKey]: sessionStoreEntry(sessionId, { pluginOwnerId: "memory-core" }),
},
});
const { performGatewaySessionReset } = await import("./session-reset-service.js");
let replaced = false;
const reset = await performGatewaySessionReset({
key: sessionKey,
reason: "reset",
commandSource: "gateway:sessions.reset",
authorizedPluginId: "memory-core",
assertCurrent: () => {
if (replaced) {
return;
}
replaced = true;
replaceSessionEntrySync(
{ sessionKey, storePath },
sessionStoreEntry(sessionId, { pluginOwnerId: "other-plugin" }),
);
},
});
expect(reset.ok).toBe(false);
if (!reset.ok) {
expect(reset.error.message).toBe(
`Plugin "memory-core" cannot reset session "${sessionKey}" because it did not create it.`,
);
}
expect(loadSessionEntry({ sessionKey, storePath })).toMatchObject({
pluginOwnerId: "other-plugin",
sessionId,
});
});
test("sessions.patch limits plugin-runtime mutations to sessions owned by that plugin", async () => {
const { storePath } = await createSessionStoreDir();
const ownedKey = "agent:main:dreaming-narrative-owned";
const foreignKey = "agent:main:dreaming-narrative-foreign";
const operatorKey = "agent:main:dashboard:operator-owned";
await writeSessionStore({
entries: {
[ownedKey]: sessionStoreEntry("sess-owned", { pluginOwnerId: "memory-core" }),
[foreignKey]: sessionStoreEntry("sess-foreign", { pluginOwnerId: "other-plugin" }),
[operatorKey]: sessionStoreEntry("sess-operator"),
},
});
const pluginClient = {
connect: { scopes: ["operator.admin"] },
internal: { pluginRuntimeOwnerId: "memory-core" },
} as never;
for (const key of [foreignKey, operatorKey]) {
const denied = await directSessionReq(
"sessions.patch",
{ key, label: "unauthorized mutation" },
{ client: pluginClient },
);
expect(denied.ok).toBe(false);
expect(denied.error).toMatchObject({
code: "INVALID_REQUEST",
message: `Plugin "memory-core" cannot patch session "${key}" because it did not create it.`,
});
expect(loadSessionEntry({ sessionKey: key, storePath })?.label).toBeUndefined();
}
const patched = await directSessionReq(
"sessions.patch",
{ key: ownedKey, label: "authorized mutation" },
{ client: pluginClient },
);
expect(patched.ok, JSON.stringify(patched.error)).toBe(true);
expect(loadSessionEntry({ sessionKey: ownedKey, storePath })?.label).toBe("authorized mutation");
});
test("sessions.patch rechecks plugin ownership after waiting for lifecycle admission", async () => {
const { storePath } = await createSessionStoreDir();
const sessionKey = "agent:main:dreaming-narrative-owned";
const sessionId = "sess-owned";
await writeSessionStore({
entries: {
[sessionKey]: sessionStoreEntry(sessionId, { pluginOwnerId: "memory-core" }),
},
});
const pluginClient = {
connect: { scopes: ["operator.admin"] },
internal: { pluginRuntimeOwnerId: "memory-core" },
} as never;
let releaseMutation = () => {};
let markMutationStarted = () => {};
const mutationStarted = new Promise<void>((resolve) => {
markMutationStarted = resolve;
});
const mutation = runExclusiveSessionLifecycleMutation({
scope: storePath,
identities: [sessionKey, sessionId],
run: async () => {
markMutationStarted();
await new Promise<void>((release) => {
releaseMutation = release;
});
},
});
await mutationStarted;
const patch = directSessionReq(
"sessions.patch",
{ key: sessionKey, label: "unauthorized raced mutation" },
{ client: pluginClient },
);
await Promise.resolve();
await replaceSessionEntry(
{ sessionKey, storePath },
sessionStoreEntry(sessionId, { pluginOwnerId: "other-plugin" }),
);
releaseMutation();
const [patched] = await Promise.all([patch, mutation]);
expect(patched.ok).toBe(false);
expect(patched.error?.message).toContain("did not create it");
expect(loadSessionEntry({ sessionKey, storePath })).toMatchObject({
pluginOwnerId: "other-plugin",
});
expect(loadSessionEntry({ sessionKey, storePath })?.label).toBeUndefined();
});
test("sessions.delete protects the archived session generation from a replacement", async () => {
const { storePath } = await createSessionStoreDir();
const sessionKey = "agent:main:dreaming-narrative-owned";
const originalSessionId = "original-plugin-session";
await writeSessionStore({
entries: {
[sessionKey]: sessionStoreEntry(originalSessionId, { pluginOwnerId: "memory-core" }),
},
});
const pluginClient = {
connect: { scopes: ["operator.write"] },
internal: { pluginRuntimeOwnerId: "memory-core" },
} as never;
const archived = await directSessionReq<{
entry: { sessionId: string; lifecycleRevision?: string };
}>("sessions.patch", { key: sessionKey, archived: true }, { client: pluginClient });
expect(archived.ok, JSON.stringify(archived.error)).toBe(true);
expect(archived.payload?.entry.sessionId).toBe(originalSessionId);
await replaceSessionEntry(
{ sessionKey, storePath },
sessionStoreEntry("replacement-plugin-session", {
archivedAt: Date.now(),
lifecycleRevision: "replacement-plugin-revision",
pluginOwnerId: "memory-core",
}),
);
const deleted = await directSessionReq(
"sessions.delete",
{
key: sessionKey,
archivedOnly: true,
expectedSessionId: originalSessionId,
...(archived.payload?.entry.lifecycleRevision
? { expectedLifecycleRevision: archived.payload.entry.lifecycleRevision }
: {}),
},
{ client: pluginClient },
);
expect(deleted.ok).toBe(false);
expect(deleted.error?.message).toBe(`Session ${sessionKey} changed before deletion. Retry.`);
expect(loadSessionEntry({ sessionKey, storePath })).toMatchObject({
lifecycleRevision: "replacement-plugin-revision",
pluginOwnerId: "memory-core",
sessionId: "replacement-plugin-session",
});
});

View File

@@ -72,6 +72,7 @@ import { normalizeSessionDeliveryState } from "../utils/delivery-context.shared.
import { ADMIN_SCOPE } from "./operator-scopes.js";
import { buildForkedGatewaySessionEntry } from "./session-create-fork-entry.js";
import { shouldPreserveSessionAuthProfileOverride } from "./session-model-patch-origin.js";
import { resolvePluginSessionOwnershipError } from "./session-plugin-ownership.js";
import { isSessionVisibilityAllowed, resolveSessionVisibility } from "./session-sharing.js";
import { resolveSessionStoreAgentId, resolveSessionStoreKey } from "./session-store-key.js";
import { loadSessionEntryReadOnly, resolveGatewaySessionStoreTarget } from "./session-utils.js";
@@ -524,6 +525,15 @@ export async function createGatewaySession(params: {
),
};
}
const parentOwnershipError = resolvePluginSessionOwnershipError({
action: params.fork === true ? "fork" : "link",
entry: parent.entry,
key: parent.canonicalKey,
pluginOwnerId: params.authorizedPluginId,
});
if (parentOwnershipError) {
return { ok: false, error: parentOwnershipError };
}
if (isModelSelectionLocked(parent.entry)) {
return {
ok: false,
@@ -588,11 +598,17 @@ export async function createGatewaySession(params: {
}
const targetSessionKey = explicitTargetKey ?? buildDashboardSessionKey(agentId, { incognito });
const creationTarget = resolveGatewaySessionStoreTarget({
cfg: params.cfg,
key: targetSessionKey,
agentId,
});
const agentMainSessionKey = resolveAgentMainSessionKey({ cfg: params.cfg, agentId });
// Durable dashboard sessions parent to main for flow-up notices and sidebar threads.
// Incognito roots omit durable lineage so notices cannot cross the storage boundary.
const dashboardParentSessionKey =
!parentSessionKey &&
!params.authorizedPluginId &&
!incognito &&
params.fork !== true &&
(params.cfg.session?.dmScope ?? "main") === "main" &&
@@ -674,7 +690,9 @@ export async function createGatewaySession(params: {
if (
canonicalParentSessionKey &&
parentSessionTarget &&
(params.emitCommandHooks === true || params.fork === true)
(params.emitCommandHooks === true ||
params.fork === true ||
params.authorizedPluginId !== undefined)
) {
const currentParent = loadSessionEntryReadOnly(
canonicalParentSessionKey,
@@ -694,18 +712,31 @@ export async function createGatewaySession(params: {
};
}
currentParentSessionEntry = currentParentEntry;
if (isModelSelectionLocked(currentParentEntry)) {
const parentOwnershipError = resolvePluginSessionOwnershipError({
action: params.fork === true ? "fork" : "link",
entry: currentParentEntry,
key: canonicalParentSessionKey,
pluginOwnerId: params.authorizedPluginId,
});
if (parentOwnershipError) {
return { ok: false, error: parentOwnershipError };
}
if (
(params.emitCommandHooks === true || params.fork === true) &&
isModelSelectionLocked(currentParentEntry)
) {
return {
ok: false,
error: errorShape(ErrorCodes.INVALID_REQUEST, MODEL_SELECTION_LOCKED_PARENT_FORK_MESSAGE),
};
}
const parentHasActiveWork =
isEmbeddedAgentRunActive(currentParentEntry.sessionId) ||
isSessionWorkAdmissionActive(parentSessionTarget.storePath, [
canonicalParentSessionKey,
currentParentEntry.sessionId,
]);
(params.emitCommandHooks === true || params.fork === true) &&
(isEmbeddedAgentRunActive(currentParentEntry.sessionId) ||
isSessionWorkAdmissionActive(parentSessionTarget.storePath, [
canonicalParentSessionKey,
currentParentEntry.sessionId,
]));
if (parentHasActiveWork) {
return {
ok: false,
@@ -747,8 +778,7 @@ export async function createGatewaySession(params: {
});
}
const key = targetSessionKey;
const target = resolveGatewaySessionStoreTarget({ cfg: params.cfg, key, agentId });
const target = creationTarget;
const created = await createSessionEntryWithTranscript<ErrorShape>(
{
agentId: target.agentId,
@@ -758,6 +788,15 @@ export async function createGatewaySession(params: {
async ({ existingEntry, sessionEntries }) => {
// This callback owns generated and explicit keys alike; no existing row
// is the canonical signal that this request will actually create one.
const existingOwnershipError = resolvePluginSessionOwnershipError({
action: "adopt",
entry: existingEntry,
key: target.canonicalKey,
pluginOwnerId: params.authorizedPluginId,
});
if (existingOwnershipError) {
return { ok: false, error: existingOwnershipError };
}
if (
isAgentHarnessSessionKey(target.canonicalKey) &&
!authorizedHarnessCreation &&
@@ -939,8 +978,8 @@ export async function createGatewaySession(params: {
...(params.worktree ? { worktree: params.worktree } : {}),
...(execNode ? { execHost: "node", execNode, ...(execCwd ? { execCwd } : {}) } : {}),
...(initialAgentHarnessId ? { agentHarnessId: initialAgentHarnessId } : {}),
...(authorizedPluginCreation
? { pluginOwnerId: params.initialEntry?.pluginOwnerId }
...(createdNewEntry && params.authorizedPluginId && !params.catalogTarget
? { pluginOwnerId: params.authorizedPluginId }
: {}),
...(authorizedPluginCreation && params.initialEntry?.providerOverride
? { providerOverride: params.initialEntry.providerOverride }
@@ -1115,32 +1154,55 @@ export async function createGatewaySession(params: {
};
};
const runWithCreationTargetLock = async () =>
await runExclusiveSessionLifecycleMutation({
scope: creationTarget.storePath,
identities: [creationTarget.canonicalKey],
run: createChildSession,
});
let result: CreateGatewaySessionResult;
if (
canonicalParentSessionKey &&
parentSessionEntry?.sessionId &&
parentSessionTarget &&
(params.emitCommandHooks === true || params.fork === true)
(params.emitCommandHooks === true ||
params.fork === true ||
params.authorizedPluginId !== undefined)
) {
const result = await runExclusiveSessionLifecycleMutation({
scope: parentSessionTarget.storePath,
identities: [canonicalParentSessionKey, parentSessionEntry.sessionId],
run: createChildSession,
});
if (result.ok && !result.resetExisting && createdContext) {
// Adoption still runs post-create work (initial chat.send, plugin hooks);
// only the created journal event is reserved for genuinely new rows.
if (createdNewEntry) {
recordSessionCreated({
sessionKey: createdContext.key,
agentId: createdContext.agentId,
entry: createdContext.entry,
if (parentSessionTarget.storePath === creationTarget.storePath) {
result = await runExclusiveSessionLifecycleMutation({
scope: creationTarget.storePath,
identities: [
canonicalParentSessionKey,
parentSessionEntry.sessionId,
creationTarget.canonicalKey,
],
run: createChildSession,
});
} else {
const runWithParentLock = async (run: () => Promise<CreateGatewaySessionResult>) =>
await runExclusiveSessionLifecycleMutation({
scope: parentSessionTarget.storePath,
identities: [canonicalParentSessionKey, parentSessionEntry.sessionId],
run,
});
}
await params.afterCreate?.(createdContext);
// Cross-agent forks touch two stores. Acquire their locks in canonical
// store order so simultaneous opposite-direction forks cannot deadlock.
result =
parentSessionTarget.storePath < creationTarget.storePath
? await runWithParentLock(runWithCreationTargetLock)
: await runExclusiveSessionLifecycleMutation({
scope: creationTarget.storePath,
identities: [creationTarget.canonicalKey],
run: async () => await runWithParentLock(createChildSession),
});
}
return result;
} else {
// Keyed creates must observe and adopt the winning row under the same
// lifecycle fence; otherwise concurrent callers mint divergent session IDs.
result = await runWithCreationTargetLock();
}
const result = await createChildSession();
if (result.ok && !result.resetExisting && createdContext) {
if (createdNewEntry) {
recordSessionCreated({

View File

@@ -0,0 +1,30 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import {
ErrorCodes,
errorShape,
type ErrorShape,
} from "../../packages/gateway-protocol/src/index.js";
import type { SessionEntry } from "../config/sessions.js";
export type PluginSessionOwnershipAction = "adopt" | "delete" | "fork" | "link" | "patch" | "reset";
/** Plugin callers may access an existing session only when they own its exact row. */
export function resolvePluginSessionOwnershipError(params: {
action: PluginSessionOwnershipAction;
entry: SessionEntry | undefined;
key: string;
pluginOwnerId: string | null | undefined;
}): ErrorShape | undefined {
const pluginOwnerId = normalizeOptionalString(params.pluginOwnerId);
if (
!pluginOwnerId ||
!params.entry ||
normalizeOptionalString(params.entry.pluginOwnerId) === pluginOwnerId
) {
return undefined;
}
return errorShape(
ErrorCodes.INVALID_REQUEST,
`Plugin "${pluginOwnerId}" cannot ${params.action} session "${params.key}" because it did not create it.`,
);
}

View File

@@ -86,6 +86,7 @@ import {
noteActiveSessionForShutdown,
} from "./active-sessions-shutdown-tracker.js";
import { findDirectChildSessionsForParent } from "./session-child-sessions.js";
import { resolvePluginSessionOwnershipError } from "./session-plugin-ownership.js";
import { notifyGatewaySessionReset } from "./session-reset-notifications.js";
import {
archiveSessionTranscriptsDetailed,
@@ -929,6 +930,8 @@ export async function performGatewaySessionReset(params: {
commandSource: string;
/** Trusted provenance for a reset that materializes a previously missing row. */
creation?: { via: SessionCreatedVia; actor?: SessionCreatedActor };
/** Exact plugin namespace authorized by the scoped plugin runtime. */
authorizedPluginId?: string;
assertCurrent?: () => void;
assertAuthorizedInstance?: () => void;
onCommitted?: (commit: { key: string; sessionId: string }) => void;
@@ -991,6 +994,15 @@ export async function performGatewaySessionReset(params: {
params.key,
resetTarget.requestedAgentId ? { agentId: resetTarget.requestedAgentId } : undefined,
).entry;
const initialOwnershipError = resolvePluginSessionOwnershipError({
action: "reset",
entry: initialResetEntry,
key: resetTarget.target.canonicalKey,
pluginOwnerId: params.authorizedPluginId,
});
if (initialOwnershipError) {
return { ok: false, error: initialOwnershipError };
}
const missingHarnessSessionError = resolveMissingAgentHarnessSessionError(
resetTarget.target.canonicalKey,
initialResetEntry,
@@ -1045,6 +1057,7 @@ export async function performGatewaySessionReset(params: {
};
}
let admittedWorkReleased = true;
let resetOwnershipError: ReturnType<typeof errorShape> | undefined;
return await runExclusiveSessionLifecycleMutation({
scope: resetTarget.storePath,
identities: resetLifecycleIdentities,
@@ -1053,6 +1066,21 @@ export async function performGatewaySessionReset(params: {
prepare: async () => {
params.assertCurrent?.();
params.assertAuthorizedInstance?.();
const currentEntry = loadSessionEntry(
params.key,
resetTarget.requestedAgentId ? { agentId: resetTarget.requestedAgentId } : undefined,
).entry;
// Check the locked generation before interrupting any work; a replaced
// foreign row must not be reset or have its admitted run cancelled.
resetOwnershipError = resolvePluginSessionOwnershipError({
action: "reset",
entry: currentEntry,
key: resetTarget.target.canonicalKey,
pluginOwnerId: params.authorizedPluginId,
});
if (resetOwnershipError) {
return;
}
admittedWorkReleased = await interruptSessionWorkAdmissions({
scope: resetTarget.storePath,
identities: resetLifecycleIdentities,
@@ -1061,6 +1089,9 @@ export async function performGatewaySessionReset(params: {
},
run: async () => {
const { cfg, target, storePath, requestedAgentId } = resetTarget;
if (resetOwnershipError) {
return { ok: false, error: resetOwnershipError };
}
if (!admittedWorkReleased) {
return {
ok: false,
@@ -1074,6 +1105,15 @@ export async function performGatewaySessionReset(params: {
params.key,
requestedAgentId ? { agentId: requestedAgentId } : undefined,
);
const currentOwnershipError = resolvePluginSessionOwnershipError({
action: "reset",
entry,
key: canonicalKey,
pluginOwnerId: params.authorizedPluginId,
});
if (currentOwnershipError) {
return { ok: false, error: currentOwnershipError };
}
const placementBlock = entry?.sessionId
? resolveSessionPlacementResetBlock(entry.sessionId)
: undefined;
@@ -1415,6 +1455,7 @@ export async function performGatewaySessionReset(params: {
subject: currentEntry?.subject,
groupChannel: currentEntry?.groupChannel,
space: currentEntry?.space,
pluginOwnerId: currentEntry?.pluginOwnerId ?? params.authorizedPluginId,
cliSessionBindings: currentEntry?.cliSessionBindings,
cliSessionIds: currentEntry?.cliSessionIds,
claudeCliSessionId: currentEntry?.claudeCliSessionId,

View File

@@ -8,7 +8,10 @@ import {
} from "../config/sessions/session-accessor.js";
import { waitForSessionTranscriptIndexReconcile } from "../config/sessions/session-transcript-reconcile.js";
import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js";
import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js";
import {
closeOpenClawAgentDatabasesForTest,
openOpenClawAgentDatabase,
} from "../state/openclaw-agent-db.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import { captureEnv, setTestEnvValue } from "../test-utils/env.js";
import { readSessionMessagesAroundIdWithStatsAsync } from "./session-transcript-anchor-reader.js";
@@ -362,6 +365,40 @@ describe("session transcript reader facade", () => {
).resolves.toMatchObject({ found: true, seq: 1 });
});
test("waits for an in-flight SQLite projection before counting messages", async () => {
const sessionId = "reader-sqlite-rebuilding-count";
const scope = {
agentId: "main",
sessionId,
sessionKey: `agent:main:${sessionId}`,
storePath,
};
await persistSessionTranscriptTurn(scope, {
messages: [
{
eventId: "root",
parentId: null,
message: { role: "user", content: "cross-client prompt" },
},
{
eventId: "reply",
parentId: "root",
message: { role: "assistant", content: "cross-client reply" },
},
],
touchSessionEntry: false,
});
const database = openOpenClawAgentDatabase({
agentId: "main",
path: path.join(tempDir, "openclaw-agent.sqlite"),
});
database.db
.prepare("UPDATE session_transcript_index_state SET needs_rebuild = 1 WHERE session_id = ?")
.run(sessionId);
await expect(readSessionMessageCountAsync(scope)).resolves.toBe(2);
});
test("projects SQLite transcript reads to the active branch", async () => {
const sessionId = "reader-sqlite-branch";
const scope = {

View File

@@ -1,10 +1,12 @@
import {
isSessionTranscriptProjectionUnavailableError,
readRecentSessionTranscriptMessageEvents,
readSessionTranscriptMessageEventById,
readSessionTranscriptMessageEventCount,
readSessionTranscriptMessageEventPage,
readSessionTranscriptMessageEvents,
resolveSessionTranscriptReadTarget,
waitForSessionTranscriptProjection,
type SessionTranscriptMessageEvent,
type SessionTranscriptReadScope,
type TranscriptEvent,
@@ -459,7 +461,18 @@ export async function readSessionMessageCountAsync(
): Promise<number> {
const target = resolveTranscriptReadTarget(scope);
if (isSqliteReadTarget(target)) {
return readSessionTranscriptMessageEventCount(toTranscriptReadScope(target));
const transcriptScope = toTranscriptReadScope(target);
try {
return readSessionTranscriptMessageEventCount(transcriptScope);
} catch (error) {
if (!isSessionTranscriptProjectionUnavailableError(error)) {
throw error;
}
// The failed read already scheduled the rebuild; wait before assigning
// a sequence so a concurrent send cannot fail or reuse a stale count.
await waitForSessionTranscriptProjection(transcriptScope);
return readSessionTranscriptMessageEventCount(transcriptScope);
}
}
return await readSessionMessageCountAsyncFile(
target.sessionId,

View File

@@ -211,6 +211,11 @@
"enum": ["subagent"],
"type": "string"
},
"runTimeoutSeconds": {
"description": "Per-run timeout in seconds; overrides the configured subagent default. Zero disables the timeout.",
"minimum": 0,
"type": "integer"
},
"sandbox": {
"enum": ["inherit", "require"],
"type": "string"

View File

@@ -211,6 +211,11 @@
"enum": ["subagent"],
"type": "string"
},
"runTimeoutSeconds": {
"description": "Per-run timeout in seconds; overrides the configured subagent default. Zero disables the timeout.",
"minimum": 0,
"type": "integer"
},
"sandbox": {
"enum": ["inherit", "require"],
"type": "string"

View File

@@ -211,6 +211,11 @@
"enum": ["subagent"],
"type": "string"
},
"runTimeoutSeconds": {
"description": "Per-run timeout in seconds; overrides the configured subagent default. Zero disables the timeout.",
"minimum": 0,
"type": "integer"
},
"sandbox": {
"enum": ["inherit", "require"],
"type": "string"

View File

@@ -220,8 +220,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 59624,
"roughTokens": 14906
"chars": 59844,
"roughTokens": 14961
},
"openClawDeveloperInstructions": {
"chars": 3471,
@@ -232,8 +232,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6964
},
"totalWithDynamicToolsJson": {
"chars": 87480,
"roughTokens": 21870
"chars": 87700,
"roughTokens": 21925
},
"userInputText": {
"chars": 1300,

View File

@@ -220,8 +220,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 59316,
"roughTokens": 14829
"chars": 59536,
"roughTokens": 14884
},
"openClawDeveloperInstructions": {
"chars": 2362,
@@ -232,8 +232,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6594
},
"totalWithDynamicToolsJson": {
"chars": 85692,
"roughTokens": 21423
"chars": 85912,
"roughTokens": 21478
},
"userInputText": {
"chars": 929,

View File

@@ -221,8 +221,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 0
},
"dynamicToolsJson": {
"chars": 60878,
"roughTokens": 15220
"chars": 61098,
"roughTokens": 15275
},
"openClawDeveloperInstructions": {
"chars": 2381,
@@ -233,8 +233,8 @@ This is the deterministic model-bound layer stack OpenClaw can snapshot for the
"roughTokens": 6706
},
"totalWithDynamicToolsJson": {
"chars": 87702,
"roughTokens": 21926
"chars": 87922,
"roughTokens": 21981
},
"userInputText": {
"chars": 1284,