refactor(sessions): canonical lineage model — creation provenance, fork ancestry, generation chain, typed row contract (#111861)

* fix(sessions): preserve spawn/fork lineage across implicit daily/idle rollover

* refactor(sessions): canonical creation model with forkSource ancestry and generation chain

* feat(sessions): stamp creation provenance across all creation paths and emit created events

* refactor(gateway): lock lineage patching, split control from navigation, add typed session-row contract

* docs(gateway): document creation provenance stamping and lineage patch tightening

* fix(sessions): keep provenance proof-only on wire fallbacks and strip node-local lineage from cron continuations

* fix(gateway): never journal a created event for session adoption

* fix(gateway): keep post-create work on adoption while gating the created event

* fix(sessions): restore trusted ensure-main provenance and stamp navigation parent at spawn

* fix(sessions): allow parentSessionKey through the direct child spawn patch

* fix(ci): break type cycles, satisfy export scan, regenerate Swift protocol models

* refactor(sessions): replace createdBy with createdActor

* fix(protocol): export session row type

* fix(sessions): preserve proven creation provenance

* fix(sessions): close lineage creation gaps

* test(sessions): align atomic spawn lineage coverage

* test(sessions): widen transcript search reconcile wait

* fix(sessions): stamp reset-created rows

* test(sessions): keep reset provenance coverage focused

* fix(sessions): journal chat-created rows

* test(ci): anchor release skill reads to repo

* test(ci): avoid cached module paths
This commit is contained in:
Peter Steinberger
2026-07-22 22:54:31 -04:00
committed by GitHub
parent 7497147519
commit 099d6351b3
131 changed files with 2272 additions and 1049 deletions
@@ -4120,7 +4120,7 @@ public struct SessionCatalogSession: Codable, Sendable {
public let pullrequest: SessionCatalogPullRequestSummary?
public let archived: Bool
public let sessionkey: String?
public let createdby: [String: AnyCodable]?
public let createdactor: [String: AnyCodable]?
public let cancontinue: Bool
public let canarchive: Bool
public let canopenterminal: Bool?
@@ -4141,7 +4141,7 @@ public struct SessionCatalogSession: Codable, Sendable {
pullrequest: SessionCatalogPullRequestSummary? = nil,
archived: Bool,
sessionkey: String? = nil,
createdby: [String: AnyCodable]? = nil,
createdactor: [String: AnyCodable]? = nil,
cancontinue: Bool,
canarchive: Bool,
canopenterminal: Bool? = nil)
@@ -4161,7 +4161,7 @@ public struct SessionCatalogSession: Codable, Sendable {
self.pullrequest = pullrequest
self.archived = archived
self.sessionkey = sessionkey
self.createdby = createdby
self.createdactor = createdactor
self.cancontinue = cancontinue
self.canarchive = canarchive
self.canopenterminal = canopenterminal
@@ -4183,7 +4183,7 @@ public struct SessionCatalogSession: Codable, Sendable {
case pullrequest = "pullRequest"
case archived
case sessionkey = "sessionKey"
case createdby = "createdBy"
case createdactor = "createdActor"
case cancontinue = "canContinue"
case canarchive = "canArchive"
case canopenterminal = "canOpenTerminal"
@@ -6624,13 +6624,7 @@ public struct SessionsPatchParams: Codable, Sendable {
public let execask: AnyCodable?
public let execnode: AnyCodable?
public let model: AnyCodable?
public let spawnedby: AnyCodable?
public let completionownersessionkey: AnyCodable?
public let spawnedworkspacedir: AnyCodable?
public let spawnedcwd: AnyCodable?
public let spawndepth: AnyCodable?
public let subagentrole: AnyCodable?
public let subagentcontrolscope: AnyCodable?
public let inheritedtoolpolicyversion: AnyCodable?
public let inheritedtoolallow: AnyCodable?
public let inheritedtooldeny: AnyCodable?
@@ -6661,13 +6655,7 @@ public struct SessionsPatchParams: Codable, Sendable {
execask: AnyCodable? = nil,
execnode: AnyCodable? = nil,
model: AnyCodable? = nil,
spawnedby: AnyCodable? = nil,
completionownersessionkey: AnyCodable? = nil,
spawnedworkspacedir: AnyCodable? = nil,
spawnedcwd: AnyCodable? = nil,
spawndepth: AnyCodable? = nil,
subagentrole: AnyCodable? = nil,
subagentcontrolscope: AnyCodable? = nil,
inheritedtoolpolicyversion: AnyCodable? = nil,
inheritedtoolallow: AnyCodable? = nil,
inheritedtooldeny: AnyCodable? = nil,
@@ -6697,13 +6685,7 @@ public struct SessionsPatchParams: Codable, Sendable {
self.execask = execask
self.execnode = execnode
self.model = model
self.spawnedby = spawnedby
self.completionownersessionkey = completionownersessionkey
self.spawnedworkspacedir = spawnedworkspacedir
self.spawnedcwd = spawnedcwd
self.spawndepth = spawndepth
self.subagentrole = subagentrole
self.subagentcontrolscope = subagentcontrolscope
self.inheritedtoolpolicyversion = inheritedtoolpolicyversion
self.inheritedtoolallow = inheritedtoolallow
self.inheritedtooldeny = inheritedtooldeny
@@ -6735,13 +6717,7 @@ public struct SessionsPatchParams: Codable, Sendable {
case execask = "execAsk"
case execnode = "execNode"
case model
case spawnedby = "spawnedBy"
case completionownersessionkey = "completionOwnerSessionKey"
case spawnedworkspacedir = "spawnedWorkspaceDir"
case spawnedcwd = "spawnedCwd"
case spawndepth = "spawnDepth"
case subagentrole = "subagentRole"
case subagentcontrolscope = "subagentControlScope"
case inheritedtoolpolicyversion = "inheritedToolPolicyVersion"
case inheritedtoolallow = "inheritedToolAllow"
case inheritedtooldeny = "inheritedToolDeny"
-1
View File
@@ -867,7 +867,6 @@ src/gateway/session-utils.test.ts
src/gateway/session-utils.ts
src/gateway/sessions-history-http.test.ts
src/gateway/sessions-patch.test.ts
src/gateway/sessions-patch.ts
src/gateway/talk-realtime-relay.test.ts
src/gateway/talk-realtime-relay.ts
src/gateway/test-helpers.server.ts
@@ -1 +1 @@
0852c1b681df33646f60d239afcac6de53fbc498bd5daa58077376331205ccdf sqlite-session-transcript-schema-baseline.sql
011b9ec0e0fa64b5a4036648fcd61a50674b813a92280e8e3fe8746f6acdb62d sqlite-session-transcript-schema-baseline.sql
+3 -1
View File
@@ -17,7 +17,9 @@ If people must not access each other's sessions, tools, credentials, or files, g
## Ownership and presence
New sessions record their creator when the Gateway has a trusted identity available. Trusted-proxy identity takes priority; otherwise OpenClaw uses the paired device's operator label or display name. Older sessions and sessions created without either identity have no owner stamp.
New sessions record a write-once `createdActor` when the creation path can prove who caused it. Authenticated people use their durable Gateway profile id; requesting agents and system paths use the same actor field. Sessions created without a proven actor remain unattributed.
Human display names are resolved from the current Gateway profile when session rows are returned. OpenClaw does not store labels on session entries, so changing a profile name updates the ownership UI without rewriting session history.
The web app keeps ownership and presence visually distinct:
+2 -2
View File
@@ -572,13 +572,13 @@ methods. Treat this as feature discovery, not a full enumeration of
- `sessions.preview` returns bounded transcript previews for specific session keys.
- `sessions.describe` returns one gateway session row for an exact session key.
- `sessions.resolve` resolves or canonicalizes a session target.
- `sessions.create` creates a new session entry. Optional `model` and `thinkingLevel` values persist the initial model and reasoning overrides atomically. `worktree: true` provisions a managed worktree; optional `worktreeBaseRef`/`worktreeName` select the base ref and branch name, and `execNode` (`operator.admin`) binds session exec to a node host. The created worktree is echoed in the result and persisted on the session row (`worktree: { id, branch, repoRoot }`). When the entry is created but its nested initial `chat.send` is rejected, the successful result includes `runStarted: false` and `runError`; clients can preserve the prompt and retry against the returned session key. A caller that passes `parentSessionKey` with `emitCommandHooks: true` should also declare the lifecycle disposition of a distinct child: `succeedsParent: true` ends the parent with `session_end`, while `false` keeps the parent active and emits only the child's `session_start`. Omitting `succeedsParent` preserves the legacy parent-rollover behavior for existing clients. The disposition requires both parent linkage and command hooks; a fork cannot succeed its parent. Main-session reset-in-place behavior is unchanged because no distinct child is created.
- `sessions.create` creates a new session entry. Optional `model` and `thinkingLevel` values persist the initial model and reasoning overrides atomically. `worktree: true` provisions a managed worktree; optional `worktreeBaseRef`/`worktreeName` select the base ref and branch name, and `execNode` (`operator.admin`) binds session exec to a node host. The created worktree is echoed in the result and persisted on the session row (`worktree: { id, branch, repoRoot }`). When the entry is created but its nested initial `chat.send` is rejected, the successful result includes `runStarted: false` and `runError`; clients can preserve the prompt and retry against the returned session key. A caller that passes `parentSessionKey` with `emitCommandHooks: true` should also declare the lifecycle disposition of a distinct child: `succeedsParent: true` ends the parent with `session_end`, while `false` keeps the parent active and emits only the child's `session_start`. Omitting `succeedsParent` preserves the legacy parent-rollover behavior for existing clients. The disposition requires both parent linkage and command hooks; a fork cannot succeed its parent. Main-session reset-in-place behavior is unchanged because no distinct child is created. New rows are stamped with write-once creation provenance (`createdVia`, `createdActor`, `createdAt`) from the trusted creation seam; adopting an existing key never restamps it. For human profile actors, `createdActor.label` is resolved from the current user profile when the row is projected and is never stored on the session entry, so profile renames do not drift. Session rows also carry `parentSessionKey` (navigation parent, persisted), `controlOwnerSessionKey` (runtime controller when live), `forkSource` (exact source key + transcript generation for forks), and `previousSessionId` (prior transcript generation under the same key).
- `sessions.dispatch` (`operator.admin`) moves an existing local OpenClaw session with a session-owned managed worktree to a configured cloud-worker profile. Pass `{ key, profileId, agentId? }`. The method is absent when no worker profile is configured, closes local turn admission before draining active work, and returns only after placement reaches `active` worker ownership. Dispatch is one-way; worker-to-local pull-back is not part of this RPC.
- `sessions.groups.list`, `sessions.groups.put`, `sessions.groups.rename`, and `sessions.groups.delete` manage the gateway-owned custom session group catalog (names + display order). Membership stays on each session's `category` field; rename and delete update member sessions server-side.
- `sessions.send` sends a message into an existing session.
- `sessions.steer` is the interrupt-and-steer variant for an active session.
- `sessions.abort` aborts active work for a session. Pass `key` plus optional `runId`, or `runId` alone for active runs the gateway can resolve to a session.
- `sessions.patch` updates session metadata/overrides and reports the resolved canonical model plus effective `agentRuntime`.
- `sessions.patch` updates session metadata/overrides and reports the resolved canonical model plus effective `agentRuntime`. Spawn lineage (`spawnedBy`, `spawnedWorkspaceDir`, `spawnedCwd`, `spawnDepth`, `subagentRole`, `subagentControlScope`) is no longer publicly patchable; those facts are written once by trusted creation paths, and requests that still send them are rejected.
- `sessions.reset`, `sessions.delete`, and `sessions.compact` perform session maintenance.
- `sessions.get` returns the full stored session row.
- Chat execution still uses `chat.history`, `chat.send`, `chat.abort`, and `chat.inject`. `chat.history` is display-normalized for UI clients: inline directive tags are stripped from visible text, plain-text tool-call XML payloads (`<tool_call>...</tool_call>`, `<function_call>...</function_call>`, `<tool_calls>...</tool_calls>`, `<function_calls>...</function_calls>`, and truncated tool-call blocks) and leaked ASCII/full-width model control tokens are stripped, pure silent-token assistant rows (exact `NO_REPLY` / `no_reply`) are omitted, and oversized rows can be replaced with placeholders.
+7 -1
View File
@@ -26,7 +26,11 @@ export type {
WizardNotFoundErrorDetails,
} from "./schema/error-codes.js";
export * from "./schema/board.js";
export { SessionCreatorIdentitySchema, type SessionCreatorIdentity } from "./schema/sessions.js";
export {
SessionCreatedActorSchema,
type SessionCreatedActor,
type SessionRow,
} from "./schema/sessions-row.js";
export * from "./migration-api.js";
export type * from "./public-session-catalog.js";
import {
@@ -413,6 +417,7 @@ import {
SecretsResolveParamsSchema,
SecretsResolveResultSchema,
SessionBranchSchema,
SessionRowSchema,
SessionsAbortParamsSchema,
SessionsCompactParamsSchema,
SessionsCleanupParamsSchema,
@@ -1156,6 +1161,7 @@ export {
SessionsCompactionBranchParamsSchema,
SessionsCompactionRestoreParamsSchema,
SessionBranchSchema,
SessionRowSchema,
SessionsBranchesListParamsSchema,
SessionsBranchesListResultSchema,
SessionsBranchesSwitchParamsSchema,
@@ -31,7 +31,7 @@ describe("SessionsCatalogListResultSchema", () => {
threadId: "thread-1",
status: "idle",
archived: false,
createdBy: { id: "profile-ada", label: "Ada" },
createdActor: { type: "human", id: "profile-ada", label: "Ada" },
canContinue: true,
canArchive: false,
canOpenTerminal: true,
@@ -3,7 +3,7 @@ import { Type } from "typebox";
import { closedObject } from "./closed-object.js";
import { PluginJsonValueSchema } from "./plugins.js";
import { NonEmptyString } from "./primitives.js";
import { SessionCreatorIdentitySchema } from "./sessions.js";
import { SessionCreatedActorSchema } from "./sessions-row.js";
const SessionCatalogErrorSchema = closedObject({ code: NonEmptyString, message: NonEmptyString });
@@ -56,7 +56,7 @@ export const SessionCatalogSessionSchema = closedObject({
pullRequest: Type.Optional(SessionCatalogPullRequestSummarySchema),
archived: Type.Boolean(),
sessionKey: Type.Optional(NonEmptyString),
createdBy: Type.Optional(SessionCreatorIdentitySchema),
createdActor: Type.Optional(SessionCreatedActorSchema),
canContinue: Type.Boolean(),
canArchive: Type.Boolean(),
canOpenTerminal: Type.Optional(Type.Boolean()),
@@ -0,0 +1,109 @@
import type { Static } from "typebox";
import { Type } from "typebox";
import { closedObject } from "./closed-object.js";
import { NonEmptyString } from "./primitives.js";
/** Projected actor that caused a session node to be created. */
export const SessionCreatedActorSchema = closedObject({
type: Type.Union([Type.Literal("human"), Type.Literal("agent"), Type.Literal("system")]),
id: Type.Optional(NonEmptyString),
label: Type.Optional(NonEmptyString),
});
/** Stable Gateway session row fields; mutation envelopes may add null tombstones. */
export const SessionRowSchema = Type.Object(
{
key: Type.String(),
sessionId: Type.Optional(Type.String()),
kind: Type.Union([
Type.Literal("direct"),
Type.Literal("group"),
Type.Literal("global"),
Type.Literal("unknown"),
]),
label: Type.Optional(Type.String()),
displayName: Type.Optional(Type.String()),
derivedTitle: Type.Optional(Type.String()),
lastMessagePreview: Type.Optional(Type.String()),
channel: Type.Optional(Type.String()),
chatType: Type.Optional(
Type.Union([Type.Literal("direct"), Type.Literal("group"), Type.Literal("channel")]),
),
updatedAt: Type.Optional(Type.Union([Type.Number(), Type.Null()])),
archived: Type.Optional(Type.Boolean()),
archivedAt: Type.Optional(Type.Number()),
pinned: Type.Optional(Type.Boolean()),
pinnedAt: Type.Optional(Type.Number()),
icon: Type.Optional(Type.String()),
unread: Type.Optional(Type.Boolean()),
lastReadAt: Type.Optional(Type.Number()),
lastActivityAt: Type.Optional(Type.Number()),
lastInteractionAt: Type.Optional(Type.Number()),
status: Type.Optional(
Type.Union([
Type.Literal("running"),
Type.Literal("done"),
Type.Literal("failed"),
Type.Literal("killed"),
Type.Literal("timeout"),
]),
),
lastRunError: Type.Optional(Type.String()),
spawnedBy: Type.Optional(Type.String()),
parentSessionKey: Type.Optional(Type.String()),
controlOwnerSessionKey: Type.Optional(Type.String()),
childSessions: Type.Optional(Type.Array(Type.String())),
forkedFromParent: Type.Optional(Type.Boolean()),
spawnDepth: Type.Optional(Type.Number()),
subagentRole: Type.Optional(Type.Union([Type.Literal("orchestrator"), Type.Literal("leaf")])),
subagentControlScope: Type.Optional(
Type.Union([Type.Literal("children"), Type.Literal("none")]),
),
swarmGroupId: Type.Optional(Type.String()),
worktree: Type.Optional(
Type.Object({
id: Type.String(),
branch: Type.String(),
repoRoot: Type.String(),
}),
),
execNode: Type.Optional(Type.String()),
execCwd: Type.Optional(Type.String()),
spawnedWorkspaceDir: Type.Optional(Type.String()),
spawnedCwd: Type.Optional(Type.String()),
createdVia: Type.Optional(
Type.Union([
Type.Literal("operator"),
Type.Literal("spawn"),
Type.Literal("channel"),
Type.Literal("cron"),
Type.Literal("talk"),
Type.Literal("run"),
Type.Literal("plugin"),
Type.Literal("internal"),
]),
),
createdActor: Type.Optional(SessionCreatedActorSchema),
createdAt: Type.Optional(Type.Number()),
forkSource: Type.Optional(
Type.Object({
sessionKey: Type.String(),
sessionId: Type.String(),
entryId: Type.Optional(Type.String()),
}),
),
previousSessionId: Type.Optional(Type.String()),
inputTokens: Type.Optional(Type.Number()),
outputTokens: Type.Optional(Type.Number()),
totalTokens: Type.Optional(Type.Number()),
totalTokensFresh: Type.Optional(Type.Boolean()),
contextTokens: Type.Optional(Type.Number()),
estimatedCostUsd: Type.Optional(Type.Number()),
model: Type.Optional(Type.String()),
modelProvider: Type.Optional(Type.String()),
},
{ additionalProperties: true },
);
export type SessionCreatedActor = Static<typeof SessionCreatedActorSchema>;
export type SessionRow = Static<typeof SessionRowSchema>;
@@ -9,6 +9,12 @@ import { NonEmptyString, SessionLabelString } from "./primitives.js";
import { SessionsCreateParamsSchema } from "./sessions-create.js";
export { SessionsCreateParamsSchema };
export {
SessionCreatedActorSchema,
SessionRowSchema,
type SessionCreatedActor,
type SessionRow,
} from "./sessions-row.js";
export const SESSION_OBSERVER_HEALTH_VALUES = [
"on-track",
@@ -20,13 +26,6 @@ export const SESSION_OBSERVER_HEALTH_VALUES = [
"failed",
] as const;
/** Stable identity stamped on a session when an operator creates it. */
export const SessionCreatorIdentitySchema = closedObject({
id: NonEmptyString,
label: Type.Optional(NonEmptyString),
});
export type SessionCreatorIdentity = Static<typeof SessionCreatorIdentitySchema>;
/** Trajectory judgment produced for one observed agent session. */
export const SessionObserverHealthSchema = Type.Union([
Type.Literal("on-track"),
@@ -478,17 +477,7 @@ export const SessionsPatchParamsSchema = closedObject({
execAsk: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
execNode: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
model: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
spawnedBy: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
completionOwnerSessionKey: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
spawnedWorkspaceDir: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
spawnedCwd: Type.Optional(Type.Union([NonEmptyString, Type.Null()])),
spawnDepth: Type.Optional(Type.Union([Type.Integer({ minimum: 0 }), Type.Null()])),
subagentRole: Type.Optional(
Type.Union([Type.Literal("orchestrator"), Type.Literal("leaf"), Type.Null()]),
),
subagentControlScope: Type.Optional(
Type.Union([Type.Literal("children"), Type.Literal("none"), Type.Null()]),
),
inheritedToolPolicyVersion: Type.Optional(Type.Union([Type.Literal(1), Type.Null()])),
inheritedToolAllow: Type.Optional(Type.Union([Type.Array(NonEmptyString), Type.Null()])),
inheritedToolDeny: Type.Optional(Type.Union([Type.Array(NonEmptyString), Type.Null()])),
+25 -15
View File
@@ -70,6 +70,7 @@ const hoisted = vi.hoisted(() => {
const countActiveRunsForSessionMock = vi.fn();
const getSubagentRunByChildSessionKeyMock = vi.fn();
const listTasksForOwnerKeyMock = vi.fn();
const upsertSessionEntryMock = vi.fn();
const createSessionAccessorMock = () => {
const resolveMockStorePath = (scope: {
agentId?: string;
@@ -111,6 +112,8 @@ const hoisted = vi.hoisted(() => {
listSessionEntriesReadOnly: listMockEntries,
loadSessionEntry: loadMockEntry,
loadSessionEntryReadOnly: loadMockEntry,
upsertSessionEntry: async (scope: unknown, patch: SessionEntry) =>
await upsertSessionEntryMock(scope, patch),
resolveSessionTranscriptRuntimeTarget: async (scope: {
agentId: string;
sessionId: string;
@@ -161,6 +164,7 @@ const hoisted = vi.hoisted(() => {
countActiveRunsForSessionMock,
getSubagentRunByChildSessionKeyMock,
listTasksForOwnerKeyMock,
upsertSessionEntryMock,
createSessionAccessorMock,
state,
};
@@ -455,8 +459,8 @@ function expectGatewayMethodNotCalled(method: string): void {
expect(gatewayRequests().some((request) => request.method === method)).toBe(false);
}
function expectSessionPatchFields(expected: Record<string, unknown>): void {
expectRecordFields(gatewayRequest("sessions.patch").params, expected);
function expectCreatedSessionFields(expected: Record<string, unknown>): void {
expectRecordFields(firstMockCall(hoisted.upsertSessionEntryMock, "session create")[1], expected);
}
function expectInitializeSessionFields(expected: Record<string, unknown>): Record<string, unknown> {
@@ -703,6 +707,13 @@ describe("spawnAcpDirect", () => {
hoisted.countActiveRunsForSessionMock.mockReset().mockReturnValue(0);
hoisted.getSubagentRunByChildSessionKeyMock.mockReset().mockReturnValue(null);
hoisted.listTasksForOwnerKeyMock.mockReset().mockReturnValue([]);
hoisted.upsertSessionEntryMock
.mockReset()
.mockImplementation(async (_scope: unknown, patch: Partial<SessionEntry>) => ({
...patch,
sessionId: patch.sessionId ?? "sess-123",
updatedAt: patch.updatedAt ?? Date.now(),
}));
hoisted.callGatewayMock.mockReset();
hoisted.callGatewayMock.mockImplementation(async (argsUnknown: unknown) => {
@@ -861,25 +872,25 @@ describe("spawnAcpDirect", () => {
expect(accepted.runId).toBe("run-1");
expect(accepted.mode).toBe("session");
expect(accepted.inlineDelivery).toBe(true);
expectSessionPatchFields({
key: accepted.childSessionKey,
expectCreatedSessionFields({
spawnedBy: "agent:main:main",
completionOwnerSessionKey: "agent:main:main",
inheritedToolPolicyVersion: 1,
parentSessionKey: "agent:main:main",
createdVia: "spawn",
createdActor: { type: "agent", id: "agent:main:main" },
createdAt: expect.any(Number),
});
expectBindingCallFields({
targetKind: "session",
placement: "child",
});
const patchCallIndex = hoisted.callGatewayMock.mock.calls.findIndex(
(call: unknown[]) => (call[0] as { method?: string }).method === "sessions.patch",
);
const agentCallIndex = hoisted.callGatewayMock.mock.calls.findIndex(
(call: unknown[]) => (call[0] as { method?: string }).method === "agent",
);
const patchCallOrder = expectDefined(
hoisted.callGatewayMock.mock.invocationCallOrder[patchCallIndex],
"hoisted.callGatewayMock.mock.invocationCallOrder[patchCallIndex] test invariant",
const createCallOrder = expectDefined(
hoisted.upsertSessionEntryMock.mock.invocationCallOrder[0],
"hoisted.upsertSessionEntryMock.mock.invocationCallOrder[0] test invariant",
);
const initializeCallOrder = expectDefined(
hoisted.initializeSessionMock.mock.invocationCallOrder[0],
@@ -889,10 +900,10 @@ describe("spawnAcpDirect", () => {
hoisted.callGatewayMock.mock.invocationCallOrder[agentCallIndex],
"hoisted.callGatewayMock.mock.invocationCallOrder[agentCallIndex] test invariant",
);
expect(typeof patchCallOrder).toBe("number");
expect(typeof createCallOrder).toBe("number");
expect(typeof initializeCallOrder).toBe("number");
expect(typeof agentCallOrder).toBe("number");
expect(patchCallOrder < initializeCallOrder).toBe(true);
expect(createCallOrder < initializeCallOrder).toBe(true);
expect(initializeCallOrder < agentCallOrder).toBe(true);
expectResolvedIntroTextInBindMetadata();
@@ -1419,9 +1430,8 @@ describe("spawnAcpDirect", () => {
agentSessionKey: "agent:main:subagent:parent",
});
const accepted = expectAcceptedSpawn(result);
expectSessionPatchFields({
key: accepted.childSessionKey,
expectAcceptedSpawn(result);
expectCreatedSessionFields({
spawnedBy: "agent:main:subagent:parent",
spawnDepth: 2,
subagentRole: "leaf",
+41 -14
View File
@@ -38,7 +38,9 @@ import {
loadSessionEntry,
loadSessionEntryReadOnly,
resolveSessionTranscriptRuntimeTarget,
upsertSessionEntry,
} from "../config/sessions/session-accessor.js";
import { buildSessionCreationStamp } from "../config/sessions/session-entry-provenance.js";
import type { SessionAcpMeta, SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { callGateway } from "../gateway/call.js";
@@ -59,7 +61,7 @@ import {
parseAgentSessionKey,
resolveAgentIdFromSessionKey,
} from "../routing/session-key.js";
import { recordSubagentSpawned } from "../sessions/session-state-events.js";
import { recordSessionCreated, recordSubagentSpawned } from "../sessions/session-state-events.js";
import { listTasksForOwnerKey } from "../tasks/runtime-internal.js";
import { deliveryContextFromSession, normalizeDeliveryContext } from "../utils/delivery-context.js";
import {
@@ -1240,6 +1242,7 @@ export async function spawnAcpDirect(
}
let sessionCreated = false;
let childCreationEntry: SessionEntry | undefined;
let initializedRuntime: AcpSpawnRuntimeCloseHandle | undefined;
const childIdem = crypto.randomUUID();
const parentAgentId = parentSessionKey
@@ -1285,20 +1288,37 @@ export async function spawnAcpDirect(
};
const adapter: SpawnBackendAdapter<AcpBackendState> = {
async initialize() {
await callGateway({
method: "sessions.patch",
params: {
key: sessionKey,
spawnedBy: requesterInternalKey,
completionOwnerSessionKey: ownership.completionRequesterSessionKey,
...admission.childSessionPatch,
inheritedToolPolicyVersion: 1,
...inheritedToolAllowPatch(ctx.inheritedToolAllowlist),
...inheritedToolDenyPatch(ctx.inheritedToolDenylist),
...(params.label ? { label: params.label } : {}),
},
timeoutMs: 10_000,
const creationStamp = buildSessionCreationStamp({
via: "spawn",
actor: { type: "agent", id: requesterInternalKey },
});
const storePath = resolveStorePath(cfg.session?.store, { agentId: targetAgentId });
const childSessionPatch = admission.childSessionPatch
? {
spawnDepth: admission.childSessionPatch.spawnDepth,
...(admission.childSessionPatch.subagentRole
? { subagentRole: admission.childSessionPatch.subagentRole }
: {}),
subagentControlScope: admission.childSessionPatch.subagentControlScope,
}
: {};
childCreationEntry =
(await upsertSessionEntry(
{ storePath, sessionKey },
{
...creationStamp,
spawnedBy: requesterInternalKey,
completionOwnerSessionKey: ownership.completionRequesterSessionKey,
// Navigation parent is stamped at creation so the durable tree edge
// does not depend on the control-lineage field.
parentSessionKey: requesterInternalKey,
...childSessionPatch,
inheritedToolPolicyVersion: 1,
...inheritedToolAllowPatch(ctx.inheritedToolAllowlist),
...inheritedToolDenyPatch(ctx.inheritedToolDenylist),
...(params.label ? { label: params.label } : {}),
},
)) ?? undefined;
sessionCreated = true;
const initializedSession = await initializeAcpSpawnRuntime({
cfg,
@@ -1335,6 +1355,13 @@ export async function spawnAcpDirect(
binding: state.binding,
});
// ACP bypasses the native adapter, so seed the same child lineage before dispatch.
if (childCreationEntry) {
recordSessionCreated({
sessionKey,
agentId: targetAgentId,
entry: childCreationEntry,
});
}
recordSubagentSpawned({
childSessionKey: sessionKey,
childRunId: childIdem,
+6 -1
View File
@@ -26,7 +26,12 @@ describe("internal session effects", () => {
expect(target.sessionKey).toMatch(/^agent:main:internal-session-effects:run_with_space-/);
expect(target.sessionId).toMatch(/^internal-session-effects-run_with_space-/);
expect(loadExactSessionEntry(target)?.entry.sessionId).toBe(target.sessionId);
expect(loadExactSessionEntry(target)?.entry).toMatchObject({
sessionId: target.sessionId,
createdVia: "internal",
createdActor: { type: "system" },
createdAt: expect.any(Number),
});
expect(listSessionEntries({ storePath })).toEqual([]);
await expect(loadTranscriptEvents(target)).resolves.toEqual([
expect.objectContaining({ id: target.sessionId, type: "session" }),
+2
View File
@@ -7,6 +7,7 @@ import {
replaceTranscriptEvents,
upsertSessionEntry,
} from "../config/sessions/session-accessor.js";
import { buildSessionCreationStamp } from "../config/sessions/session-entry-provenance.js";
import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js";
import { createSessionTranscriptHeader } from "../config/sessions/transcript-header.js";
import type { SessionEntry } from "../config/sessions/types.js";
@@ -93,6 +94,7 @@ export async function prepareInternalSessionEffectsSession(params: {
}
const now = Date.now();
const entry = await upsertSessionEntry(scope, {
...buildSessionCreationStamp({ via: "internal", actor: { type: "system" } }),
sessionId: scope.sessionId,
sessionStartedAt: now,
updatedAt: now,
+4 -1
View File
@@ -645,12 +645,15 @@ export function createOpenClawTools(
config: resolvedConfig,
senderIsOwner: options?.senderIsOwner,
}),
// No explicit callGateway: the tool defaults to the same in-process
// caller, and an injected override would disable the trusted creation
// stamp for materialized agent roots (opts.callGateway === undefined
// is the gate in ensureConfiguredAgentMainSession).
createSessionsSendTool({
agentSessionKey: options?.agentSessionKey,
agentChannel: options?.agentChannel,
sandboxed: options?.sandboxed,
config: resolvedConfig,
callGateway,
}),
]),
...(includeSubagentSpawnTool
-53
View File
@@ -591,57 +591,4 @@ describe("sessions_spawn subagent lifecycle hooks", () => {
"delete params",
);
});
it("cleans up the provisional session when lineage patching fails after thread binding", async () => {
const store: Record<string, Record<string, unknown>> = {};
hoisted.updateSessionStoreMock.mockImplementation(
async (_storePath: unknown, mutator: unknown) => {
if (typeof mutator !== "function") {
throw new Error("missing session store mutator");
}
await mutator(store);
if (Object.values(store).some((entry) => typeof entry.spawnedBy === "string")) {
throw new Error("lineage patch failed");
}
return store;
},
);
hoisted.callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: Record<string, unknown> };
if (request.method === "sessions.delete") {
return { ok: true };
}
if (request.method === "agent") {
return { runId: "run-1", status: "accepted", acceptedAt: 1_001 };
}
return {};
});
const result = await spawn({
thread: true,
mode: "session",
agentAccountId: "work",
agentTo: "channel:123",
agentThreadId: "456",
context: "isolated",
});
expect(result.status).toBe("error");
expect(result.error).toContain("lineage patch failed");
expect(hookRunnerMocks.runSubagentSpawned).not.toHaveBeenCalled();
expect(hookRunnerMocks.runSubagentEnded).not.toHaveBeenCalled();
const methods = getGatewayMethods();
expect(methods).toContain("sessions.delete");
expect(methods).not.toContain("agent");
const deleteCall = findGatewayRequest("sessions.delete");
expectFields(
deleteCall?.params,
{
key: result.childSessionKey,
deleteTranscript: true,
emitLifecycleHooks: true,
},
"delete params",
);
});
});
@@ -218,57 +218,4 @@ describe("spawnSubagentDirect filename validation", () => {
fs.rmSync(homeDir, { recursive: true, force: true });
}
});
it("removes materialized attachments when lineage patching fails", async () => {
// Attachments are created before the child session lineage patch; failures
// must delete both the child session and materialized files.
const calls: Array<{ method?: string; params?: Record<string, unknown> }> = [];
const store: Record<string, Record<string, unknown>> = {};
updateSessionStoreMock.mockImplementation(async (_storePath: unknown, mutator: unknown) => {
if (typeof mutator !== "function") {
throw new Error("missing session store mutator");
}
await mutator(store);
if (Object.values(store).some((entry) => typeof entry.spawnedBy === "string")) {
throw new Error("lineage patch failed");
}
return store;
});
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: Record<string, unknown> };
calls.push(request);
if (request.method === "sessions.delete") {
return { ok: true };
}
return {};
});
const { spawnSubagentDirect } = subagentSpawnModule;
const result = await spawnSubagentDirect(
{
task: "test",
attachments: [{ name: "file.txt", content: validContent, encoding: "base64" }],
},
ctx,
);
expect(result.status).toBe("error");
expect(result.error).toContain("lineage patch failed");
const attachmentsRoot = path.join(workspaceDirOverride, ".openclaw", "attachments");
const retainedDirs = fs.existsSync(attachmentsRoot)
? fs.readdirSync(attachmentsRoot).filter((entry) => !entry.startsWith("."))
: [];
expect(retainedDirs).toHaveLength(0);
const deleteCall = calls.find((entry) => entry.method === "sessions.delete");
const deleteParams = deleteCall?.params as
| {
key?: string;
deleteTranscript?: boolean;
emitLifecycleHooks?: boolean;
}
| undefined;
expect(deleteParams?.key).toMatch(/^agent:main:subagent:/);
expect(deleteParams?.deleteTranscript).toBe(true);
expect(deleteParams?.emitLifecycleHooks).toBe(false);
});
});
@@ -84,7 +84,7 @@ describe("spawnSubagentDirect runtime model persistence", () => {
expect(result.modelApplied).toBe(true);
expect(result.resolvedModel).toBe("openai/gpt-5.4");
expect(result.resolvedProvider).toBe("openai");
expect(updateSessionStoreMock).toHaveBeenCalledTimes(3);
expect(updateSessionStoreMock).toHaveBeenCalledTimes(2);
expectPersistedRuntimeModel({
persistedStore,
sessionKey: /^agent:main:subagent:/,
+9 -1
View File
@@ -884,7 +884,15 @@ describe("spawnSubagentDirect seam flow", () => {
expect(result.childSessionKey).toMatch(/^agent:main:subagent:/);
const childSessionKey = result.childSessionKey as string;
expect(hoisted.updateSessionStoreMock).toHaveBeenCalledTimes(3);
expect(hoisted.updateSessionStoreMock).toHaveBeenCalledTimes(2);
expect(persistedStore?.[childSessionKey]).toMatchObject({
spawnedBy: "agent:main:main",
completionOwnerSessionKey: "agent:main:main",
parentSessionKey: "agent:main:main",
createdVia: "spawn",
createdActor: { type: "agent", id: "agent:main:main" },
createdAt: expect.any(Number),
});
const registerInput = firstRegisteredSubagentRun();
const requesterOrigin = requireRecord(registerInput.requesterOrigin);
expect(registerInput.runId).toBe("run-1");
+33 -27
View File
@@ -18,6 +18,7 @@ import {
resolveThreadBindingMaxAgeMsForChannel,
resolveThreadBindingSpawnPolicy,
} from "../channels/thread-bindings-policy.js";
import { buildSessionCreationStamp } from "../config/sessions/session-entry-provenance.js";
import type { SessionEntry } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { SubagentSpawnPreparation } from "../context-engine/types.js";
@@ -26,7 +27,7 @@ import { stringifyRouteThreadId } from "../plugin-sdk/channel-route.js";
import { listRegisteredPluginAgentPromptGuidance } from "../plugins/command-registry-state.js";
import type { SubagentLifecycleHookRunner } from "../plugins/hooks.js";
import { isValidAgentId, normalizeAgentId, parseAgentSessionKey } from "../routing/session-key.js";
import { recordSubagentSpawned } from "../sessions/session-state-events.js";
import { recordSessionCreated, recordSubagentSpawned } from "../sessions/session-state-events.js";
import type { FastMode } from "../shared/fast-mode.js";
import { resolveUserPath } from "../utils.js";
import type { DeliveryContext } from "../utils/delivery-context.types.js";
@@ -243,15 +244,15 @@ async function callSubagentGateway(
authorization?: SubagentLaunchAuthorization,
): Promise<Awaited<ReturnType<typeof callGateway>>> {
// Subagent lifecycle requires methods spanning multiple scope tiers
// (sessions.patch / sessions.delete → admin, agent → write). When each call
// (sessions.delete → admin, agent → write). When each call
// independently negotiates least-privilege scopes the first connection pairs
// at a lower tier and every subsequent higher-tier call triggers a
// scope-upgrade handshake that headless gateway-client connections cannot
// complete interactively, causing close(1008) "pairing required" (#59428).
//
// Only admin-requiring calls are pinned to ADMIN_SCOPE; other methods (e.g.
// "agent" -> write) keep their least-privilege scope. The params-aware
// resolver keeps spawn-metadata sessions.patch calls on the admin tier.
// "agent" -> write) keep their least-privilege scope. Apply the trusted
// launch authorization before resolving the request's required scope.
const authorizedParams =
params.params != null && typeof params.params === "object" && !Array.isArray(params.params)
? applySubagentLaunchAuthorization(params.params as Record<string, unknown>, authorization)
@@ -390,6 +391,9 @@ function buildDirectChildSessionPatch(patch: Record<string, unknown>): Partial<S
) {
entry.completionOwnerSessionKey = patch.completionOwnerSessionKey.trim();
}
if (typeof patch.parentSessionKey === "string" && patch.parentSessionKey.trim()) {
entry.parentSessionKey = patch.parentSessionKey.trim();
}
if (typeof patch.spawnedWorkspaceDir === "string" && patch.spawnedWorkspaceDir.trim()) {
entry.spawnedWorkspaceDir = patch.spawnedWorkspaceDir.trim();
}
@@ -1340,21 +1344,24 @@ export async function spawnSubagentDirect(
}
}
const resolvedModelMetadata = buildResolvedSubagentModelMetadata(resolvedModel);
let childCreationEntry: SessionEntry | undefined;
const patchChildSession = async (
patch: Record<string, unknown>,
creationStamp?: ReturnType<typeof buildSessionCreationStamp>,
): Promise<string | undefined> => {
try {
const target = resolveGatewaySessionStoreTarget({
cfg,
key: childSessionKey,
});
await upsertSessionEntry(
const updatedEntry = await upsertSessionEntry(
{
storePath: target.storePath,
sessionKey: target.canonicalKey,
},
buildDirectChildSessionPatch(patch),
{ ...buildDirectChildSessionPatch(patch), ...creationStamp },
);
childCreationEntry ??= updatedEntry ?? undefined;
return undefined;
} catch (err) {
const message =
@@ -1364,6 +1371,13 @@ export async function spawnSubagentDirect(
};
const initialChildSessionPatch: Record<string, unknown> = {
spawnedBy: spawnedByKey,
completionOwnerSessionKey: ownership.completionRequesterSessionKey,
// Navigation and control lineage commit with the creation stamp so a
// launch failure cannot leave a durable but parentless child row.
parentSessionKey: spawnedByKey,
...(spawnedWorkspaceDir ? { spawnedWorkspaceDir } : {}),
...(spawnedCwd ? { spawnedCwd } : {}),
...admission.childSessionPatch,
inheritedToolPolicyVersion: 1,
...inheritedToolAllowPatch(ctx.inheritedToolAllowlist),
@@ -1374,7 +1388,13 @@ export async function spawnSubagentDirect(
...(params.outputSchema ? { swarmOutputSchema: params.outputSchema } : {}),
};
const initialPatchError = await patchChildSession(initialChildSessionPatch);
const initialPatchError = await patchChildSession(
initialChildSessionPatch,
buildSessionCreationStamp({
via: "spawn",
actor: { type: "agent", id: requesterInternalKey },
}),
);
if (initialPatchError) {
return {
status: "error",
@@ -1530,32 +1550,18 @@ export async function spawnSubagentDirect(
persistentSession: spawnMode === "session",
task,
});
const spawnedMetadata = normalizeSpawnedRunMetadata({
spawnedBy: spawnedByKey,
...toolSpawnMetadata,
workspaceDir: spawnedWorkspaceDir,
});
const spawnLineagePatchError = await patchChildSession({
spawnedBy: spawnedByKey,
completionOwnerSessionKey: ownership.completionRequesterSessionKey,
...(spawnedMetadata.workspaceDir
? { spawnedWorkspaceDir: spawnedMetadata.workspaceDir }
: {}),
...(spawnedCwd ? { spawnedCwd } : {}),
});
if (spawnLineagePatchError) {
await cleanupFailedSpawnBeforeAgentStart({
childSessionKey,
attachmentAbsDir,
emitLifecycleHooks: threadBindingReady,
deleteTranscript: true,
if (childCreationEntry) {
recordSessionCreated({
sessionKey: childSessionKey,
agentId: targetAgentId,
entry: childCreationEntry,
});
return {
status: "error",
error: spawnLineagePatchError,
childSessionKey,
};
}
recordSubagentSpawned({
childSessionKey,
@@ -0,0 +1,58 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
hasContext: true,
dispatch: vi.fn(),
callGatewayTool: vi.fn(),
}));
vi.mock("../../gateway/method-scopes.js", () => ({
resolveLeastPrivilegeOperatorScopesForMethod: () => ["operator.write"],
}));
vi.mock("../../gateway/server-plugins.js", () => ({
dispatchGatewayMethodInProcess: mocks.dispatch,
getInProcessGatewayRequestContext: vi.fn(),
hasInProcessGatewayContext: () => mocks.hasContext,
}));
vi.mock("./gateway.js", () => ({ callGatewayTool: mocks.callGatewayTool }));
import { callInProcessGatewayToolWithCreation } from "./in-process-gateway.js";
describe("trusted in-process Gateway session creation", () => {
beforeEach(() => {
mocks.hasContext = true;
mocks.dispatch.mockReset().mockResolvedValue({ key: "agent:main:dashboard:child" });
mocks.callGatewayTool.mockReset().mockResolvedValue({ key: "agent:main:dashboard:child" });
});
it("surfaces creation provenance only on in-process dispatch", async () => {
const creation = {
via: "spawn" as const,
actor: { type: "agent" as const, id: "agent:main:main" },
};
await callInProcessGatewayToolWithCreation("sessions.create", { agentId: "main" }, creation);
expect(mocks.dispatch).toHaveBeenCalledWith(
"sessions.create",
{ agentId: "main" },
{
forceSyntheticClient: true,
sessionCreation: creation,
syntheticScopes: ["operator.write"],
},
);
expect(mocks.callGatewayTool).not.toHaveBeenCalled();
mocks.hasContext = false;
await callInProcessGatewayToolWithCreation("sessions.create", { agentId: "main" }, creation);
expect(mocks.callGatewayTool).toHaveBeenCalledWith(
"sessions.create",
{},
{ agentId: "main" },
{ scopes: ["operator.write"] },
);
});
});
+18
View File
@@ -1,5 +1,6 @@
/** In-process Gateway calls for built-in agent tools. */
import { resolveLeastPrivilegeOperatorScopesForMethod } from "../../gateway/method-scopes.js";
import type { TrustedSessionCreation } from "../../gateway/server-methods/session-creation-provenance.js";
import type { GatewayRequestContext } from "../../gateway/server-methods/types.js";
import {
dispatchGatewayMethodInProcess,
@@ -34,3 +35,20 @@ export const callInProcessGatewayTool: InProcessGatewayCaller = async <T>(
}
return await callGatewayTool<T>(method, {}, params, { scopes });
};
export async function callInProcessGatewayToolWithCreation<T = Record<string, unknown>>(
method: string,
params: Record<string, unknown>,
creation: TrustedSessionCreation,
): Promise<T> {
const scopes = resolveLeastPrivilegeOperatorScopesForMethod(method, params);
if (hasInProcessGatewayContext()) {
return await dispatchGatewayMethodInProcess<T>(method, params, {
forceSyntheticClient: true,
sessionCreation: creation,
syntheticScopes: scopes,
});
}
// The fallback is a real Gateway request; trusted creation metadata never crosses the wire.
return await callGatewayTool<T>(method, {}, params, { scopes });
}
+28 -8
View File
@@ -50,6 +50,10 @@ import {
} from "../tool-description-presets.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readNonNegativeIntegerParam, readStringParam } from "./common.js";
import {
callInProcessGatewayToolWithCreation,
hasInProcessGatewayToolContext,
} from "./in-process-gateway.js";
import { runWithScopedSessionAccess } from "./scoped-session-access.js";
import {
createSessionVisibilityGuard,
@@ -189,6 +193,8 @@ async function ensureConfiguredAgentMainSession(params: {
callGateway: GatewayCaller;
sessionKey: string;
mainKey: string;
requesterSessionKey?: string;
useTrustedInProcessCreation: boolean;
}): Promise<{ ok: true } | { ok: false; error: string }> {
if (
!isConfiguredAgentMainSessionKey({
@@ -209,14 +215,26 @@ async function ensureConfiguredAgentMainSession(params: {
return { ok: true };
} catch {
try {
await params.callGateway({
method: "sessions.create",
params: {
key: params.sessionKey,
agentId: resolveAgentIdFromSessionKey(params.sessionKey),
},
timeoutMs: 10_000,
});
const createParams = {
key: params.sessionKey,
agentId: resolveAgentIdFromSessionKey(params.sessionKey),
};
if (
params.useTrustedInProcessCreation &&
params.requesterSessionKey &&
hasInProcessGatewayToolContext()
) {
await callInProcessGatewayToolWithCreation("sessions.create", createParams, {
via: "internal",
actor: { type: "agent", id: params.requesterSessionKey },
});
} else {
await params.callGateway({
method: "sessions.create",
params: createParams,
timeoutMs: 10_000,
});
}
return { ok: true };
} catch (err) {
return { ok: false, error: formatErrorMessage(err) };
@@ -621,6 +639,8 @@ export function createSessionsSendTool(opts?: {
callGateway: gatewayCall,
sessionKey: resolvedKey,
mainKey,
requesterSessionKey,
useTrustedInProcessCreation: opts?.callGateway === undefined,
});
if (!ensuredSession.ok) {
return jsonResult({
+13 -2
View File
@@ -24,7 +24,11 @@ import { resolveSubagentSpawnOwnership } from "../subagent-spawn-ownership.js";
import { resolveConfiguredSubagentRunTimeoutSeconds } from "../subagent-spawn-plan.js";
import { resolveSubagentTargetPolicy } from "../subagent-target-policy.js";
import { normalizeToolModelOverride, readStringParam, ToolInputError } from "./common.js";
import { callInProcessGatewayTool, type InProcessGatewayCaller } from "./in-process-gateway.js";
import {
callInProcessGatewayTool,
callInProcessGatewayToolWithCreation,
type InProcessGatewayCaller,
} from "./in-process-gateway.js";
import { reserveVisibleChildSlot } from "./sessions-spawn-visible-admission.js";
export const VISIBLE_SESSIONS_SPAWN_SCHEMA = {
@@ -274,7 +278,14 @@ export async function maybeSpawnVisibleSession(params: {
}
try {
const gatewayCall = params.options?.callGateway ?? callInProcessGatewayTool;
const response = await gatewayCall<{
const createGatewayCall: InProcessGatewayCaller =
params.options?.callGateway ??
((method, requestParams) =>
callInProcessGatewayToolWithCreation(method, requestParams, {
via: "spawn",
actor: { type: "agent", id: requesterKey },
}));
const response = await createGatewayCall<{
key?: string;
runStarted?: boolean;
runId?: string;
+70
View File
@@ -10,6 +10,12 @@ import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { extractAssistantText, sanitizeTextContent } from "./chat-history-text.js";
const callGatewayMock = vi.fn();
const inProcessCreationMock = vi.fn(
async (..._args: [unknown, unknown, unknown]): Promise<unknown> => ({}),
);
// Default false mirrors running outside a gateway process; the trusted-creation
// regression test flips it on and restores it.
let inProcessGatewayContextAvailable = false;
const facadeRuntimeMock = vi.hoisted(() => ({
sessionKeyResolvers: new Map<
string,
@@ -25,6 +31,11 @@ const facadeRuntimeMock = vi.hoisted(() => ({
vi.mock("../../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
vi.mock("./in-process-gateway.js", () => ({
callInProcessGatewayToolWithCreation: (method: unknown, params: unknown, creation: unknown) =>
inProcessCreationMock(method, params, creation),
hasInProcessGatewayToolContext: () => inProcessGatewayContextAvailable,
}));
vi.mock("../../plugin-sdk/facade-runtime.js", async () => {
const actual = await vi.importActual<typeof import("../../plugin-sdk/facade-runtime.js")>(
"../../plugin-sdk/facade-runtime.js",
@@ -1243,4 +1254,63 @@ describe("sessions_send gating", () => {
expect(waitTimeouts).toEqual([MAX_TIMER_TIMEOUT_MS]);
});
});
describe("sessions_send agent-main materialization provenance", () => {
it("uses the trusted in-process creation stamp in the production assembly (no injected caller)", async () => {
inProcessGatewayContextAvailable = true;
inProcessCreationMock.mockClear();
loadConfigMock.mockReturnValue({
session: { scope: "per-sender", mainKey: "main" },
tools: {
agentToAgent: { enabled: false },
sessions: { visibility: "all" },
},
});
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string };
if (request.method === "sessions.resolve") {
// Unmaterialized agent main: the probe fails, forcing creation.
throw new Error("unknown session: agent:main:main");
}
if (request.method === "sessions.create") {
throw new Error("plain sessions.create must not be used for trusted materialization");
}
if (request.method === "chat.history") {
return { messages: [] };
}
if (request.method === "agent") {
return { runId: "run-ensure-main", acceptedAt: 1 };
}
return {};
});
// Mirror production assembly (openclaw-tools.ts): no callGateway override, so
// ensureConfiguredAgentMainSession takes the trusted in-process branch.
const tool = createSessionsSendTool({
agentSessionKey: "agent:main:dashboard:req-provenance",
agentChannel: MAIN_AGENT_CHANNEL,
});
try {
const result = await tool.execute("call-ensure-main-provenance", {
sessionKey: "agent:main:main",
message: "wake up",
timeoutSeconds: 0,
});
expect(requireDetails(result).status).toBe("accepted");
expect(inProcessCreationMock).toHaveBeenCalledTimes(1);
expect(inProcessCreationMock).toHaveBeenCalledWith(
"sessions.create",
{ key: "agent:main:main", agentId: "main" },
{
via: "internal",
actor: { type: "agent", id: "agent:main:dashboard:req-provenance" },
},
);
} finally {
inProcessGatewayContextAvailable = false;
inProcessCreationMock.mockClear();
}
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
+20 -2
View File
@@ -9,6 +9,7 @@ import { normalizeAnyChannelId } from "../../channels/registry.js";
import { applyMergePatch } from "../../config/merge-patch.js";
import { resolveStorePath } from "../../config/sessions/paths.js";
import { loadSessionEntry, listSessionEntries } from "../../config/sessions/session-accessor.js";
import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js";
import { resolveSessionKey } from "../../config/sessions/session-key.js";
import { formatSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js";
import type { SessionEntry, SessionScope } from "../../config/sessions/types.js";
@@ -214,8 +215,25 @@ export function initFastReplySessionState(params: {
const sessionEntry: SessionEntry = {
...(!resetTriggered ? existingEntry : undefined),
sessionId,
...((resetTriggered || !existingEntry) && ctx.SessionCreator
? { createdBy: { ...ctx.SessionCreator } }
...(!existingEntry && ctx.SessionCreation
? buildSessionCreationStamp(ctx.SessionCreation)
: {}),
...(resetTriggered && existingEntry
? {
previousSessionId: existingEntry.sessionId,
spawnedBy: existingEntry.spawnedBy,
spawnedWorkspaceDir: existingEntry.spawnedWorkspaceDir,
spawnedCwd: existingEntry.spawnedCwd,
parentSessionKey: existingEntry.parentSessionKey,
forkedFromParent: existingEntry.forkedFromParent,
forkSource: existingEntry.forkSource,
createdVia: existingEntry.createdVia,
createdActor: existingEntry.createdActor,
createdAt: existingEntry.createdAt,
spawnDepth: existingEntry.spawnDepth,
subagentRole: existingEntry.subagentRole,
subagentControlScope: existingEntry.subagentControlScope,
}
: {}),
sessionFile,
updatedAt: now,
@@ -6,6 +6,7 @@ import {
} from "../../agents/model-selection.js";
import { loadPreparedModelCatalog } from "../../agents/prepared-model-catalog.js";
import type { OpenClawConfig } from "../../config/config.js";
import { recordSessionCreated } from "../../sessions/session-state-events.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
import type { SkillCommandSpec } from "../../skills/types.js";
import { isInternalMessageChannel } from "../../utils/message-channel.js";
@@ -169,6 +170,13 @@ export async function maybeResolveNativeSlashCommandFastReply(params: {
};
}
const persistedInitialEntry = persistence.entry;
if (creatingSession) {
recordSessionCreated({
sessionKey: sessionState.sessionKey,
agentId: params.agentId,
entry: persistedInitialEntry,
});
}
// Commit the synthesized activity/channel touch before commands or directives
// capture their own mutation baseline.
sessionState.sessionEntry = persistedInitialEntry;
@@ -13,6 +13,8 @@ import {
MODEL_SELECTION_LOCKED_RESET_MESSAGE,
ModelSelectionLockedError,
} from "../../sessions/model-overrides.js";
import { listSessionStateEventsSince } from "../../sessions/session-state-events.js";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import { getReplyPayloadMetadata } from "../reply-payload.js";
import { handleGoalCommand } from "./commands-goal.js";
import { buildFastReplyCommandContext, initFastReplySessionState } from "./get-reply-fast-path.js";
@@ -200,6 +202,7 @@ describe("getReplyFromConfig fast test bootstrap", () => {
});
afterEach(() => {
closeOpenClawStateDatabaseForTest();
cliBackendsTesting.resetDepsForTest();
vi.unstubAllEnvs();
});
@@ -604,6 +607,7 @@ describe("getReplyFromConfig fast test bootstrap", () => {
it("handles native slash directives before workspace bootstrap", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-native-slash-fast-"));
const targetSessionKey = "agent:main:telegram:123";
vi.stubEnv("OPENCLAW_STATE_DIR", path.join(home, "state"));
const cfg = markCompleteReplyConfig({
agents: {
defaults: {
@@ -628,6 +632,10 @@ describe("getReplyFromConfig fast test bootstrap", () => {
CommandAuthorized: true,
SessionKey: "telegram:slash:123",
CommandTargetSessionKey: targetSessionKey,
SessionCreation: {
via: "operator",
actor: { type: "human", id: "profile-native-slash" },
},
}),
undefined,
cfg,
@@ -644,6 +652,13 @@ describe("getReplyFromConfig fast test bootstrap", () => {
expect(vi.mocked(runPreparedReplyMock)).not.toHaveBeenCalled();
expect(mocks.handleCommands).toHaveBeenCalledOnce();
expect(mocks.resolveReplyDirectives).toHaveBeenCalledOnce();
expect(listSessionStateEventsSince(targetSessionKey, "main", 0, 20).events).toContainEqual(
expect.objectContaining({
kind: "created",
actorType: "human",
actorId: "profile-native-slash",
}),
);
const directiveParams = requireDirectiveParams();
expect(directiveParams.sessionKey).toBe(targetSessionKey);
expect(directiveParams.workspaceDir).toBe("/tmp/workspace");
@@ -759,6 +774,28 @@ describe("getReplyFromConfig fast test bootstrap", () => {
);
});
it("stamps trusted creation provenance during fast bootstrap", () => {
const result = initFastReplySessionState({
ctx: buildGetReplyCtx({
SessionKey: "agent:main:dashboard:created",
SessionCreation: {
via: "operator",
actor: { type: "human", id: "profile-ada" },
},
}),
cfg: { session: { store: "/tmp/sessions.json" } } as OpenClawConfig,
agentId: "main",
commandAuthorized: true,
workspaceDir: "/tmp/workspace",
});
expect(result.sessionEntry).toMatchObject({
createdVia: "operator",
createdActor: { type: "human", id: "profile-ada" },
createdAt: expect.any(Number),
});
});
it("preserves usage footer mode during fast reset bootstrap", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fast-reset-usage-"));
const storePath = path.join(home, "sessions.json");
@@ -788,6 +825,57 @@ describe("getReplyFromConfig fast test bootstrap", () => {
expect(result.sessionEntry.responseUsage).toBe("full");
});
it("preserves node provenance and lineage during fast reset bootstrap", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fast-reset-lineage-"));
const storePath = path.join(home, "sessions.json");
const sessionKey = "agent:main:telegram:lineage";
await seedFastPathSessionStore(storePath, {
[sessionKey]: {
sessionId: "existing-fast-reset-lineage",
updatedAt: Date.now(),
spawnedBy: "agent:main:main",
parentSessionKey: "agent:main:dashboard:parent",
spawnedWorkspaceDir: "/tmp/workspace",
spawnedCwd: "/tmp/repo",
forkSource: { sessionKey: "agent:main:main", sessionId: "source-generation" },
createdVia: "spawn",
createdActor: { type: "agent", id: "agent:main:main" },
createdAt: 1_234,
spawnDepth: 2,
subagentRole: "orchestrator",
subagentControlScope: "children",
},
});
const result = initFastReplySessionState({
ctx: buildGetReplyCtx({
Body: "/reset",
RawBody: "/reset",
CommandBody: "/reset",
SessionKey: sessionKey,
}),
cfg: { session: { store: storePath } } as OpenClawConfig,
agentId: "main",
commandAuthorized: true,
workspaceDir: home,
});
expect(result.sessionEntry).toMatchObject({
previousSessionId: "existing-fast-reset-lineage",
spawnedBy: "agent:main:main",
parentSessionKey: "agent:main:dashboard:parent",
spawnedWorkspaceDir: "/tmp/workspace",
spawnedCwd: "/tmp/repo",
forkSource: { sessionKey: "agent:main:main", sessionId: "source-generation" },
createdVia: "spawn",
createdActor: { type: "agent", id: "agent:main:main" },
createdAt: 1_234,
spawnDepth: 2,
subagentRole: "orchestrator",
subagentControlScope: "children",
});
});
it("rejects a fast reset bootstrap for a model-locked session", async () => {
const home = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-fast-reset-locked-"));
const storePath = path.join(home, "sessions.json");
@@ -1,41 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, expect, it } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import { upsertSessionEntry } from "../../config/sessions/session-accessor.js";
import { closeOpenClawAgentDatabasesForTest } from "../../state/openclaw-agent-db.js";
import { initSessionState } from "./session.js";
let tempDir: string | undefined;
afterEach(async () => {
closeOpenClawAgentDatabasesForTest();
if (tempDir) {
await fs.rm(tempDir, { force: true, recursive: true });
tempDir = undefined;
}
});
it("clears the previous creator when an ownerless turn starts a new generation", async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-creator-"));
const storePath = path.join(tempDir, "sessions.json");
const sessionKey = "agent:main:telegram:chat:creator";
await upsertSessionEntry(
{ sessionKey, storePath },
{
createdBy: { id: "alice@example.com", label: "Alice" },
sessionId: "owned-session",
updatedAt: 1,
},
);
const result = await initSessionState({
ctx: { Body: "/new", CommandBody: "/new", SessionKey: sessionKey },
cfg: { session: { store: storePath } } as OpenClawConfig,
commandAuthorized: true,
});
expect(result.isNewSession).toBe(true);
expect(result.sessionEntry).not.toHaveProperty("createdBy");
});
@@ -188,6 +188,10 @@ describe("forkSessionEntryFromParent", () => {
const stored = loadSessionEntry({ agentId: "main", sessionKey, storePath });
expect(stored).toMatchObject({
forkedFromParent: true,
forkSource: {
sessionKey: parentSessionKey,
sessionId: "parent-session",
},
label: "forked child",
sessionFile: result.fork.sessionFile,
sessionId: result.fork.sessionId,
@@ -269,6 +273,9 @@ describe("forkSessionEntryFromParent", () => {
sessionId: "",
updatedAt: expect.any(Number),
});
expect(
loadSessionEntry({ agentId: "main", sessionKey, storePath })?.forkSource,
).toBeUndefined();
});
it("skips stale-token SQLite parents using transcript usage estimates", async () => {
@@ -60,6 +60,10 @@ export async function prepareReplySessionParentFork(params: {
...buildMainSessionRecoveryClearPatch(params.sessionEntry),
sessionId: fork.sessionId,
sessionFile: fork.sessionFile,
forkSource: {
sessionKey: params.parentSessionKey,
sessionId: parentEntry.sessionId,
},
forkedFromParent: true,
totalTokens: undefined,
totalTokensFresh: false,
+104
View File
@@ -36,6 +36,8 @@ import {
isSessionLifecycleMutationActive,
runExclusiveSessionLifecycleMutation,
} from "../../sessions/session-lifecycle-admission.js";
import { listSessionStateEventsSince } from "../../sessions/session-state-events.js";
import { closeOpenClawStateDatabaseForTest } from "../../state/openclaw-state-db.js";
import {
createChannelTestPluginBase,
createTestRegistry,
@@ -579,6 +581,7 @@ beforeEach(() => {
});
});
afterEach(async () => {
closeOpenClawStateDatabaseForTest();
resetSystemEventsForTest();
await sessionMcpTesting.resetSessionMcpRuntimeManager();
});
@@ -720,6 +723,10 @@ describe("initSessionState thread forking", () => {
expect(result.sessionEntry.totalTokensFresh).toBe(false);
expect(result.sessionEntry.forkedFromParent).toBe(true);
expect(result.sessionEntry.forkSource).toEqual({
sessionKey: parentSessionKey,
sessionId: parentSessionId,
});
expect(result.sessionEntry.sessionFile).toBe(
formatSqliteSessionFileMarker({
agentId: "main",
@@ -811,6 +818,10 @@ describe("initSessionState thread forking", () => {
expect(first.sessionEntry.sessionId).not.toBe("preseed-thread-session");
expect(first.sessionEntry.forkedFromParent).toBe(true);
expect(first.sessionEntry.forkSource).toEqual({
sessionKey: parentSessionKey,
sessionId: parentSessionId,
});
expect(first.sessionEntry.totalTokens).toBeUndefined();
expect(first.sessionEntry.totalTokensFresh).toBe(false);
expect(first.sessionEntry.abortedLastRun).toBe(false);
@@ -899,6 +910,7 @@ describe("initSessionState thread forking", () => {
// Should be marked as forked (to prevent re-attempts) but NOT actually forked from parent
expect(result.sessionEntry.forkedFromParent).toBe(true);
expect(result.sessionEntry.forkSource).toBeUndefined();
// Session ID should NOT match the parent — it should be a fresh UUID
expect(result.sessionEntry.sessionId).not.toBe(parentSessionId);
// Session file should NOT be the parent's file (it was not forked)
@@ -1308,6 +1320,97 @@ describe("initSessionState RawBody", () => {
expect(store[sessionKey]?.modelOverrideSource).toBe("user");
});
it("stamps trusted creation provenance when initializing a missing session", async () => {
const root = await makeCaseDir("openclaw-session-creation-provenance-");
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:dashboard:created";
const result = await withEnvAsync(
{ OPENCLAW_STATE_DIR: path.join(root, "state") },
async () => {
const initialized = await initSessionState({
ctx: {
RawBody: "hello",
ChatType: "direct",
SessionKey: sessionKey,
SessionCreation: {
via: "operator",
actor: { type: "human", id: "profile-ada" },
},
},
cfg: { session: { store: storePath } } as OpenClawConfig,
commandAuthorized: true,
});
expect(listSessionStateEventsSince(sessionKey, "main", 0, 20).events).toContainEqual(
expect.objectContaining({
kind: "created",
actorType: "human",
actorId: "profile-ada",
}),
);
return initialized;
},
);
expect(result.sessionEntry).toMatchObject({
createdVia: "operator",
createdActor: { type: "human", id: "profile-ada" },
createdAt: expect.any(Number),
});
});
it("preserves session lineage across an implicit daily stale rollover (#90119)", async () => {
const root = await makeCaseDir("openclaw-daily-rollover-lineage-");
const storePath = path.join(root, "sessions.json");
const sessionKey = "agent:main:subagent:daily-rollover-lineage";
const existingSessionId = "session-before-daily-reset-lineage";
const staleStartedAt = Date.now() - 48 * 60 * 60 * 1000;
const lineage = {
spawnedBy: "agent:main:main",
spawnedWorkspaceDir: "/tmp/child-workspace",
spawnedCwd: "/tmp/task-repo",
parentSessionKey: "agent:main:main",
forkedFromParent: true,
forkSource: {
sessionKey: "agent:main:root",
sessionId: "root-transcript-generation",
},
createdVia: "spawn",
createdActor: { type: "agent", id: "agent:main:main" },
createdAt: staleStartedAt - 1_000,
spawnDepth: 1,
subagentRole: "leaf",
subagentControlScope: "none",
} as const;
await writeSessionStoreFast(storePath, {
[sessionKey]: {
sessionId: existingSessionId,
updatedAt: staleStartedAt,
sessionStartedAt: staleStartedAt,
lastInteractionAt: staleStartedAt,
...lineage,
},
});
const result = await initSessionState({
ctx: {
RawBody: "continue child work",
ChatType: "direct",
SessionKey: sessionKey,
},
cfg: {
session: { store: storePath, reset: { mode: "daily", atHour: 4 } },
} as OpenClawConfig,
commandAuthorized: true,
});
expect(result.isNewSession).toBe(true);
expect(result.resetTriggered).toBe(false);
expect(result.sessionId).not.toBe(existingSessionId);
expect(result.sessionEntry.previousSessionId).toBe(existingSessionId);
expectEntryFields(result.sessionEntry, lineage);
});
it("preserves user-set behavior and pinned state across an implicit daily stale rollover (#92562)", async () => {
// Regression: session-level behavior overrides (/think, /verbose, /reasoning,
// /trace, ttsAuto) survive an explicit /new but were dropped after the
@@ -3979,6 +4082,7 @@ describe("initSessionState preserves behavior overrides across /new and /reset",
const overrides = {
spawnedBy: "agent:main:main",
spawnedWorkspaceDir: "/tmp/child-workspace",
spawnedCwd: "/tmp/task-repo",
parentSessionKey: "agent:main:main",
forkedFromParent: true,
spawnDepth: 2,
+26 -14
View File
@@ -34,6 +34,8 @@ import {
commitReplySessionInitialization,
loadReplySessionInitializationSnapshot,
} from "../../config/sessions/session-accessor.js";
import { sessionEntryForkedFromParent } from "../../config/sessions/session-entry-lineage.js";
import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js";
import { resolveSessionKey } from "../../config/sessions/session-key.js";
import { resolveMaintenanceConfigFromInput } from "../../config/sessions/store-maintenance.js";
import { runExclusiveSessionStoreWrite } from "../../config/sessions/store-writer.js";
@@ -77,6 +79,7 @@ import {
interruptSessionWorkAdmissions,
runExclusiveSessionLifecycleMutation,
} from "../../sessions/session-lifecycle-admission.js";
import { recordSessionCreated } from "../../sessions/session-state-events.js";
import {
classifySessionStateActor,
registerMainSessionGroupWatch,
@@ -477,6 +480,10 @@ async function initSessionStateAttemptLocked(
let persistedSpawnedCwd: SessionEntry["spawnedCwd"];
let persistedParentSessionKey: SessionEntry["parentSessionKey"];
let persistedForkedFromParent: SessionEntry["forkedFromParent"];
let persistedForkSource: SessionEntry["forkSource"];
let persistedCreatedVia: SessionEntry["createdVia"];
let persistedCreatedActor: SessionEntry["createdActor"];
let persistedCreatedAt: SessionEntry["createdAt"];
let persistedSpawnDepth: SessionEntry["spawnDepth"];
let persistedSubagentRole: SessionEntry["subagentRole"];
let persistedSubagentControlScope: SessionEntry["subagentControlScope"];
@@ -584,6 +591,7 @@ async function initSessionStateAttemptLocked(
ctx,
});
const entry = initializationSnapshot.currentEntry;
const createdNewEntry = entry === undefined;
const archivedSessionError = resolveSessionWorkStartError(sessionKey, entry);
if (archivedSessionError) {
throw new Error(archivedSessionError);
@@ -804,17 +812,18 @@ async function initSessionStateAttemptLocked(
persistedResponseUsage = entry.responseUsage;
persistedLabel = entry.label;
persistedDisplayName = entry.displayName;
}
// When a reset trigger (/new, /reset) starts a new session, also rotate the
// underlying CLI conversation and carry forward spawn lineage.
if (resetTriggered && entry) {
// Explicit /new and /reset should rotate the underlying CLI conversation too.
// Keep the model/auth choice, but force the next turn to mint a fresh CLI binding.
// Explicit /new and /reset rotate CLI conversation bindings elsewhere.
// Lineage/control facts belong to the session node and survive ANY rollover
// from an existing entry, following the #90119 carry pattern.
persistedSpawnedBy = entry.spawnedBy;
persistedSpawnedWorkspaceDir = entry.spawnedWorkspaceDir;
persistedSpawnedCwd = entry.spawnedCwd;
persistedParentSessionKey = entry.parentSessionKey;
persistedForkedFromParent = entry.forkedFromParent;
persistedForkSource = entry.forkSource;
persistedCreatedVia = entry.createdVia;
persistedCreatedActor = entry.createdActor;
persistedCreatedAt = entry.createdAt;
persistedSpawnDepth = entry.spawnDepth;
persistedSubagentRole = entry.subagentRole;
persistedSubagentControlScope = entry.subagentControlScope;
@@ -902,16 +911,11 @@ async function initSessionStateAttemptLocked(
const lastTo = deliveryFields.lastTo ?? lastToRaw;
const lastAccountId = deliveryFields.lastAccountId ?? lastAccountIdRaw;
const lastThreadId = deliveryFields.lastThreadId ?? lastThreadIdRaw;
const creationStamp =
!entry && ctx.SessionCreation ? buildSessionCreationStamp(ctx.SessionCreation) : undefined;
sessionEntry = {
...baseEntry,
sessionId,
...(isNewSession
? ctx.SessionCreator
? { createdBy: { ...ctx.SessionCreator } }
: {}
: baseEntry?.createdBy
? { createdBy: baseEntry.createdBy }
: {}),
updatedAt: Date.now(),
sessionStartedAt: isNewSession
? now
@@ -930,6 +934,7 @@ async function initSessionStateAttemptLocked(
pinnedAt: entry?.pinnedAt,
usageFamilyKey,
usageFamilySessionIds,
previousSessionId: previousSessionEntry?.sessionId ?? baseEntry?.previousSessionId,
modelOverride: persistedModelOverride ?? baseEntry?.modelOverride,
providerOverride: persistedProviderOverride ?? baseEntry?.providerOverride,
modelOverrideSource: persistedModelOverrideSource ?? baseEntry?.modelOverrideSource,
@@ -947,6 +952,10 @@ async function initSessionStateAttemptLocked(
spawnedCwd: persistedSpawnedCwd ?? baseEntry?.spawnedCwd,
parentSessionKey: persistedParentSessionKey ?? baseEntry?.parentSessionKey,
forkedFromParent: persistedForkedFromParent ?? baseEntry?.forkedFromParent,
forkSource: persistedForkSource ?? baseEntry?.forkSource,
createdVia: persistedCreatedVia ?? baseEntry?.createdVia ?? creationStamp?.createdVia,
createdActor: persistedCreatedActor ?? baseEntry?.createdActor ?? creationStamp?.createdActor,
createdAt: persistedCreatedAt ?? baseEntry?.createdAt ?? creationStamp?.createdAt,
spawnDepth: persistedSpawnDepth ?? baseEntry?.spawnDepth,
subagentRole: persistedSubagentRole ?? baseEntry?.subagentRole,
subagentControlScope: persistedSubagentControlScope ?? baseEntry?.subagentControlScope,
@@ -999,7 +1008,7 @@ async function initSessionStateAttemptLocked(
sessionEntry.displayName = threadLabel;
}
const parentSessionKey = normalizeOptionalString(ctx.ParentSessionKey);
const alreadyForked = sessionEntry.forkedFromParent === true;
const alreadyForked = sessionEntryForkedFromParent(sessionEntry);
if (params.signal?.aborted === true) {
throw new Error("reply session initialization aborted");
}
@@ -1093,6 +1102,9 @@ async function initSessionStateAttemptLocked(
}
sessionEntry = committed.sessionEntry;
sessionId = sessionEntry.sessionId;
if (createdNewEntry) {
recordSessionCreated({ sessionKey, agentId, entry: sessionEntry });
}
if (
!isSystemEvent &&
classifySessionStateActor({ inputProvenance: ctx.InputProvenance }).actorType === "human"
+5 -2
View File
@@ -276,8 +276,11 @@ export type MsgContext = {
OwnerAllowFrom?: Array<string | number>;
SenderName?: string;
SenderId?: string;
/** Trusted Gateway operator identity used only when creating a session. */
SessionCreator?: import("../../packages/gateway-protocol/src/schema/sessions.js").SessionCreatorIdentity;
/** Trusted in-process creation provenance; never populated from channel payloads. */
SessionCreation?: {
via: import("../config/sessions/session-entry-provenance.js").SessionCreatedVia;
actor?: import("../config/sessions/session-entry-provenance.js").SessionCreatedActor;
};
SenderUsername?: string;
SenderTag?: string;
SenderE164?: string;
+2 -1
View File
@@ -7,6 +7,7 @@
import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { theme } from "../../packages/terminal-core/src/theme.js";
import type { SessionEntry } from "../config/sessions.js";
import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js";
import { formatTimeAgo } from "../infra/format-time/format-relative.ts";
/** Display row derived from a persisted session entry. */
@@ -66,7 +67,7 @@ export function toSessionDisplayRow(key: string, entry: SessionEntry): SessionDi
spawnedWorkspaceDir: entry?.spawnedWorkspaceDir,
spawnedCwd: entry?.spawnedCwd,
parentSessionKey: entry?.parentSessionKey,
forkedFromParent: entry?.forkedFromParent,
forkedFromParent: sessionEntryForkedFromParent(entry) ? true : undefined,
spawnDepth: entry?.spawnDepth,
subagentRole: entry?.subagentRole,
subagentControlScope: entry?.subagentControlScope,
@@ -1258,7 +1258,11 @@ describe("sqlite session normalization", () => {
await upsertSqliteSessionEntry(scope, {
channel: "telegram",
chatType: "group",
createdVia: "channel",
createdActor: { type: "human", id: "telegram-sender" },
createdAt: 1_782_973_390_000,
displayName: "telegram:g-bucephalus-+-topics",
forkSource: { sessionKey: "agent:main:main", sessionId: "root-session" },
sessionId: oldSessionId,
status: "killed",
updatedAt: 1_782_973_392_492,
@@ -1298,6 +1302,11 @@ describe("sqlite session normalization", () => {
expect(loadSqliteSessionEntry(scope)).toEqual(
expect.objectContaining({
sessionId: newSessionId,
createdVia: "channel",
createdActor: { type: "human", id: "telegram-sender" },
createdAt: 1_782_973_390_000,
forkSource: { sessionKey: "agent:main:main", sessionId: "root-session" },
previousSessionId: oldSessionId,
usageFamilyKey: sessionKey,
usageFamilySessionIds: [oldSessionId, newSessionId],
}),
@@ -25,7 +25,6 @@ import {
import {
normalizeSqliteStatus,
parseSqliteSessionEntryJson as parseSessionEntryRow,
serializeSqliteSessionCreatorIdentity,
} from "./session-accessor.sqlite-status.js";
import {
readTranscriptMutationStateInTransaction,
@@ -488,7 +487,6 @@ export function writeSessionEntry(
entry_json: JSON.stringify(normalizedEntry),
updated_at: updatedAt,
status: normalizeSqliteStatus(normalizedEntry.status),
created_by_json: serializeSqliteSessionCreatorIdentity(normalizedEntry.createdBy),
})
.onConflict((conflict) =>
conflict.column("session_key").doUpdateSet({
@@ -496,7 +494,6 @@ export function writeSessionEntry(
entry_json: JSON.stringify(normalizedEntry),
updated_at: updatedAt,
status: normalizeSqliteStatus(normalizedEntry.status),
created_by_json: serializeSqliteSessionCreatorIdentity(normalizedEntry.createdBy),
}),
),
);
@@ -64,6 +64,7 @@ import {
readSqliteSessionEntriesByStatus,
} from "./session-accessor.sqlite-status.js";
import { preserveSqliteSameKeySessionRolloverLineage } from "./session-entry-lineage.js";
import { buildSessionCreationStamp } from "./session-entry-provenance.js";
import { kickSessionHistoryDiskBudgetMaintenance } from "./session-history-eviction.js";
import { resolveSessionStorePathForScope } from "./session-store-path.js";
import type { GroupKeyResolution, SessionEntry } from "./types.js";
@@ -419,13 +420,27 @@ export async function recordSqliteInboundSessionMeta(params: {
const createIfMissing = params.createIfMissing ?? true;
return await patchSqliteSessionEntry(
{ sessionKey: params.sessionKey, storePath: params.storePath },
(_entry, context) =>
deriveSessionMetaPatch({
(_entry, context) => {
const metadataPatch = deriveSessionMetaPatch({
ctx: params.ctx,
sessionKey: params.sessionKey,
existing: context.existingEntry,
groupResolution: params.groupResolution,
}),
});
if (context.existingEntry) {
return metadataPatch;
}
const senderId = params.ctx.From?.trim();
return {
...buildSessionCreationStamp(
params.ctx.SessionCreation ?? {
via: "channel",
actor: { type: "human", ...(senderId ? { id: senderId } : {}) },
},
),
...metadataPatch,
};
},
{
// Inbound metadata must not refresh activity timestamps; idle reset
// evaluation relies on updatedAt from actual session turns.
@@ -452,8 +467,8 @@ export async function updateSqliteSessionLastRoute(params: {
const createIfMissing = params.createIfMissing ?? true;
return await patchSqliteSessionEntry(
{ sessionKey: params.sessionKey, storePath: params.storePath },
(_entry, context) =>
deriveLastRoutePatch({
(_entry, context) => {
const routePatch = deriveLastRoutePatch({
channel: params.channel,
to: params.to,
accountId: params.accountId,
@@ -464,7 +479,23 @@ export async function updateSqliteSessionLastRoute(params: {
groupResolution: params.groupResolution,
existing: context.existingEntry,
sessionKey: params.sessionKey,
}),
});
if (context.existingEntry) {
return routePatch;
}
const senderId = params.ctx?.From?.trim();
return {
...buildSessionCreationStamp(
params.ctx?.SessionCreation ?? {
via: "channel",
...(params.ctx
? { actor: { type: "human" as const, ...(senderId ? { id: senderId } : {}) } }
: {}),
},
),
...routePatch,
};
},
{
// Route updates must not refresh activity timestamps (#49515).
preserveActivity: true,
@@ -37,7 +37,11 @@ async function createSession(options: { activeLeafTarget?: string } = {}) {
cliSessionIds: { "claude-cli": "claude-conversation" },
compactionCount: 2,
contextTokens: 100_000,
createdVia: "operator",
createdActor: { type: "human", id: "profile-1" },
createdAt: 1_000,
deliveryContext: { channel: "telegram", to: "chat-123" },
forkSource: { sessionKey: "agent:main:root", sessionId: "root-session" },
lastChannel: "telegram",
lastTo: "chat-123",
lifecycleRevision: "source-lifecycle-revision",
@@ -210,6 +214,11 @@ describe("SQLite session message cuts", () => {
cliSessionIds: undefined,
compactionCount: undefined,
contextTokens: undefined,
createdVia: "operator",
createdActor: { type: "human", id: "profile-1" },
createdAt: 1_000,
forkSource: { sessionKey: "agent:main:root", sessionId: "root-session" },
previousSessionId: "message-cut-source",
});
expect(result.entry.deliveryContext).toEqual({ channel: "telegram", to: "chat-123" });
});
@@ -274,6 +283,16 @@ describe("SQLite session message cuts", () => {
expect(result.entry.lastChannel).toBeUndefined();
expect(result.entry.lastTo).toBeUndefined();
expect(result.entry.parentSessionKey).toBe(canonicalSourceKey);
expect(result.entry.previousSessionId).toBeUndefined();
expect(result.entry.forkedFromParent).toBeUndefined();
expect(result.entry.createdVia).toBeUndefined();
expect(result.entry.createdActor).toBeUndefined();
expect(result.entry.createdAt).toBeUndefined();
expect(result.entry.forkSource).toEqual({
sessionKey: canonicalSourceKey,
sessionId: "message-cut-source",
entryId: "user-2",
});
expect(result.entry).toMatchObject({
modelOverride: "gpt-5",
modelOverrideSource: "user",
@@ -33,6 +33,7 @@ import type {
SessionMessageCutMutationParams,
SessionMessageCutMutationResult,
} from "./session-accessor.types.js";
import { buildSessionCreationStamp } from "./session-entry-provenance.js";
import { inheritSessionSelection } from "./session-entry-selection.js";
import { reconcileSessionTranscriptIndexInTransaction } from "./session-transcript-index.js";
import { parseSqliteSessionFileMarker } from "./sqlite-marker.js";
@@ -142,6 +143,7 @@ async function mutateSqliteSessionAtMessage(
result = mutateSqliteSessionAtMessageInTransaction(database, resolved, {
entryId: params.entryId,
canonicalSourceKey,
creation: params.creation,
mode,
sourceKey,
targetKey,
@@ -158,6 +160,7 @@ function mutateSqliteSessionAtMessageInTransaction(
resolved: ResolvedSqliteScope,
params: {
canonicalSourceKey: string;
creation?: SessionMessageCutMutationParams["creation"];
entryId: string;
mode: SessionTranscriptMutationMode;
sourceKey: string;
@@ -215,13 +218,25 @@ function mutateSqliteSessionAtMessageInTransaction(
// Rotating transcript identity fences stale live managers: later snapshot-replace writes
// target the old session and cannot erase this leaf repoint from the active session.
const nextEntry = cloneMessageCutSessionEntry({
currentEntry,
forked: params.mode === "fork",
nextSessionFile,
nextSessionId,
parentSessionKey: params.mode === "fork" ? params.canonicalSourceKey : undefined,
});
const nextEntry = {
...cloneMessageCutSessionEntry({
currentEntry,
forked: params.mode === "fork",
forkSource:
params.mode === "fork"
? {
sessionKey: params.canonicalSourceKey,
sessionId: currentEntry.sessionId,
entryId: params.entryId,
}
: undefined,
nextSessionFile,
nextSessionId,
}),
...(params.mode === "fork" && params.creation
? buildSessionCreationStamp(params.creation)
: {}),
};
writeSessionEntry(database, params.targetKey, nextEntry);
return {
status: "created",
@@ -356,9 +371,9 @@ function resolveMessageCut(
function cloneMessageCutSessionEntry(params: {
currentEntry: SessionEntry;
forked: boolean;
forkSource?: NonNullable<SessionEntry["forkSource"]>;
nextSessionFile: string;
nextSessionId: string;
parentSessionKey?: string;
}): SessionEntry {
const baseEntry = params.forked
? inheritSessionSelection(params.currentEntry)
@@ -407,7 +422,10 @@ function cloneMessageCutSessionEntry(params: {
abortCutoffTimestamp: undefined,
usageFamilyKey: params.forked ? undefined : params.currentEntry.usageFamilyKey,
usageFamilySessionIds: params.forked ? undefined : params.currentEntry.usageFamilySessionIds,
...(params.parentSessionKey ? { parentSessionKey: params.parentSessionKey } : {}),
previousSessionId: params.forked ? undefined : params.currentEntry.sessionId,
...(params.forkSource
? { forkSource: params.forkSource, parentSessionKey: params.forkSource.sessionKey }
: {}),
};
}
@@ -218,6 +218,10 @@ export async function forkSqliteSessionEntryFromParentTarget(
});
const next = mergeSessionEntry(freshBase, {
...patch,
forkSource: {
sessionKey: parentTarget.canonicalKey,
sessionId: freshParent.sessionId,
},
forkedFromParent: true,
sessionFile: fork.transcript.sessionFile,
sessionId: fork.transcript.sessionId,
@@ -19,42 +19,12 @@ export function normalizeSqliteStatus(value: unknown): SessionEntryStatus | null
: null;
}
function normalizeSessionCreatorIdentity(value: unknown): SessionEntry["createdBy"] {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return undefined;
}
const candidate = value as { id?: unknown; label?: unknown };
const id = typeof candidate.id === "string" ? candidate.id.trim() : "";
if (!id) {
return undefined;
}
const label = typeof candidate.label === "string" ? candidate.label.trim() : "";
return { id, ...(label ? { label } : {}) };
}
export function serializeSqliteSessionCreatorIdentity(
createdBy: SessionEntry["createdBy"],
): string | null {
const normalized = normalizeSessionCreatorIdentity(createdBy);
return normalized ? JSON.stringify(normalized) : null;
}
export function parseSqliteSessionEntryJson(row: { entry_json: string }): SessionEntry | null {
try {
const parsed = JSON.parse(row.entry_json) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return null;
}
const entry = parsed as SessionEntry;
// entry_json stays authoritative across downgrade/upgrade cycles: an older
// binary can rewrite it without knowing about the additive projection column.
const createdBy = normalizeSessionCreatorIdentity(entry.createdBy);
if (createdBy) {
entry.createdBy = createdBy;
} else {
delete entry.createdBy;
}
return entry;
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as SessionEntry)
: null;
} catch {
return null;
}
+36 -23
View File
@@ -105,7 +105,6 @@ describe("session accessor seam", () => {
};
await upsertSessionEntry(scope, {
createdBy: { id: "profile-ada", label: "Ada Lovelace" },
model: "gpt-5.5",
sessionId: "session-1",
updatedAt: 10,
@@ -113,20 +112,10 @@ describe("session accessor seam", () => {
expect(loadSessionEntry(scope)).toMatchObject({
model: "gpt-5.5",
createdBy: { id: "profile-ada", label: "Ada Lovelace" },
sessionId: "session-1",
updatedAt: expect.any(Number),
});
expect(readSessionUpdatedAt(scope)).toEqual(expect.any(Number));
const databasePath = resolveSqliteTargetFromSessionStorePath(storePath, {
agentId: "main",
}).path;
const database = openOpenClawAgentDatabase({ agentId: "main", path: databasePath });
expect(
database.db
.prepare("SELECT created_by_json FROM session_entries WHERE session_key = ?")
.get(scope.sessionKey),
).toEqual({ created_by_json: '{"id":"profile-ada","label":"Ada Lovelace"}' });
expect(listSessionEntries({ storePath })).toEqual([
{
sessionKey: "agent:main:main",
@@ -138,17 +127,6 @@ describe("session accessor seam", () => {
},
]);
// A downgraded writer knows only entry_json and can leave the additive
// projection untouched. Re-upgrade must not resurrect that stale creator.
database.db
.prepare("UPDATE session_entries SET entry_json = ?, updated_at = ? WHERE session_key = ?")
.run(
JSON.stringify({ model: "legacy-reset", sessionId: "session-1", updatedAt: 15 }),
15,
scope.sessionKey,
);
expect(loadSessionEntry(scope)).not.toHaveProperty("createdBy");
await upsertSessionEntry(scope, { model: "sonnet-4.6", updatedAt: 20 });
expect(loadSessionEntry(scope)).toMatchObject({
@@ -156,7 +134,6 @@ describe("session accessor seam", () => {
sessionId: "session-1",
updatedAt: expect.any(Number),
});
expect(loadSessionEntry(scope)).not.toHaveProperty("createdBy");
});
it("lists retained transcript instances across same-key session rotation", async () => {
@@ -496,12 +473,48 @@ describe("session accessor seam", () => {
const recorded = await recordInboundSessionMeta({ storePath, sessionKey, ctx });
expect(recorded?.origin?.provider).toBe("webchat");
expect(recorded).toMatchObject({
createdVia: "channel",
createdActor: { type: "human", id: "webchat:user-1" },
createdAt: expect.any(Number),
});
const creationStamp = {
createdVia: recorded?.createdVia,
createdActor: recorded?.createdActor,
createdAt: recorded?.createdAt,
};
await recordInboundSessionMeta({
storePath,
sessionKey,
ctx: { ...ctx, From: "webchat:different-sender" },
});
expect(loadSessionEntry({ sessionKey, storePath })).toMatchObject(creationStamp);
// Detached result: caller mutations must never leak into cached store state.
if (recorded) {
recorded.origin = { provider: "mutated" };
}
expect(loadSessionEntry({ sessionKey, storePath })?.origin?.provider).toBe("webchat");
const operatorKey = "agent:main:dashboard:operator-created";
const operator = await recordInboundSessionMeta({
storePath,
sessionKey: operatorKey,
ctx: {
...ctx,
SessionKey: operatorKey,
SessionCreation: {
via: "operator",
actor: { type: "human", id: "profile-ada" },
},
},
});
expect(operator).toMatchObject({
createdVia: "operator",
createdActor: { type: "human", id: "profile-ada" },
createdAt: expect.any(Number),
});
});
it("does not create sessions when inbound meta recording opts out of upsert", async () => {
@@ -665,6 +665,10 @@ export type SessionMessageCutMutationResult =
export type SessionMessageCutMutationParams = {
agentId?: string;
creation?: {
via: import("./session-entry-provenance.js").SessionCreatedVia;
actor?: import("./session-entry-provenance.js").SessionCreatedActor;
};
entryId: string;
env?: NodeJS.ProcessEnv;
sessionKey: string;
@@ -1,6 +1,13 @@
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import type { SessionEntry } from "./types.js";
/** True when this entry's transcript began as a copy of a parent (actual forkSource ancestry or the legacy/thread-settled marker). */
export function sessionEntryForkedFromParent(
entry: Pick<SessionEntry, "forkedFromParent" | "forkSource"> | undefined,
): boolean {
return entry?.forkSource !== undefined || entry?.forkedFromParent === true;
}
export function preserveSqliteSameKeySessionRolloverLineage(params: {
next: SessionEntry;
previous: SessionEntry;
@@ -13,6 +20,7 @@ export function preserveSqliteSameKeySessionRolloverLineage(params: {
}
return {
...params.next,
previousSessionId,
usageFamilyKey:
params.next.usageFamilyKey ?? params.previous.usageFamilyKey ?? params.sessionKey,
usageFamilySessionIds: uniqueStrings([
@@ -1,5 +1,31 @@
import type { HookExternalContentSource } from "../../security/external-content.js";
/** Kept aligned with SessionStateActorType (src/sessions/session-state-event-kinds.ts); not imported to avoid layering config/sessions onto src/sessions. */
export type SessionCreatedActor = { type: "human" | "agent" | "system"; id?: string };
export type SessionCreatedVia =
| "operator" // gateway sessions.create (Control UI / operator clients)
| "spawn" // sessions_spawn native or ACP subagent spawn
| "channel" // inbound channel conversation materialization
| "cron"
| "talk"
| "run" // create-on-run materialization (agent-session-persist)
| "plugin" // trusted plugin runtime creation
| "internal"; // internal/hidden sessions (internal-session-effects, voice bare rows)
// Return shape mirrors the SessionEntry creation fields as a leaf contract;
// types.ts imports from here, never the reverse (madge cycle guard).
export function buildSessionCreationStamp(params: {
via: SessionCreatedVia;
actor?: SessionCreatedActor;
now?: number;
}): { createdVia: SessionCreatedVia; createdActor?: SessionCreatedActor; createdAt: number } {
return {
createdVia: params.via,
...(params.actor ? { createdActor: params.actor } : {}),
createdAt: params.now ?? Date.now(),
};
}
export type SessionEntryProvenance = {
/** Plugin id that owns this session through a trusted runtime creation seam. */
pluginOwnerId?: string;
@@ -74,7 +74,8 @@ function search(query: string, options: { limit?: number; sessionKeys?: string[]
async function waitForSearchReconcile(query: string): Promise<void> {
await vi.waitFor(() => expect(search(query).indexing).toBe(false), {
interval: 10,
timeout: 5_000,
// Compact CI shards can delay the asynchronous index worker beyond five seconds.
timeout: 15_000,
});
}
+81
View File
@@ -19,6 +19,7 @@ import {
import { evaluateSessionFreshness, resolveSessionResetPolicy } from "./reset.js";
import { mergeRestartRecoveryTerminalRunIds } from "./restart-recovery-state.js";
import { loadSessionEntry } from "./session-accessor.js";
import { buildSessionCreationStamp } from "./session-entry-provenance.js";
import { resolveAndPersistSessionFile } from "./session-file.js";
import { formatSqliteSessionFileMarker } from "./sqlite-marker.js";
import { readSessionStoreCache, writeSessionStoreCache } from "./store-cache.js";
@@ -453,6 +454,28 @@ describe("session store writer queue", () => {
expect(store["agent:main:array"]).toBeUndefined();
});
it("round-trips durable session creation and lineage fields", async () => {
const key = "agent:main:lineage-roundtrip";
const entry: SessionEntry = {
sessionId: "lineage-roundtrip-session",
updatedAt: 200,
createdVia: "operator",
createdActor: { type: "human", id: "profile-1" },
createdAt: 100,
forkSource: {
sessionKey: "agent:main:source",
sessionId: "source-session",
entryId: "source-entry",
},
previousSessionId: "previous-generation",
};
const { storePath } = await makeTmpStore();
await saveSessionStore(storePath, { [key]: entry }, { skipMaintenance: true });
expect(loadSessionStore(storePath, { skipCache: true })[key]).toEqual(entry);
});
it("strips malformed pending final-delivery fields on load", async () => {
const { storePath } = await makeTmpStore({
"agent:main:bad-pending": {
@@ -1117,6 +1140,64 @@ describe("session store writer queue", () => {
expect(merged.modelProvider).toBeUndefined();
});
it("builds a deterministic session creation stamp", () => {
expect(
buildSessionCreationStamp({
via: "spawn",
actor: { type: "agent", id: "agent:main:requester" },
now: 123,
}),
).toEqual({
createdVia: "spawn",
createdActor: { type: "agent", id: "agent:main:requester" },
createdAt: 123,
});
});
it("keeps session creation and fork ancestry fields write-once", () => {
const existing: SessionEntry = {
sessionId: "session-write-once",
updatedAt: 100,
createdVia: "channel",
createdActor: { type: "human", id: "sender-1" },
createdAt: 50,
forkSource: { sessionKey: "agent:main:parent", sessionId: "parent-session" },
};
expect(
mergeSessionEntry(existing, {
createdVia: undefined,
createdActor: { type: "system", id: "replacement" },
createdAt: 200,
forkSource: undefined,
}),
).toMatchObject({
createdVia: "channel",
createdActor: { type: "human", id: "sender-1" },
createdAt: 50,
forkSource: { sessionKey: "agent:main:parent", sessionId: "parent-session" },
});
});
it("fills absent session creation and fork ancestry fields", () => {
expect(
mergeSessionEntry(
{ sessionId: "session-fill", updatedAt: 100 },
{
createdVia: "internal",
createdActor: { type: "system" },
createdAt: 75,
forkSource: { sessionKey: "agent:main:source", sessionId: "source-session" },
},
),
).toMatchObject({
createdVia: "internal",
createdActor: { type: "system" },
createdAt: 75,
forkSource: { sessionKey: "agent:main:source", sessionId: "source-session" },
});
});
it("rewrites generated sessionFile paths when session id changes", () => {
const previousSessionId = "11111111-1111-4111-8111-111111111111";
const nextSessionId = "22222222-2222-4222-8222-222222222222";
+30 -5
View File
@@ -7,7 +7,6 @@ import type {
} from "@openclaw/acp-core/types";
import { normalizeOptionalString, type FastMode } from "@openclaw/normalization-core/string-coerce";
import type { SessionObserverDigest } from "../../../packages/gateway-protocol/src/schema/sessions.js";
import type { SessionCreatorIdentity } from "../../../packages/gateway-protocol/src/schema/sessions.js";
import type { SessionAgentStatus } from "../../../packages/gateway-protocol/src/session-icon.js";
import type { ChatType } from "../../channels/chat-type.js";
import type { ChannelId } from "../../channels/plugins/channel-id.types.js";
@@ -17,7 +16,11 @@ import type { DeliveryContext } from "../../utils/delivery-context.types.js";
import type { TtsAutoMode } from "../types.tts.js";
import type { MainRestartRecoveryState } from "./main-session-recovery.types.js";
import type { SessionRestartRecoveryState } from "./restart-recovery-types.js";
import type { SessionEntryProvenance } from "./session-entry-provenance.js";
import type {
SessionCreatedActor,
SessionCreatedVia,
SessionEntryProvenance,
} from "./session-entry-provenance.js";
import { rewriteSessionFileForNewSessionId } from "./session-file-rotation.js";
import type { AgentPatchedSessionModelFallback } from "./session-model-fallback.js";
@@ -252,8 +255,6 @@ export type SessionEntry = SessionRestartRecoveryState &
/** Durable one-shot prompt additions drained before the next agent turn. */
pluginNextTurnInjections?: Record<string, SessionPluginNextTurnInjection[]>;
sessionId: string;
/** Operator identity captured once for this session generation. */
createdBy?: SessionCreatorIdentity;
updatedAt: number;
/** Opaque owner revision used to reject stale lifecycle mutations. */
lifecycleRevision?: string;
@@ -294,7 +295,17 @@ export type SessionEntry = SessionRestartRecoveryState &
worktree?: { id: string; branch: string; repoRoot: string };
/** Explicit parent session linkage for dashboard-created child sessions. */
parentSessionKey?: string;
/** True after a thread/topic session has been forked from its parent transcript once. */
/** How this session node came to exist; written once and retained across sessionId rotations. */
createdVia?: SessionCreatedVia;
/** Actor that caused node creation, with an optional profile, session, or sender id; written once. */
createdActor?: SessionCreatedActor;
/** Node creation time (ms); unlike sessionStartedAt, survives sessionId rotations. */
createdAt?: number;
/** Exact source generation and optional cut entry for an actual transcript-copy fork. */
forkSource?: { sessionKey: string; sessionId: string; entryId?: string };
/** Session id of the prior transcript generation under this same session key. */
previousSessionId?: string;
/** Thread parent-seeding settled marker; also set when seeding is deliberately skipped. */
forkedFromParent?: boolean;
/** Subagent spawn depth (0 = main, 1 = sub-agent, 2 = sub-sub-agent). */
spawnDepth?: number;
@@ -671,6 +682,20 @@ function mergeSessionEntryWithPolicy(
(existing.sessionId === sessionId ? existing.sessionStartedAt : updatedAt),
};
// Node creation and exact fork ancestry are write-once; patches may only fill absent values.
if (existing.createdVia !== undefined) {
next.createdVia = existing.createdVia;
}
if (existing.createdActor !== undefined) {
next.createdActor = existing.createdActor;
}
if (existing.createdAt !== undefined) {
next.createdAt = existing.createdAt;
}
if (existing.forkSource !== undefined) {
next.forkSource = existing.forkSource;
}
if (existing.sessionId !== sessionId) {
// Session id rotations should move transcript paths when they match known reset/fork shapes.
const patchHasSessionFile = Object.hasOwn(patch, "sessionFile");
@@ -65,6 +65,9 @@ describe("createPersistCronSessionEntry", () => {
lifecycleRevision,
modelProvider: "claude-cli",
model: "claude-opus-4-8",
// Node-local lineage on the base row must not leak onto the :run: node.
previousSessionId: "base-prior-generation",
forkSource: { sessionKey: "agent:main:other", sessionId: "other-generation" },
}),
),
lifecycleRevision,
@@ -87,7 +90,12 @@ describe("createPersistCronSessionEntry", () => {
storePath: cronSession.storePath,
update: expect.any(Function),
});
expect(store[runSessionKey]?.previousSessionId).toBeUndefined();
expect(store[runSessionKey]?.forkSource).toBeUndefined();
expect(store[runSessionKey]).toMatchObject({
createdVia: "cron",
createdActor: { type: "system" },
createdAt: expect.any(Number),
sessionId: "run-session-id",
modelProvider: "claude-cli",
model: "claude-opus-4-8",
@@ -165,7 +173,8 @@ describe("createPersistCronSessionEntry", () => {
},
}),
);
const persistSessionEntry = vi.fn(async () => {});
const persistedStore: Record<string, SessionEntry> = {};
const persistSessionEntry = makeGuardedPersistSessionEntry(persistedStore);
const persist = createPersistCronSessionEntry({
cronSession,
@@ -175,12 +184,21 @@ describe("createPersistCronSessionEntry", () => {
await persist();
expect(cronSession.store["agent:main:cron:job"]).toBe(cronSession.sessionEntry);
expect(cronSession.store["agent:main:cron:job"]).toMatchObject({
createdVia: "cron",
createdActor: { type: "system" },
createdAt: expect.any(Number),
});
expect(persistedStore["agent:main:cron:job"]).toMatchObject({
createdVia: "cron",
createdActor: { type: "system" },
createdAt: expect.any(Number),
});
expect(cronSession.store["agent:main:cron:job:run:run-session-id"]).toBeUndefined();
expect(persistSessionEntry).toHaveBeenCalledWith({
storePath: "/tmp/sessions.json",
sessionKey: "agent:main:cron:job",
fallbackEntry: cronSession.sessionEntry,
fallbackEntry: expect.objectContaining({ sessionId: "run-session-id" }),
update: expect.any(Function),
});
});
@@ -3,6 +3,7 @@ import fs from "node:fs";
import { isDeepStrictEqual } from "node:util";
import type { LiveSessionModelSelection } from "../../agents/live-model-switch.js";
import type { SessionEntry } from "../../config/sessions.js";
import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js";
import { mergeSessionSnapshotChanges } from "../../config/sessions/session-snapshot-merge.js";
import { parseSqliteSessionFileMarker } from "../../config/sessions/sqlite-marker.js";
import { isCronSessionKey } from "../../sessions/session-key-utils.js";
@@ -112,6 +113,14 @@ export function createPersistCronSessionEntry(params: {
sessionKey: params.agentSessionKey,
fallbackEntry: persistedEntry,
update: (currentEntry) => {
if (!currentEntry) {
const creationStamp = buildSessionCreationStamp({
via: "cron",
actor: { type: "system" },
});
committedEntry = { ...persistedEntry, ...creationStamp };
mergedLiveEntry = { ...liveEntry, ...creationStamp };
}
const ownsCurrentRevision =
currentEntry?.lifecycleRevision === params.cronSession.lifecycleRevision;
const currentRevisionActive = Boolean(
@@ -189,6 +198,13 @@ export function createCronRunContinuationSession(params: {
entry?.cronRunContinuation?.lifecycleRevision === continuation.lifecycleRevision;
const persist = async (create: boolean, phase: "running" | "ready", basePersisted = false) => {
const source = structuredClone(params.cronSession.sessionEntry);
delete source.createdVia;
delete source.createdActor;
delete source.createdAt;
// Node-local lineage must not leak across keys: the base row's generation
// chain and fork ancestry describe the cron root, not this :run: node.
delete source.previousSessionId;
delete source.forkSource;
let persisted = false;
let alreadySealed = false;
await params.persistSessionEntry({
@@ -212,6 +228,9 @@ export function createCronRunContinuationSession(params: {
return {
...current,
...source,
...(!current
? buildSessionCreationStamp({ via: "cron", actor: { type: "system" } })
: {}),
...(params.thinkingLevel ? { thinkingLevel: params.thinkingLevel } : {}),
cronRunContinuation: {
...continuation,
+4 -1
View File
@@ -423,7 +423,10 @@ describe("method scope resolution", () => {
["sendPolicy", { key: "agent:main:ios-1", sendPolicy: "deny" }],
["inheritedToolAllow", { key: "agent:main:ios-1", inheritedToolAllow: ["exec"] }],
["inheritedToolPolicyVersion", { key: "agent:main:ios-1", inheritedToolPolicyVersion: 1 }],
["spawnedBy", { key: "agent:main:ios-1", spawnedBy: "agent:main:main" }],
[
"completionOwnerSessionKey",
{ key: "agent:main:ios-1", completionOwnerSessionKey: "agent:main:main" },
],
["mixed with safe fields", { key: "agent:main:ios-1", label: "x", execHost: "node-1" }],
["unknown fields", { key: "agent:main:ios-1", futureField: true }],
])("keeps sessions.patch admin-only when params include %s", (_name, params) => {
+2 -1
View File
@@ -15,6 +15,7 @@ import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "../auto-re
import { normalizeVerboseLevel } from "../auto-reply/thinking.js";
import { normalizeAgentPlanSteps } from "../channels/streaming.js";
import { getRuntimeConfig } from "../config/io.js";
import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js";
import {
type AgentEventPayload,
type AgentEventRuntimePayload,
@@ -550,7 +551,7 @@ export function createAgentEventHandler({
spawnedBy: row?.spawnedBy,
spawnedWorkspaceDir: row?.spawnedWorkspaceDir,
spawnedCwd: row?.spawnedCwd,
forkedFromParent: row?.forkedFromParent,
forkedFromParent: sessionEntryForkedFromParent(row ?? undefined) ? true : undefined,
spawnDepth: row?.spawnDepth,
subagentRole: row?.subagentRole,
subagentControlScope: row?.subagentControlScope,
@@ -21,8 +21,8 @@ import {
resolveBareSessionResetResult,
runSessionResetFromAgent,
} from "./agent-session-reset.js";
import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
import { emitSessionsChanged } from "./session-change-event.js";
import { resolveAgentRunSessionCreation } from "./session-creation-provenance.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
export type CommittedResetCompletion = {
@@ -97,7 +97,7 @@ export async function runAgentResetPhase(params: {
? { agentId: params.agentId }
: {}),
reason: resetReason,
createdBy: gatewayClientSessionCreator(params.client),
creation: resolveAgentRunSessionCreation(params.client),
assertCurrent: () => assertAgentRunLifecycleGenerationCurrent(params.lifecycleGeneration),
onCommitted: (commit) => {
params.setCommittedResetCompletion({
@@ -23,7 +23,7 @@ import { startAgentRunExecution } from "./agent-run-execution-phase.js";
import { buildAgentSessionPatch } from "./agent-session-patch.js";
import { persistAgentSessionPhase } from "./agent-session-persist.js";
import { prepareAgentSession } from "./agent-session-prepare.js";
import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
import { resolveAgentRunSessionCreation } from "./session-creation-provenance.js";
import type { GatewayRequestHandlers } from "./types.js";
export const agentRunHandler: GatewayRequestHandlers["agent"] = async ({
@@ -298,7 +298,6 @@ export const agentRunHandler: GatewayRequestHandlers["agent"] = async ({
freshEntry === undefined
? normalizeOptionalString(client?.internal?.pluginRuntimeOwnerId)
: undefined,
createdBy: gatewayClientSessionCreator(client),
expectedExistingSessionId,
hasRestoredCronContinuation: restoredCronContinuationIdentity !== undefined,
resetPolicy,
@@ -338,6 +337,7 @@ export const agentRunHandler: GatewayRequestHandlers["agent"] = async ({
canonicalSessionKey,
sessionAgentId,
mainSessionKey,
creation: resolveAgentRunSessionCreation(client),
lifecycleGeneration,
isRestartRecoveryResumeRun,
runId,
@@ -31,59 +31,6 @@ function buildPatch(touchInteraction: boolean) {
}
describe("agent session patch", () => {
it("stamps a creator only when minting a new session", () => {
const patch = buildAgentSessionPatch({
freshEntry: undefined,
initialEntry: undefined,
cfg: {},
sessionAgentId: "main",
canonicalSessionKey: "agent:main:new",
storePath: "/tmp/openclaw-agent-creator-test.json",
normalizedSpawned: {},
requestDeliveryHint: undefined,
createdBy: { id: "profile-ada", label: "Ada" },
hasRestoredCronContinuation: false,
resetPolicy: resolveSessionResetPolicy({ resetType: "direct" }),
now: 1_000,
isSystemGatewayRun: false,
visibleRequest: true,
fallbackSessionId: "new-session",
touchInteraction: true,
failedSessionTranscriptMissing: () => false,
}).patch;
expect(patch.createdBy).toEqual({ id: "profile-ada", label: "Ada" });
});
it("clears a previous creator on an ownerless implicit rotation", () => {
const entry: SessionEntry = {
createdBy: { id: "profile-ada", label: "Ada" },
sessionId: "old-session",
updatedAt: 1,
};
const patch = buildAgentSessionPatch({
freshEntry: entry,
initialEntry: entry,
cfg: {},
sessionAgentId: "main",
canonicalSessionKey: "agent:main:main",
storePath: "/tmp/openclaw-agent-creator-rotation.json",
normalizedSpawned: {},
requestDeliveryHint: undefined,
hasRestoredCronContinuation: false,
resetPolicy: resolveSessionResetPolicy({ resetType: "direct" }),
now: 2,
isSystemGatewayRun: false,
visibleRequest: true,
fallbackSessionId: "new-session",
touchInteraction: true,
failedSessionTranscriptMissing: () => true,
}).patch;
expect(Object.hasOwn(patch, "createdBy")).toBe(true);
expect(patch.createdBy).toBeUndefined();
});
it("clears agent status at the next human interaction boundary", () => {
const patch = buildPatch(true);
expect(Object.hasOwn(patch, "agentStatus")).toBe(true);
@@ -50,7 +50,6 @@ export function buildAgentSessionPatch(params: {
requestLabel?: string;
recipientChannel?: string;
pluginOwnerId?: string;
createdBy?: SessionEntry["createdBy"];
expectedExistingSessionId?: string;
hasRestoredCronContinuation: boolean;
resetPolicy: ReturnType<typeof import("../../config/sessions.js").resolveSessionResetPolicy>;
@@ -210,9 +209,6 @@ export function buildAgentSessionPatch(params: {
sessionId: patchSessionId,
updatedAt: params.now,
...(freshIsNewSession && !freshSessionRotatedSinceLoad ? { sessionStartedAt: params.now } : {}),
...(freshIsNewSession && !freshSessionRotatedSinceLoad
? { createdBy: params.createdBy ? { ...params.createdBy } : undefined }
: {}),
...(params.touchInteraction
? {
lastInteractionAt: params.now,
@@ -18,9 +18,11 @@ import {
patchSessionEntryTarget,
type SessionEntryPatchOptions,
} from "../../config/sessions/session-accessor.js";
import { buildSessionCreationStamp } from "../../config/sessions/session-entry-provenance.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { assertAgentRunLifecycleGenerationCurrent } from "../../infra/agent-events.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { recordSessionCreated } from "../../sessions/session-state-events.js";
import { getGeneratedMediaTaskIdsForSessionKey } from "../../tasks/task-status-access.js";
import { formatForLog } from "../ws-log.js";
import {
@@ -35,6 +37,7 @@ import {
} from "./agent-handler-helpers.js";
import type { AgentRunRequest } from "./agent-request-types.js";
import type { AgentSessionPatchBuild } from "./agent-session-patch.js";
import type { TrustedSessionCreation } from "./session-creation-provenance.js";
import type { GatewayRequestHandlerOptions } from "./types.js";
export type CronContinuationClaim = {
@@ -75,6 +78,7 @@ export async function persistAgentSessionPhase(params: {
canonicalSessionKey: string;
sessionAgentId: string;
mainSessionKey: string;
creation: TrustedSessionCreation;
lifecycleGeneration: string;
isRestartRecoveryResumeRun: boolean;
runId: string;
@@ -117,6 +121,7 @@ export async function persistAgentSessionPhase(params: {
let restoredCronContinuation: RestoredCronContinuation | undefined;
let mainRestartRecoveryOwnerLease: MainSessionRecoveryOwnerLease | undefined;
let skipAgentInitialSessionTouch = false;
let createdNewEntry = false;
const recoveredSessionStartedAt =
!patchBuild.isNewSession &&
params.entry !== undefined &&
@@ -246,12 +251,25 @@ export async function persistAgentSessionPhase(params: {
});
}
patchBuild = params.buildSessionPatch(entryForPatch);
const effectivePatch =
const lifecyclePatch =
recoveredSessionStartedAt !== undefined &&
entryForPatch?.sessionStartedAt === undefined &&
entryForPatch?.sessionId === params.entry?.sessionId
? { ...patchBuild.patch, sessionStartedAt: recoveredSessionStartedAt }
: patchBuild.patch;
const previousSessionId = normalizeOptionalString(freshEntry?.sessionId);
const nextSessionId = normalizeOptionalString(lifecyclePatch.sessionId);
const rotationLineage =
previousSessionId && nextSessionId && previousSessionId !== nextSessionId
? { previousSessionId }
: {};
const effectivePatch = freshEntry
? { ...lifecyclePatch, ...rotationLineage }
: {
...lifecyclePatch,
...buildSessionCreationStamp(params.creation),
};
createdNewEntry = freshEntry === undefined;
const merged = withSqliteSessionFileMarker({
agentId: params.sessionAgentId,
entry: mergeSessionEntry(entryForPatch, effectivePatch),
@@ -416,6 +434,13 @@ export async function persistAgentSessionPhase(params: {
const rotatedSessionId = patchBuild.rotatedSessionId;
const usableRequestedSessionId = patchBuild.usableRequestedSessionId;
const freshness = patchBuild.freshness;
if (createdNewEntry && sessionEntry) {
recordSessionCreated({
sessionKey: params.canonicalSessionKey,
agentId: params.sessionAgentId,
entry: sessionEntry,
});
}
if (isNewSession && params.entry?.sessionId && resolvedSessionId !== params.entry.sessionId) {
supersededSessionId = params.entry.sessionId;
}
@@ -1,5 +1,4 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { SessionCreatorIdentity } from "../../../packages/gateway-protocol/src/index.js";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
import type { AgentCommandOpts } from "../../agents/command/types.js";
import { agentCommandFromIngress } from "../../commands/agent.js";
@@ -14,13 +13,14 @@ import { defaultRuntime } from "../../runtime.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { performGatewaySessionReset } from "../session-reset-service.js";
import { loadSessionEntry } from "../session-utils.js";
import type { TrustedSessionCreation } from "./session-creation-provenance.js";
import type { GatewayRequestHandlerOptions, GatewayRequestHandlers } from "./types.js";
export async function runSessionResetFromAgent(params: {
key: string;
agentId?: string;
reason: "new" | "reset";
createdBy?: SessionCreatorIdentity;
creation: TrustedSessionCreation;
assertCurrent?: () => void;
onCommitted?: (commit: { key: string; sessionId: string }) => void;
}) {
@@ -29,7 +29,7 @@ export async function runSessionResetFromAgent(params: {
...(params.agentId ? { agentId: params.agentId } : {}),
reason: params.reason,
commandSource: "gateway:agent",
createdBy: params.createdBy,
creation: params.creation,
assertCurrent: params.assertCurrent,
onCommitted: params.onCommitted,
});
@@ -163,6 +163,12 @@ describe("prepareChatSendUserTurn", () => {
}),
client: {
connId: "conn-1",
authenticatedUserProfile: {
profileId: "profile-ada",
displayName: "Ada",
hasAvatar: false,
updatedAt: 1,
},
connect: {
device: { id: "device-1" },
scopes: ["operator.admin"],
@@ -190,6 +196,10 @@ describe("prepareChatSendUserTurn", () => {
MediaStaged: true,
GatewayClientScopes: ["operator.admin"],
GatewayClientCaps: ["tool-events"],
SessionCreation: {
via: "operator",
actor: { type: "human", id: "profile-ada" },
},
});
expect(prepared.ctx).not.toHaveProperty("SenderId");
expect(prepared.queuedFollowupOwnerKey).toBe("device:device-1");
@@ -17,7 +17,7 @@ import type { prepareChatSendAttachments } from "./chat-send-attachments.js";
import type { NormalizedChatSendRequest } from "./chat-send-request.js";
import type { PreparedChatSendSession } from "./chat-send-session.js";
import { normalizeOptionalChatText } from "./chat-text-normalization.js";
import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
import { resolveOperatorSessionCreation } from "./session-creation-provenance.js";
import type { GatewayRequestContext, GatewayRequestHandlerOptions } from "./types.js";
type PreparedChatSendAttachments = Extract<
@@ -180,9 +180,7 @@ function buildChatSendMessageContext(params: {
body: commandBody,
},
MessageSid: params.clientRunId,
...(gatewayClientSessionCreator(params.client)
? { SessionCreator: gatewayClientSessionCreator(params.client) }
: {}),
SessionCreation: resolveOperatorSessionCreation(params.client),
ApprovalReviewerDeviceId: queuedFollowupOwnerDeviceId,
...(!isOperatorUiClient(params.clientInfo)
? {
@@ -17,8 +17,3 @@ export function gatewayClientSenderFields(client: GatewayClient | null): {
}
return client?.authenticatedUserId ? { sender: { id: client.authenticatedUserId } } : {};
}
/** Returns the trusted creator identity captured during connection admission. */
export function gatewayClientSessionCreator(client: GatewayClient | null) {
return client?.operatorIdentity ? { ...client.operatorIdentity } : undefined;
}
@@ -4,15 +4,15 @@ import { gatewaySubagentState } from "../../plugins/runtime/gateway-bindings.js"
import { createPluginRuntime } from "../../plugins/runtime/index.js";
import type { SessionCatalogProvider } from "../../plugins/session-catalog.js";
type CatalogSessionEntryLoader = (
type CatalogSessionRowLoader = (
sessionKey: string,
options?: { agentId?: string; clone?: boolean },
) => { entry: { createdBy?: { id: string; label?: string } } | undefined };
) => { createdActor?: { type: "human" | "agent" | "system"; id?: string; label?: string } } | null;
const hoisted = vi.hoisted(() => ({
activeRegistry: { sessionCatalogs: [] as unknown[] },
pinnedSessionExtensionRegistry: undefined as { sessionCatalogs: unknown[] } | undefined,
loadSessionEntryReadOnly: vi.fn<CatalogSessionEntryLoader>(() => ({ entry: undefined })),
loadGatewaySessionRow: vi.fn<CatalogSessionRowLoader>(() => null),
recordSessionStateEvent: vi.fn(),
upsertSessionUpstreamLink: vi.fn(),
}));
@@ -41,7 +41,7 @@ vi.mock("../../plugins/session-conversation-binding.js", () => ({
}));
vi.mock("../session-utils.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../session-utils.js")>();
return { ...actual, loadSessionEntryReadOnly: hoisted.loadSessionEntryReadOnly };
return { ...actual, loadGatewaySessionRow: hoisted.loadGatewaySessionRow };
});
const { resolveSessionCatalogCreateTarget, sessionCatalogHandlers } =
@@ -81,8 +81,8 @@ describe("session catalog Gateway methods", () => {
beforeEach(() => {
hoisted.activeRegistry.sessionCatalogs = [];
hoisted.pinnedSessionExtensionRegistry = undefined;
hoisted.loadSessionEntryReadOnly.mockReset();
hoisted.loadSessionEntryReadOnly.mockReturnValue({ entry: undefined });
hoisted.loadGatewaySessionRow.mockReset();
hoisted.loadGatewaySessionRow.mockReturnValue(null);
hoisted.recordSessionStateEvent.mockClear();
hoisted.upsertSessionUpstreamLink.mockClear();
conversationBindingMocks.bindPluginSessionConversation.mockClear();
@@ -169,7 +169,7 @@ describe("session catalog Gateway methods", () => {
status: "stored",
archived: false,
sessionKey: "agent:main:owned",
createdBy: { id: "provider-spoof" },
createdActor: { type: "human" as const, id: "provider-spoof" },
canContinue: true,
canArchive: false,
},
@@ -178,7 +178,7 @@ describe("session catalog Gateway methods", () => {
status: "stored",
archived: false,
sessionKey: "agent:main:missing",
createdBy: { id: "provider-spoof" },
createdActor: { type: "human" as const, id: "provider-spoof" },
canContinue: true,
canArchive: false,
},
@@ -186,18 +186,17 @@ describe("session catalog Gateway methods", () => {
threadId: "external-thread",
status: "stored",
archived: false,
createdBy: { id: "provider-spoof" },
createdActor: { type: "human" as const, id: "provider-spoof" },
canContinue: true,
canArchive: false,
},
],
};
hoisted.loadSessionEntryReadOnly.mockImplementation((sessionKey: string) => ({
entry:
sessionKey === "agent:main:owned"
? { createdBy: { id: "profile-ada", label: "Ada" } }
: undefined,
}));
hoisted.loadGatewaySessionRow.mockImplementation((sessionKey: string) =>
sessionKey === "agent:main:owned"
? { createdActor: { type: "human", id: "profile-ada", label: "Ada" } }
: null,
);
hoisted.activeRegistry.sessionCatalogs = [
{
provider: provider("claude", {
@@ -219,10 +218,10 @@ describe("session catalog Gateway methods", () => {
const projectedSessions = [
expect.objectContaining({
threadId: "owned-thread",
createdBy: { id: "profile-ada", label: "Ada" },
createdActor: { type: "human", id: "profile-ada", label: "Ada" },
}),
expect.not.objectContaining({ createdBy: expect.anything() }),
expect.not.objectContaining({ createdBy: expect.anything() }),
expect.not.objectContaining({ createdActor: expect.anything() }),
expect.not.objectContaining({ createdActor: expect.anything() }),
];
expect(broadcastToConnIds).toHaveBeenCalledWith(
@@ -242,10 +241,10 @@ describe("session catalog Gateway methods", () => {
}),
],
});
expect(hoisted.loadSessionEntryReadOnly).toHaveBeenCalledWith("agent:main:owned", {
expect(hoisted.loadGatewaySessionRow).toHaveBeenCalledWith("agent:main:owned", {
agentId: "main",
});
expect(hoisted.loadSessionEntryReadOnly).toHaveBeenCalledTimes(2);
expect(hoisted.loadGatewaySessionRow).toHaveBeenCalledTimes(2);
});
it("uses the pinned Gateway catalog runtime after active registry churn", async () => {
+20 -14
View File
@@ -24,7 +24,7 @@ import { bindPluginSessionConversation } from "../../plugins/session-conversatio
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import { recordSessionStateEvent } from "../../sessions/session-state-events.js";
import { upsertSessionUpstreamLink } from "../../sessions/session-upstream-links.js";
import { loadSessionEntryReadOnly } from "../session-utils.js";
import { loadGatewaySessionRow } from "../session-utils.js";
import { resolveAgentIdOrRespondError } from "./agent-id-shared.js";
import type { GatewayRequestHandlers, RespondFn } from "./types.js";
import { assertValidParams } from "./validation.js";
@@ -158,28 +158,28 @@ function catalogResult(
return result;
}
function projectCatalogHostCreators(
function projectCatalogHostCreatedActors(
host: SessionCatalogHost,
agentId: string,
creatorBySessionKey: Map<string, SessionCatalogSession["createdBy"]>,
actorBySessionKey: Map<string, SessionCatalogSession["createdActor"]>,
): SessionCatalogHost {
return {
...host,
sessions: host.sessions.map(({ createdBy: _providerCreatedBy, ...session }) => {
sessions: host.sessions.map(({ createdActor: _providerCreatedActor, ...session }) => {
// Catalog providers do not own creator identity; the persisted session entry does.
const sessionKey = session.sessionKey;
let createdBy: SessionCatalogSession["createdBy"];
if (sessionKey && creatorBySessionKey.has(sessionKey)) {
createdBy = creatorBySessionKey.get(sessionKey);
let createdActor: SessionCatalogSession["createdActor"];
if (sessionKey && actorBySessionKey.has(sessionKey)) {
createdActor = actorBySessionKey.get(sessionKey);
} else {
createdBy = sessionKey
? loadSessionEntryReadOnly(sessionKey, { agentId }).entry?.createdBy
createdActor = sessionKey
? loadGatewaySessionRow(sessionKey, { agentId })?.createdActor
: undefined;
if (sessionKey) {
creatorBySessionKey.set(sessionKey, createdBy);
actorBySessionKey.set(sessionKey, createdActor);
}
}
return createdBy ? { ...session, createdBy: { ...createdBy } } : session;
return createdActor ? { ...session, createdActor: { ...createdActor } } : session;
}),
};
}
@@ -228,7 +228,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
const search = normalizeSessionCatalogSearch(request.search);
const progressId = request.progressId;
const progressConnId = progressId && client?.connId ? client.connId : undefined;
const creatorBySessionKey = new Map<string, SessionCatalogSession["createdBy"]>();
const actorBySessionKey = new Map<string, SessionCatalogSession["createdActor"]>();
const catalogList = await Promise.all(
selected.map(async (provider): Promise<SessionCatalog> => {
const createTarget = resolveProviderCreateTarget(provider, resolvedAgent.agentId);
@@ -244,7 +244,13 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
agentId: resolvedAgent.agentId,
catalog: catalogResult(
provider,
[projectCatalogHostCreators(host, resolvedAgent.agentId, creatorBySessionKey)],
[
projectCatalogHostCreatedActors(
host,
resolvedAgent.agentId,
actorBySessionKey,
),
],
undefined,
createSession,
),
@@ -265,7 +271,7 @@ export const sessionCatalogHandlers: GatewayRequestHandlers = {
return catalogResult(
provider,
hosts.map((host) =>
projectCatalogHostCreators(host, resolvedAgent.agentId, creatorBySessionKey),
projectCatalogHostCreatedActors(host, resolvedAgent.agentId, actorBySessionKey),
),
undefined,
createSession,
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { resolveAgentRunSessionCreation } from "./session-creation-provenance.js";
describe("agent run session creation provenance", () => {
it("uses a proven Gateway profile id", () => {
expect(
resolveAgentRunSessionCreation({
authenticatedUserProfile: { profileId: "profile-ada" },
}),
).toEqual({ via: "run", actor: { type: "human", id: "profile-ada" } });
});
it("does not infer an actor for a profile-less wire client", () => {
expect(resolveAgentRunSessionCreation({})).toEqual({ via: "run" });
});
});
@@ -0,0 +1,42 @@
import type {
SessionCreatedActor,
SessionCreatedVia,
} from "../../config/sessions/session-entry-provenance.js";
export type TrustedSessionCreation = {
via: SessionCreatedVia;
actor?: SessionCreatedActor;
};
/**
* Structural subset of GatewayClient; a leaf contract so shared-types.ts can
* import TrustedSessionCreation without a type cycle back through this module.
*/
type SessionCreationClient = {
authenticatedUserProfile?: { profileId?: string } | null;
internal?: { syntheticClient?: true; sessionCreation?: TrustedSessionCreation };
};
export function resolveOperatorSessionCreation(
client: SessionCreationClient | null | undefined,
options: { allowTrustedHint?: boolean } = {},
): TrustedSessionCreation {
if (options.allowTrustedHint && client?.internal?.sessionCreation) {
return client.internal.sessionCreation;
}
const profileId = client?.authenticatedUserProfile?.profileId;
// Actor only when proven: a profile-less wire connection may be an agent-tool
// client on a remote topology, so claiming a human actor would misattribute
// agent-caused creations. Absent actor means unknown, never inferred.
return {
via: "operator",
...(profileId ? { actor: { type: "human" as const, id: profileId } } : {}),
};
}
export function resolveAgentRunSessionCreation(
client: SessionCreationClient | null | undefined,
): TrustedSessionCreation {
const actor = resolveOperatorSessionCreation(client).actor;
return { via: "run", ...(actor ? { actor } : {}) };
}
@@ -14,6 +14,7 @@ import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status
import { insideGitCheckout } from "../../agents/worktrees/git.js";
import { managedWorktrees } from "../../agents/worktrees/service.js";
import { resolveAgentMainSessionKey } from "../../config/sessions/main-session.js";
import { sessionEntryForkedFromParent } from "../../config/sessions/session-entry-lineage.js";
import { formatErrorMessage } from "../../infra/errors.js";
import { isPathInside } from "../../infra/path-guards.js";
import { normalizeAgentId, parseAgentSessionKey } from "../../routing/session-key.js";
@@ -28,13 +29,13 @@ import { resolveSessionStoreAgentId } from "../session-store-key.js";
import { readSessionMessageCountAsync } from "../session-transcript-readers.js";
import { loadSessionEntryReadOnly, resolveGatewaySessionStoreTarget } from "../session-utils.js";
import { chatHandlers } from "./chat.js";
import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
import { resolveSessionCatalogCreateTarget } from "./session-catalog.js";
import { emitSessionsChanged } from "./session-change-event.js";
import {
resolveSessionCreateInitialTurn,
shouldAttachPendingMessageSeq,
} from "./session-create-initial-turn.js";
import { resolveOperatorSessionCreation } from "./session-creation-provenance.js";
import { sessionLog } from "./sessions-shared.js";
import type { GatewayRequestHandlers } from "./types.js";
import { assertValidParams } from "./validation.js";
@@ -329,7 +330,6 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
).allowed;
const created = await createGatewaySession({
cfg,
createdBy: gatewayClientSessionCreator(client),
key: sessionKey,
agentId: sessionAgentId,
label: p.label,
@@ -356,6 +356,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
emitCommandHooks: p.emitCommandHooks,
resetMainWhenUnspecified: !hasInitialTurn,
commandSource: "webchat",
creation: resolveOperatorSessionCreation(client, { allowTrustedHint: true }),
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
afterCreate: hasInitialTurn
? async ({ key, agentId, entry, storePath }) => {
@@ -432,6 +433,9 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
branch: sessionWorktree.branch,
}
: undefined;
const responseEntry = sessionEntryForkedFromParent(created.entry)
? { ...created.entry, forkedFromParent: true as const }
: created.entry;
if (created.resetExisting) {
respond(
true,
@@ -439,7 +443,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
ok: true,
key: created.key,
sessionId: created.entry.sessionId,
entry: created.entry,
entry: responseEntry,
resolved: created.resolved,
runStarted: false,
...(createdWorktree ? { worktree: createdWorktree } : {}),
@@ -467,7 +471,7 @@ export const sessionCreateHandlers: GatewayRequestHandlers = {
ok: true,
key: created.key,
sessionId: created.entry.sessionId,
entry: created.entry,
entry: responseEntry,
runStarted,
...(runPayload ? runPayload : {}),
...(runStarted && typeof messageSeq === "number" ? { messageSeq } : {}),
@@ -34,9 +34,9 @@ import {
type SessionsPatchResult,
} from "../session-utils.js";
import { projectSessionsPatchEntry } from "../sessions-patch.js";
import { gatewayClientSessionCreator } from "./gateway-client-identity.js";
import { hasVisibleActiveSessionRun } from "./session-active-runs.js";
import { emitSessionsChanged } from "./session-change-event.js";
import { resolveOperatorSessionCreation } from "./session-creation-provenance.js";
import {
isAgentMainSessionKey,
loadSessionsRuntimeModule,
@@ -400,7 +400,7 @@ export const sessionMutationHandlers: GatewayRequestHandlers = {
...(p.agentId ? { agentId: p.agentId } : {}),
reason,
commandSource: "gateway:sessions.reset",
createdBy: gatewayClientSessionCreator(client),
creation: resolveOperatorSessionCreation(client),
});
if (!result.ok) {
respond(false, undefined, result.error);
@@ -58,9 +58,12 @@ import {
appendTranscriptEvent,
appendTranscriptMessage,
listSessionEntries,
loadSessionEntry,
upsertSessionEntry,
} from "../../config/sessions/session-accessor.js";
import { listSessionStateEventsSince } from "../../sessions/session-state-events.js";
import { sessionsHandlers } from "./sessions.js";
import type { GatewayClient } from "./types.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const sessionKey = "agent:main:rewind-handler";
@@ -140,8 +143,12 @@ type MessageCutMethod =
| "sessions.fork"
| "sessions.rewind";
async function invoke(method: MessageCutMethod, entryId?: string) {
const respond = vi.fn() as unknown as RespondFn;
async function invoke(
method: MessageCutMethod,
entryId?: string,
client: GatewayClient | null = null,
) {
const respond = vi.fn();
await expectDefined(
sessionsHandlers[method],
`${method} handler`,
@@ -155,9 +162,9 @@ async function invoke(method: MessageCutMethod, entryId?: string) {
? {}
: { entryId }),
},
respond,
respond: respond as unknown as RespondFn,
context: context(),
client: null,
client,
isWebchatConnect: () => false,
});
return respond;
@@ -209,12 +216,36 @@ describe("session message-cut methods", () => {
});
it("returns editor text for rewind and a new key for fork", async () => {
const fork = await invoke("sessions.fork", "user-entry");
const profileId = "profile-fork-creator";
const fork = await invoke("sessions.fork", "user-entry", {
connect: { scopes: ["operator.write"] },
authenticatedUserProfile: {
profileId,
displayName: "Fork Operator",
hasAvatar: false,
updatedAt: 1,
},
} as GatewayClient);
expect(fork).toHaveBeenCalledWith(
true,
expect.objectContaining({ editorText: "edit me", sessionKey: expect.any(String) }),
undefined,
);
const forkKey = (fork.mock.calls[0]?.[1] as { sessionKey?: string } | undefined)?.sessionKey;
expect(forkKey).toBeTruthy();
const forkEntry = loadSessionEntry({ agentId: "main", sessionKey: forkKey ?? "" });
expect(forkEntry).toMatchObject({
createdVia: "operator",
createdActor: { type: "human", id: profileId },
createdAt: expect.any(Number),
});
expect(listSessionStateEventsSince(forkKey ?? "", "main", 0, 20).events).toContainEqual(
expect.objectContaining({
kind: "created",
actorType: "human",
actorId: profileId,
}),
);
const rewind = await invoke("sessions.rewind", "user-entry");
expect(rewind).toHaveBeenCalledWith(true, { editorText: "edit me" }, undefined);
+10 -1
View File
@@ -22,6 +22,7 @@ import {
isCompetingSessionWorkAdmissionActive,
runExclusiveSessionLifecycleMutation,
} from "../../sessions/session-lifecycle-admission.js";
import { recordSessionCreated } from "../../sessions/session-state-events.js";
import {
readSessionUpstreamLink,
type SessionUpstreamLink,
@@ -33,6 +34,7 @@ import {
import { asWorkerInferenceControl } from "../worker-environments/inference-control.js";
import { hasVisibleActiveSessionRun } from "./session-active-runs.js";
import { emitSessionsChanged } from "./session-change-event.js";
import { resolveOperatorSessionCreation } from "./session-creation-provenance.js";
import {
loadAccessorSessionEntryForGatewayTarget,
resolveSessionWorkerPlacementMutationError,
@@ -155,7 +157,7 @@ async function mutateSessionAtMessage(
options: GatewayRequestHandlerOptions,
action: MessageCutAction,
): Promise<void> {
const { params, respond, context } = options;
const { params, respond, context, client } = options;
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey.trim() : "";
const entryId =
action === "switch"
@@ -385,6 +387,7 @@ async function mutateSessionAtMessage(
sessionStoreKey: current.sessionStoreKey,
storePath: current.storePath,
targetKey,
creation: resolveOperatorSessionCreation(client),
})
: action === "rewind"
? rewindSessionToMessage({
@@ -415,6 +418,12 @@ async function mutateSessionAtMessage(
}
if (action !== "fork") {
clearSessionQueues(lifecycleIdentities);
} else {
recordSessionCreated({
sessionKey: result.key,
agentId: current.target.agentId,
entry: result.entry,
});
}
respond(
true,
+5 -3
View File
@@ -1,5 +1,4 @@
import type {
SessionCreatorIdentity,
SessionApprovalReplay,
SystemAgentChatQuestion,
} from "../../../packages/gateway-protocol/src/index.js";
@@ -58,6 +57,7 @@ import type {
WorkerEnvironmentServiceContract,
WorkerPlacementDispatchContract,
} from "../worker-environments/service-contract.js";
import type { TrustedSessionCreation } from "./session-creation-provenance.js";
/**
* Shared gateway request types used by every server-method module.
@@ -78,8 +78,6 @@ export type GatewayClient = {
hasAvatar: boolean;
updatedAt: number;
};
/** Trusted operator identity resolved once during connection admission. */
operatorIdentity?: SessionCreatorIdentity;
pluginSurfaceUrls?: Record<string, string>;
pluginNodeCapabilitySurfaces?: Record<string, PluginNodeCapabilitySurface>;
pluginNodeCapabilities?: Record<string, { capability: string; expiresAtMs: number }>;
@@ -89,6 +87,10 @@ export type GatewayClient = {
/** Signed shared-auth session admitted only to approve its own upgrade pairing. */
isControlUiDeviceAuthMigration?: boolean;
internal?: {
/** Marks the server-constructed client used by trusted in-process dispatch. */
syntheticClient?: true;
/** Trusted session creation provenance; never accepted from Gateway wire params. */
sessionCreation?: TrustedSessionCreation;
allowModelOverride?: boolean;
approvalRuntime?: boolean;
cronRunContinuation?: boolean;
@@ -10,6 +10,7 @@ import { normalizeToolName } from "../agents/tool-policy.js";
import { getActivePluginRegistry } from "../plugins/runtime.js";
import type { RuntimePluginToolGrant } from "../plugins/runtime/tool-grant.js";
import { APPROVALS_SCOPE, WRITE_SCOPE } from "./method-scopes.js";
import type { TrustedSessionCreation } from "./server-methods/session-creation-provenance.js";
import type { GatewayRequestOptions } from "./server-methods/types.js";
export function createSyntheticPluginRuntimeClient(params?: {
@@ -21,6 +22,7 @@ export function createSyntheticPluginRuntimeClient(params?: {
pluginRuntimeOwnerId?: string;
runtimePluginToolGrant?: RuntimePluginToolGrant;
delegatedToolPolicyHandoff?: boolean;
sessionCreation?: TrustedSessionCreation;
scopes?: string[];
}): NonNullable<GatewayRequestOptions["client"]> {
const pluginRuntimeOwnerId =
@@ -41,6 +43,8 @@ export function createSyntheticPluginRuntimeClient(params?: {
scopes: params?.scopes ?? [WRITE_SCOPE],
},
internal: {
syntheticClient: true,
...(params?.sessionCreation ? { sessionCreation: params.sessionCreation } : {}),
allowModelOverride: params?.allowModelOverride === true,
...(params?.agentRunTracking ? { agentRunTracking: params.agentRunTracking } : {}),
...(params?.cronRunContinuation === true ? { cronRunContinuation: true } : {}),
+3
View File
@@ -28,6 +28,7 @@ import {
type GatewayMethodDispatchResponse,
unwrapGatewayMethodDispatchResponse,
} from "./server-in-process-dispatch.js";
import type { TrustedSessionCreation } from "./server-methods/session-creation-provenance.js";
import type {
GatewayRequestContext,
GatewayRequestHandler,
@@ -247,6 +248,7 @@ type DispatchGatewayMethodInProcessOptions = {
pluginRuntimeOwnerId?: string;
runtimePluginToolGrant?: RuntimePluginToolGrant;
delegatedToolPolicyHandoff?: boolean;
sessionCreation?: TrustedSessionCreation;
requireScopedClient?: boolean;
syntheticScopes?: string[];
timeoutMs?: number;
@@ -288,6 +290,7 @@ export async function dispatchGatewayMethodInProcessRaw(
? { runtimePluginToolGrant: options.runtimePluginToolGrant }
: {}),
delegatedToolPolicyHandoff: options?.delegatedToolPolicyHandoff === true,
...(options?.sessionCreation ? { sessionCreation: options.sessionCreation } : {}),
scopes: options?.syntheticScopes,
});
const scopedClient = mergePluginRuntimeClientInternal(
+1 -1
View File
@@ -93,7 +93,7 @@ function buildGatewaySessionSnapshot(params: {
const session = params.includeSession
? {
...buildGatewaySessionEventRow(sessionRow),
createdBy: sessionRow.createdBy ?? null,
createdActor: sessionRow.createdActor ?? null,
thinkingLevel: sessionRow.thinkingLevel ?? null,
}
: undefined;
@@ -367,6 +367,53 @@ describe("gateway server agent", () => {
expect(persisted?.spawnedBy).toBe("agent:main:main");
});
test("agent stamps create-on-run session rows without guessing an actor", async () => {
await setTestSessionStore({ entries: {} });
const sessionKey = "agent:main:dashboard:create-on-run";
const res = await rpcReq(gatewaySuite.ws, "agent", {
message: "hi",
sessionKey,
idempotencyKey: "idem-agent-create-on-run",
});
expect(res.ok).toBe(true);
await waitForAgentCommandCall("idem-agent-create-on-run");
expect(
loadSessionEntry({ sessionKey, storePath: gatewaySuite.sessionStorePath }),
).toMatchObject({
createdVia: "run",
createdAt: expect.any(Number),
});
expect(
loadSessionEntry({ sessionKey, storePath: gatewaySuite.sessionStorePath })?.createdActor,
).toBeUndefined();
});
test("agent links the previous generation when a missing transcript rotates the session", async () => {
const sessionKey = "agent:main:dashboard:rotate-missing-transcript";
await setTestSessionStore({
entries: {
[sessionKey]: {
sessionId: "missing-transcript-generation",
status: "failed",
updatedAt: Date.now(),
},
},
});
const res = await rpcReq(gatewaySuite.ws, "agent", {
message: "continue",
sessionKey,
idempotencyKey: "idem-agent-rotate-missing-transcript",
});
expect(res.ok).toBe(true);
await waitForAgentCommandCall("idem-agent-rotate-missing-transcript");
const persisted = loadSessionEntry({ sessionKey, storePath: gatewaySuite.sessionStorePath });
expect(persisted?.sessionId).not.toBe("missing-transcript-generation");
expect(persisted?.previousSessionId).toBe("missing-transcript-generation");
});
test("agent derives sessionKey from agentId", async () => {
testState.agentsConfig = { list: [{ id: "ops" }] };
await setTestSessionStore({
@@ -224,10 +224,10 @@ describe("subagent session deliveryContext from spawn request params", () => {
});
});
test("pre-patched subagent session (via sessions.patch) inherits deliveryContext from agent request", async () => {
// Simulates the real subagent spawn flow: spawnSubagentDirect calls sessions.patch
// first (to set spawnDepth, spawnedBy, etc.), then calls callSubagentGateway({method: "agent"}).
// The sessions.patch creates a partial entry without deliveryContext.
test("pre-created subagent session inherits deliveryContext from agent request", async () => {
// Simulates the real subagent spawn flow: the trusted session accessor persists lineage
// before callSubagentGateway({method: "agent"}) seeds the delivery context.
// The direct lineage write creates a partial entry without deliveryContext.
// The agent handler must seed deliveryContext from the request params.
await prepareSessionStore({
"agent:main:subagent:pre-patched": {
+204 -46
View File
@@ -15,6 +15,7 @@ import { managedWorktrees } from "../agents/worktrees/service.js";
import { loadSessionEntry, loadTranscriptEvents } from "../config/sessions/session-accessor.js";
import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { listSessionStateEventsSince } from "../sessions/session-state-events.js";
import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js";
import {
agentCommand,
@@ -73,51 +74,6 @@ function requireNonEmptyString(value: string | undefined, label: string): string
return value;
}
test("sessions.create stamps the trusted creator and preserves it until reset", async () => {
await createSessionStoreDir();
const adaClient = {
operatorIdentity: { id: "profile-ada", label: "Ada Lovelace" },
connect: { scopes: ["operator.admin"] },
} as never;
const bobClient = {
operatorIdentity: { id: "profile-bob", label: "Bob Hopper" },
connect: { scopes: ["operator.admin"] },
} as never;
const created = await directSessionReq<{
key: string;
entry: { createdBy?: { id: string; label?: string } };
}>("sessions.create", { agentId: "main" }, { client: adaClient });
expect(created.ok).toBe(true);
expect(created.payload?.entry.createdBy).toEqual({
id: "profile-ada",
label: "Ada Lovelace",
});
const key = requireNonEmptyString(created.payload?.key, "created session key");
const reused = await directSessionReq<{ entry: { createdBy?: { id: string } } }>(
"sessions.create",
{ agentId: "main", key },
{ client: bobClient },
);
expect(reused.payload?.entry.createdBy?.id).toBe("profile-ada");
const listed = await directSessionReq<{
sessions: Array<{ key: string; createdBy?: { id: string; label?: string } }>;
}>("sessions.list", { agentId: "main" });
expect(listed.payload?.sessions.find((row) => row.key === key)?.createdBy).toEqual({
id: "profile-ada",
label: "Ada Lovelace",
});
const reset = await directSessionReq<{ entry: { createdBy?: { id: string; label?: string } } }>(
"sessions.reset",
{ agentId: "main", key },
{ client: bobClient },
);
expect(reset.payload?.entry.createdBy).toEqual({ id: "profile-bob", label: "Bob Hopper" });
});
test("sessions.create provisions and reuses a session worktree for later runs", async () => {
const root = await fs.mkdtemp(
path.join(await fs.realpath(os.tmpdir()), "openclaw-session-worktree-"),
@@ -1488,6 +1444,190 @@ test("sessions.create preserves write-scoped fresh keyed model selection but gat
});
});
test("sessions.create stamps trusted operator provenance and records created", async () => {
await createSessionStoreDir();
const profileId = "profile-session-creator";
const created = await directSessionReq<{
key?: string;
entry?: {
createdVia?: string;
createdActor?: { type: string; id?: string };
createdAt?: number;
};
}>(
"sessions.create",
{ agentId: "main" },
{
client: {
connect: { scopes: ["operator.write"] },
authenticatedUserProfile: {
profileId,
displayName: "Test Operator",
hasAvatar: false,
updatedAt: 1,
},
} as never,
},
);
expect(created.ok).toBe(true);
expect(created.payload?.entry).toMatchObject({
createdVia: "operator",
createdActor: { type: "human", id: profileId },
createdAt: expect.any(Number),
});
const key = requireNonEmptyString(created.payload?.key, "created session key");
expect(listSessionStateEventsSince(key, "main", 0, 20).events).toContainEqual(
expect.objectContaining({
kind: "created",
actorType: "human",
actorId: profileId,
summary: "session created",
}),
);
const synthetic = await directSessionReq<{
entry?: { createdVia?: string; createdActor?: unknown; createdAt?: number };
}>(
"sessions.create",
{ agentId: "main" },
{
client: {
connect: { scopes: ["operator.write"] },
internal: { syntheticClient: true },
} as never,
},
);
expect(synthetic.payload?.entry).toMatchObject({
createdVia: "operator",
createdAt: expect.any(Number),
});
expect(synthetic.payload?.entry?.createdActor).toBeUndefined();
const hinted = await directSessionReq<{
entry?: { createdVia?: string; createdActor?: unknown };
}>(
"sessions.create",
{ agentId: "main" },
{
client: {
connect: { scopes: ["operator.write"] },
internal: {
syntheticClient: true,
sessionCreation: {
via: "spawn",
actor: { type: "agent", id: "agent:main:main" },
},
},
} as never,
},
);
expect(hinted.payload?.entry).toMatchObject({
createdVia: "spawn",
createdActor: { type: "agent", id: "agent:main:main" },
});
});
test("sessions.create reset-in-place preserves the node creation stamp", async () => {
testState.sessionConfig = { dmScope: "main" };
const { storePath } = await createSessionStoreDir();
await writeSessionStore({
entries: {
main: sessionStoreEntry("existing-main", {
createdVia: "channel",
createdActor: { type: "human", id: "telegram:42" },
createdAt: 1234,
}),
},
});
const reset = await directSessionReq<{ entry?: Record<string, unknown> }>(
"sessions.create",
{ agentId: "main", parentSessionKey: "main", emitCommandHooks: true },
{
client: {
connect: { scopes: ["operator.write"] },
authenticatedUserProfile: {
profileId: "profile-resetter",
displayName: null,
hasAvatar: false,
updatedAt: 1,
},
} as never,
},
);
expect(reset.ok).toBe(true);
expect(reset.payload?.entry).toMatchObject({
createdVia: "channel",
createdActor: { type: "human", id: "telegram:42" },
createdAt: 1234,
});
expect(loadSessionEntry({ sessionKey: "agent:main:main", storePath })).toMatchObject({
createdVia: "channel",
createdActor: { type: "human", id: "telegram:42" },
createdAt: 1234,
});
});
test("sessions.create adopting an existing key does not restamp node provenance", async () => {
const { storePath } = await createSessionStoreDir();
await writeSessionStore({
entries: {
"agent:main:dashboard:adopted": sessionStoreEntry("existing-adopted", {
createdVia: "spawn",
createdActor: { type: "agent", id: "agent:main:main" },
createdAt: 4321,
}),
},
});
const { chatHandlers } = await import("./server-methods/chat.js");
const chatSend = vi.spyOn(chatHandlers, "chat.send").mockImplementation(async ({ respond }) => {
respond(true, { runId: "adopted-run", status: "started" });
});
try {
const adopted = await directSessionReq<{
entry?: Record<string, unknown>;
runStarted?: boolean;
}>(
"sessions.create",
{ key: "agent:main:dashboard:adopted", agentId: "main", message: "adopted follow-up" },
{
client: {
connect: { scopes: ["operator.write"] },
authenticatedUserProfile: {
profileId: "profile-adopter",
displayName: null,
hasAvatar: false,
updatedAt: 1,
},
} as never,
},
);
expect(adopted.ok).toBe(true);
// Post-create work (the nested initial chat.send) still runs on adoption.
expect(adopted.payload?.runStarted).toBe(true);
expect(chatSend).toHaveBeenCalledTimes(1);
expect(
loadSessionEntry({ sessionKey: "agent:main:dashboard:adopted", storePath }),
).toMatchObject({
createdVia: "spawn",
createdActor: { type: "agent", id: "agent:main:main" },
createdAt: 4321,
});
// Adoption is not a node creation: no `created` event may enter the journal.
expect(
listSessionStateEventsSince("agent:main:dashboard:adopted", "main", 0, 20).events.filter(
(event) => event.kind === "created",
),
).toEqual([]);
} finally {
chatSend.mockRestore();
}
});
test("sessions.create scopes the main alias to the requested agent", async () => {
const { storePath } = await createSessionStoreDir();
@@ -1875,6 +2015,7 @@ test("sessions.create forks the parent transcript into the new session", async (
entry?: {
sessionFile?: string;
parentSessionKey?: string;
forkSource?: { sessionKey: string; sessionId: string };
forkedFromParent?: boolean;
totalTokens?: number;
totalTokensFresh?: boolean;
@@ -1887,6 +2028,10 @@ test("sessions.create forks the parent transcript into the new session", async (
expect(created.ok, JSON.stringify(created.error)).toBe(true);
expect(created.payload?.entry?.parentSessionKey).toBe("agent:main:main");
expect(created.payload?.entry?.forkSource).toEqual({
sessionKey: "agent:main:main",
sessionId: parent.sessionId,
});
expect(created.payload?.entry?.forkedFromParent).toBe(true);
expect(created.payload?.entry?.totalTokens).toBeUndefined();
expect(created.payload?.entry?.totalTokensFresh).toBe(false);
@@ -1932,8 +2077,16 @@ test("sessions.create forks the parent transcript into the new session", async (
expect(loadSessionEntry({ sessionKey: key, storePath })).toMatchObject({
sessionId: created.payload?.sessionId,
sessionFile: forkedSessionFile,
forkedFromParent: true,
forkSource: {
sessionKey: "agent:main:main",
sessionId: parent.sessionId,
},
});
expect(loadSessionEntry({ sessionKey: key, storePath })).not.toHaveProperty("forkedFromParent");
const listed = await directSessionReq<{
sessions?: Array<{ key: string; forkedFromParent?: boolean }>;
}>("sessions.list", {});
expect(listed.payload?.sessions?.find((row) => row.key === key)?.forkedFromParent).toBe(true);
testState.sessionConfig = undefined;
});
@@ -2159,6 +2312,7 @@ test("sessions.create resolves an agent-qualified fork from the parent store", a
entry?: {
parentSessionKey?: string;
sessionFile?: string;
forkSource?: { sessionKey: string; sessionId: string };
forkedFromParent?: boolean;
};
}>("sessions.create", {
@@ -2169,6 +2323,10 @@ test("sessions.create resolves an agent-qualified fork from the parent store", a
expect(created.ok, JSON.stringify(created.error)).toBe(true);
expect(created.payload?.key).toMatch(/^agent:main:dashboard:/);
expect(created.payload?.entry?.parentSessionKey).toBe("agent:work:main");
expect(created.payload?.entry?.forkSource).toEqual({
sessionKey: "agent:work:main",
sessionId: parent.sessionId,
});
expect(created.payload?.entry?.forkedFromParent).toBe(true);
const forkedSessionFile = requireNonEmptyString(
created.payload?.entry?.sessionFile,
@@ -1191,12 +1191,22 @@ test("sessions.changed mutation events include subagent ownership metadata", asy
entries: {
"subagent:child": sessionStoreEntry("sess-child", {
spawnedBy: "agent:main:main",
parentSessionKey: "agent:main:dashboard:navigation-parent",
spawnedWorkspaceDir: "/tmp/subagent-workspace",
spawnedCwd: "/tmp/task-repo",
forkedFromParent: true,
spawnDepth: 2,
subagentRole: "orchestrator",
subagentControlScope: "children",
createdVia: "spawn",
createdActor: { type: "agent", id: "agent:main:main" },
createdAt: 1_000,
forkSource: {
sessionKey: "agent:main:main",
sessionId: "sess-source",
entryId: "entry-source",
},
previousSessionId: "sess-previous",
}),
},
});
@@ -1211,12 +1221,23 @@ test("sessions.changed mutation events include subagent ownership metadata", asy
sessionKey: "agent:main:subagent:child",
reason: "patch",
spawnedBy: "agent:main:main",
controlOwnerSessionKey: "agent:main:main",
parentSessionKey: "agent:main:dashboard:navigation-parent",
spawnedWorkspaceDir: "/tmp/subagent-workspace",
spawnedCwd: "/tmp/task-repo",
forkedFromParent: true,
spawnDepth: 2,
subagentRole: "orchestrator",
subagentControlScope: "children",
createdVia: "spawn",
createdActor: { type: "agent", id: "agent:main:main" },
createdAt: 1_000,
forkSource: {
sessionKey: "agent:main:main",
sessionId: "sess-source",
entryId: "entry-source",
},
previousSessionId: "sess-previous",
});
});
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
@@ -6,6 +6,7 @@ import path from "node:path";
import { expect, test } from "vitest";
import { loadSessionEntry } from "../config/sessions/session-accessor.js";
import { MODEL_SELECTION_LOCKED_RESET_MESSAGE } from "../sessions/model-overrides.js";
import { listSessionStateEventsSince } from "../sessions/session-state-events.js";
import { testState, writeSessionStore } from "./test-helpers.js";
import {
setupGatewaySessionsTestHarness,
@@ -28,6 +29,11 @@ type ResetSessionEntry = {
spawnedWorkspaceDir?: string;
spawnedCwd?: string;
parentSessionKey?: string;
createdVia?: string;
createdActor?: { type: string; id?: string };
createdAt?: number;
forkSource?: { sessionKey: string; sessionId: string; entryId?: string };
previousSessionId?: string;
forkedFromParent?: boolean;
spawnDepth?: number;
subagentRole?: string;
@@ -84,6 +90,35 @@ type ModelResetEntry = Pick<
type ResolvedSessionModel = { modelProvider: string; model: string };
type SessionEntryOverrides = NonNullable<Parameters<typeof sessionStoreEntry>[1]>;
test("sessions.reset stamps provenance when it materializes a missing row", async () => {
await createSessionStoreDir();
const reset = await directSessionReq<{ entry: ResetSessionEntry }>(
"sessions.reset",
{ key: "agent:main:subagent:missing" },
{
client: {
authenticatedUserProfile: { profileId: "profile-reset-creator" },
} as never,
},
);
expect(reset.ok).toBe(true);
expect(reset.payload?.entry).toMatchObject({
createdVia: "operator",
createdActor: { type: "human", id: "profile-reset-creator" },
createdAt: expect.any(Number),
});
expect(
listSessionStateEventsSince("agent:main:subagent:missing", "main", 0, 20).events,
).toContainEqual(
expect.objectContaining({
kind: "created",
actorType: "human",
actorId: "profile-reset-creator",
}),
);
});
const ownedChildMetadata = {
chatType: "group",
channel: "discord",
@@ -523,6 +558,15 @@ test("sessions.reset preserves spawned session ownership metadata", async () =>
"subagent:child": sessionStoreEntry("sess-owned-child", {
sessionFile: customSessionFile,
...ownedChildMetadata,
forkedFromParent: undefined,
createdVia: "spawn",
createdActor: { type: "agent", id: "agent:main:main" },
createdAt: 1_000,
forkSource: {
sessionKey: "agent:main:root",
sessionId: "root-session",
entryId: "root-entry",
},
}),
},
});
@@ -535,9 +579,31 @@ test("sessions.reset preserves spawned session ownership metadata", async () =>
expect(reset.ok).toBe(true);
expectOwnedChildMetadata(reset.payload?.entry);
expect(reset.payload?.entry).toMatchObject({
createdVia: "spawn",
createdActor: { type: "agent", id: "agent:main:main" },
createdAt: 1_000,
forkSource: {
sessionKey: "agent:main:root",
sessionId: "root-session",
entryId: "root-entry",
},
previousSessionId: "sess-owned-child",
});
const stored = loadSessionEntry({ sessionKey: "agent:main:subagent:child", storePath }) as
| ResetSessionEntry
| undefined;
expectOwnedChildMetadata(stored);
expect(stored).toMatchObject({
createdVia: "spawn",
createdActor: { type: "agent", id: "agent:main:main" },
createdAt: 1_000,
forkSource: {
sessionKey: "agent:main:root",
sessionId: "root-session",
entryId: "root-entry",
},
previousSessionId: "sess-owned-child",
});
});
+16 -25
View File
@@ -439,33 +439,24 @@ test("lists and patches session store via sessions.* RPC", async () => {
expect(spawnedOnly.ok).toBe(true);
expect(spawnedOnly.payload?.sessions.map((s) => s.key)).toEqual(["agent:main:subagent:one"]);
const spawnedPatched = await directSessionReq<{
ok: true;
entry: { spawnedBy?: string };
}>("sessions.patch", {
key: "agent:main:subagent:two",
spawnedBy: "agent:main:main",
});
expect(spawnedPatched.ok).toBe(true);
expect(spawnedPatched.payload?.entry.spawnedBy).toBe("agent:main:main");
const acpPatched = await directSessionReq<{
ok: true;
entry: { spawnedBy?: string; spawnDepth?: number };
}>("sessions.patch", {
key: "agent:main:acp:child",
for (const [field, value] of Object.entries({
spawnedBy: "agent:main:main",
spawnedWorkspaceDir: "/tmp/subagent-workspace",
spawnedCwd: "/tmp/task-repo",
spawnDepth: 1,
});
expect(acpPatched.ok).toBe(true);
expect(acpPatched.payload?.entry.spawnedBy).toBe("agent:main:main");
expect(acpPatched.payload?.entry.spawnDepth).toBe(1);
const spawnedPatchedInvalidKey = await directSessionReq("sessions.patch", {
key: "agent:main:main",
spawnedBy: "agent:main:main",
});
expect(spawnedPatchedInvalidKey.ok).toBe(false);
subagentRole: "leaf",
subagentControlScope: "none",
})) {
const rejected = await directSessionReq("sessions.patch", {
key: "agent:main:subagent:two",
[field]: value,
});
expect(rejected.ok, field).toBe(false);
expect(rejected.error, field).toMatchObject({
code: "INVALID_REQUEST",
message: expect.stringContaining(`unexpected property '${field}'`),
});
}
const cleaned = await directSessionReq<{
applied: true;
@@ -9,7 +9,6 @@ import {
import { ConnectErrorDetailCodes } from "../../../../packages/gateway-protocol/src/connect-error-details.js";
import { ErrorCodes, PROTOCOL_VERSION } from "../../../../packages/gateway-protocol/src/index.js";
import { getRuntimeConfig } from "../../../config/io.js";
import { getPairedDevice } from "../../../infra/device-pairing.js";
import {
captureAuthenticatedNodePairingState,
type NodePairingGeneration,
@@ -214,33 +213,6 @@ export async function attachAuthenticatedGatewayConnect(
);
}
}
let pairedDeviceLabel: string | undefined;
if (device?.id) {
try {
const pairedDevice = await getPairedDevice(device.id);
pairedDeviceLabel =
normalizeOptionalString(pairedDevice?.operatorLabel) ??
normalizeOptionalString(pairedDevice?.displayName);
} catch (error) {
// Pairing metadata is attribution-only and must not turn into a login dependency.
logWsControl.warn(
`paired device label resolution failed conn=${connId}: ${formatForLog(error)}`,
);
}
}
// SSO identity wins over device labeling so one person keeps the same creator
// across browsers; paired-device labels cover gateways without trusted proxy auth.
const operatorIdentity = authenticatedUserProfile
? {
id: authenticatedUserId,
label: authenticatedUserProfile.displayName ?? authenticatedUserId,
}
: authenticatedUserId
? { id: authenticatedUserId, label: authenticatedUserId }
: device?.id && pairedDeviceLabel
? { id: device.id, label: pairedDeviceLabel }
: undefined;
const pluginSurfaceUrls: Record<string, string> = {};
const pluginNodeCapabilitySurfaces = indexPluginNodeCapabilitySurfaces(pluginNodeCapabilities);
const pendingPluginNodeCapabilities: Array<{
@@ -342,7 +314,6 @@ export async function attachAuthenticatedGatewayConnect(
presenceKey,
...(authenticatedUserId ? { authenticatedUserId } : {}),
...(authenticatedUserProfile ? { authenticatedUserProfile } : {}),
...(operatorIdentity ? { operatorIdentity } : {}),
clientIp: reportedClientIp,
...(internal ? { internal } : {}),
...(Object.keys(pluginSurfaceUrls).length > 0 ? { pluginSurfaceUrls } : {}),
@@ -628,7 +628,6 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
displayName: "alice",
hasAvatar: false,
},
operatorIdentity: { id: "alice@example.com", label: "alice" },
});
expect(setAvatar(profileId!, new Uint8Array([1, 2, 3]), "image/png").ok).toBe(true);
@@ -665,10 +664,7 @@ describe("attachGatewayWsMessageHandler post-connect health refresh", () => {
}),
);
});
expect(harness.client).toMatchObject({
authenticatedUserId: "alice@example.com",
operatorIdentity: { id: "alice@example.com", label: "alice@example.com" },
});
expect(harness.client).toMatchObject({ authenticatedUserId: "alice@example.com" });
expect(harness.client).not.toMatchObject({ authenticatedUserProfile: expect.anything() });
expect(harness.logWsControl.warn).toHaveBeenCalledTimes(1);
expect(harness.logWsControl.warn).toHaveBeenCalledWith(
@@ -0,0 +1,41 @@
import { describe, expect, it } from "vitest";
import type { SessionEntry } from "../config/sessions.js";
import { buildForkedGatewaySessionEntry } from "./session-create-fork-entry.js";
describe("buildForkedGatewaySessionEntry", () => {
it("preserves adopted node ancestry and links the replaced generation", () => {
const previous: SessionEntry = {
sessionId: "adopted-generation",
updatedAt: 1,
forkSource: { sessionKey: "agent:main:original", sessionId: "original-generation" },
};
const forked = buildForkedGatewaySessionEntry(
previous,
{ sessionId: "next-generation", sessionFile: "/tmp/next-generation.jsonl" },
{ sessionKey: "agent:main:new-parent", sessionId: "new-parent-generation" },
previous,
);
expect(forked).toMatchObject({
sessionId: "next-generation",
previousSessionId: "adopted-generation",
forkSource: { sessionKey: "agent:main:original", sessionId: "original-generation" },
});
});
it("uses the requested ancestry for a genuinely new node", () => {
const entry: SessionEntry = { sessionId: "provisional", updatedAt: 1 };
const forked = buildForkedGatewaySessionEntry(
entry,
{ sessionId: "forked", sessionFile: "/tmp/forked.jsonl" },
{ sessionKey: "agent:main:parent", sessionId: "parent-generation" },
);
expect(forked.forkSource).toEqual({
sessionKey: "agent:main:parent",
sessionId: "parent-generation",
});
expect(forked.previousSessionId).toBeUndefined();
});
});
+6 -1
View File
@@ -4,6 +4,8 @@ import type { SessionEntry } from "../config/sessions.js";
export function buildForkedGatewaySessionEntry(
entry: SessionEntry,
fork: { sessionId: string; sessionFile: string },
forkSource: NonNullable<SessionEntry["forkSource"]>,
previousEntry?: SessionEntry,
): SessionEntry {
// Replacing the transcript identity also replaces the recovery episode owned by the old row.
return {
@@ -11,7 +13,10 @@ export function buildForkedGatewaySessionEntry(
...buildMainSessionRecoveryClearPatch(entry),
sessionId: fork.sessionId,
sessionFile: fork.sessionFile,
forkedFromParent: true,
forkSource: previousEntry?.forkSource ?? forkSource,
...(previousEntry?.sessionId && previousEntry.sessionId !== fork.sessionId
? { previousSessionId: previousEntry.sessionId }
: {}),
totalTokens: undefined,
totalTokensFresh: false,
};
+42 -7
View File
@@ -6,7 +6,6 @@ import {
import {
ErrorCodes,
type ErrorShape,
type SessionCreatorIdentity,
errorShape,
missingScopeErrorShape,
} from "../../packages/gateway-protocol/src/index.js";
@@ -34,6 +33,11 @@ import {
createSessionEntryWithTranscript,
resolveSessionEntryAccessTarget,
} from "../config/sessions/session-accessor.js";
import {
buildSessionCreationStamp,
type SessionCreatedActor,
type SessionCreatedVia,
} from "../config/sessions/session-entry-provenance.js";
import { inheritSessionSelection } from "../config/sessions/session-entry-selection.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
@@ -58,6 +62,7 @@ import {
isSessionWorkAdmissionActive,
runExclusiveSessionLifecycleMutation,
} from "../sessions/session-lifecycle-admission.js";
import { recordSessionCreated } from "../sessions/session-state-events.js";
import { createLazyRuntimeModule } from "../shared/lazy-runtime.js";
import { ADMIN_SCOPE } from "./operator-scopes.js";
import { buildForkedGatewaySessionEntry } from "./session-create-fork-entry.js";
@@ -261,7 +266,6 @@ export async function createGatewaySession(params: {
thinkingLevel?: string;
/** Trusted catalog-owned model/runtime pair, persisted and locked together. */
catalogTarget?: TrustedCatalogSessionTarget;
createdBy?: SessionCreatorIdentity;
parentSessionKey?: string;
/**
* Spawn-lineage depth declared by spawn-owned creations (visible subagent
@@ -293,6 +297,8 @@ export async function createGatewaySession(params: {
initialEntry?: TrustedInitialSessionEntry;
/** Public callers need admin before reconfiguring an adopted keyed session. */
allowExistingModelSelection?: boolean;
/** Trusted in-process creation provenance; never populated from public Gateway params. */
creation?: { via: SessionCreatedVia; actor?: SessionCreatedActor };
/** Exact harness namespace authorized by the scoped plugin runtime. */
authorizedAgentHarnessId?: string;
/** Exact plugin namespace authorized by the scoped plugin runtime. */
@@ -537,7 +543,7 @@ export async function createGatewaySession(params: {
: {}),
reason: "new",
commandSource: params.commandSource,
createdBy: params.createdBy,
...(params.creation ? { creation: params.creation } : {}),
...(spawnedCwd ? { spawnedCwd } : {}),
...(params.worktree ? { worktree: params.worktree } : {}),
...(params.execNode ? { execNode: params.execNode } : {}),
@@ -560,6 +566,7 @@ export async function createGatewaySession(params: {
}
let createdContext: CreatedGatewaySession | undefined;
let createdNewEntry = false;
const createChildSession = async (): Promise<CreateGatewaySessionResult> => {
let currentParentSessionEntry = parentSessionEntry;
if (
@@ -687,6 +694,9 @@ export async function createGatewaySession(params: {
),
};
}
// Adoption of an existing key must not stamp provenance or emit a
// `created` event; only a genuinely new row is a node creation.
createdNewEntry = existingEntry === undefined;
const requestedModel = normalizeOptionalString(params.model);
const requestedThinkingLevel = normalizeOptionalString(params.thinkingLevel);
if (existingEntry?.sessionId && params.allowExistingModelSelection !== true) {
@@ -771,9 +781,10 @@ export async function createGatewaySession(params: {
: undefined;
const initializedEntry: SessionEntry = {
...patched.entry,
...(existingEntry === undefined && params.createdBy
? { createdBy: { ...params.createdBy } }
: {}),
// Stamp provenance only for genuinely new rows: adopting an existing key
// must not restamp write-once node facts (this direct store write bypasses
// the merge-level write-once guard), and legacy rows stay "unknown".
...(params.creation && createdNewEntry ? buildSessionCreationStamp(params.creation) : {}),
...(catalogResolvedModel && catalogAgentRuntime
? {
providerOverride: catalogResolvedModel.provider,
@@ -880,7 +891,15 @@ export async function createGatewaySession(params: {
}
return {
...initialized,
entry: buildForkedGatewaySessionEntry(entry, fork),
entry: buildForkedGatewaySessionEntry(
entry,
fork,
{
sessionKey: forkParentSessionKey,
sessionId: currentParentSessionEntry.sessionId,
},
existingEntry,
),
};
},
params.initialEntry
@@ -967,12 +986,28 @@ export async function createGatewaySession(params: {
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,
});
}
await params.afterCreate?.(createdContext);
}
return result;
}
const result = await createChildSession();
if (result.ok && !result.resetExisting && createdContext) {
if (createdNewEntry) {
recordSessionCreated({
sessionKey: createdContext.key,
agentId: createdContext.agentId,
entry: createdContext.entry,
});
}
await params.afterCreate?.(createdContext);
}
return result;
+5 -5
View File
@@ -1,21 +1,21 @@
import { expect, it } from "vitest";
import { buildGatewaySessionEventFields } from "./session-event-payload.js";
it("projects creator identity and explicitly clears it for ownerless generations", () => {
it("projects the created actor and explicitly clears it for actorless sessions", () => {
expect(
buildGatewaySessionEventFields({
sessionRow: {
key: "agent:main:owned",
kind: "direct",
updatedAt: 1,
createdBy: { id: "profile-ada", label: "Ada" },
createdActor: { type: "human", id: "profile-ada", label: "Ada" },
},
}).createdBy,
).toEqual({ id: "profile-ada", label: "Ada" });
}).createdActor,
).toEqual({ type: "human", id: "profile-ada", label: "Ada" });
expect(
buildGatewaySessionEventFields({
sessionRow: { key: "agent:main:ownerless", kind: "direct", updatedAt: 2 },
}).createdBy,
}).createdActor,
).toBeNull();
});
+8 -2
View File
@@ -1,3 +1,4 @@
import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js";
import type { GatewaySessionRow } from "./session-utils.js";
/**
@@ -27,7 +28,7 @@ export function buildGatewaySessionEventFields(params: {
return {
updatedAt: sessionRow.updatedAt ?? undefined,
sessionId: sessionRow.sessionId,
createdBy: sessionRow.createdBy ?? null,
createdActor: sessionRow.createdActor ?? null,
kind: sessionRow.kind,
channel: sessionRow.channel,
subject: sessionRow.subject,
@@ -46,13 +47,18 @@ export function buildGatewaySessionEventFields(params: {
observerDigest: sessionRow.observerDigest ?? null,
lastActivityAt: sessionRow.lastActivityAt,
spawnedBy: sessionRow.spawnedBy,
controlOwnerSessionKey: sessionRow.controlOwnerSessionKey ?? null,
swarmGroupId: sessionRow.swarmGroupId,
spawnedWorkspaceDir: sessionRow.spawnedWorkspaceDir,
spawnedCwd: sessionRow.spawnedCwd,
forkedFromParent: sessionRow.forkedFromParent,
forkedFromParent: sessionEntryForkedFromParent(sessionRow) ? true : undefined,
spawnDepth: sessionRow.spawnDepth,
subagentRole: sessionRow.subagentRole,
subagentControlScope: sessionRow.subagentControlScope,
createdVia: sessionRow.createdVia,
createdAt: sessionRow.createdAt,
forkSource: sessionRow.forkSource,
previousSessionId: sessionRow.previousSessionId,
label: params.label ?? sessionRow.label ?? null,
// Explicit null so subscribed clients drop a cleared category during merge-reconcile.
category: sessionRow.category ?? null,
+35 -9
View File
@@ -1,11 +1,7 @@
// Gateway session reset/delete service.
// Rotates transcripts and coordinates lifecycle cleanup across runtimes/hooks.
import { randomUUID } from "node:crypto";
import {
ErrorCodes,
errorShape,
type SessionCreatorIdentity,
} from "../../packages/gateway-protocol/src/index.js";
import { ErrorCodes, errorShape } from "../../packages/gateway-protocol/src/index.js";
import { getAcpSessionManager } from "../acp/control-plane/manager.js";
import { getAcpRuntimeBackend } from "../acp/runtime/registry.js";
import {
@@ -39,6 +35,12 @@ import {
} from "../config/sessions.js";
import { rebindCliSessionReseedReceiptsForReset } from "../config/sessions/cli-session-binding.js";
import { resolveResetPreservedSelection } from "../config/sessions/reset-preserved-selection.js";
import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js";
import {
buildSessionCreationStamp,
type SessionCreatedActor,
type SessionCreatedVia,
} from "../config/sessions/session-entry-provenance.js";
import { formatSqliteSessionFileMarker } from "../config/sessions/sqlite-marker.js";
import type { SessionAcpMeta } from "../config/sessions/types.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
@@ -66,7 +68,10 @@ import {
runExclusiveSessionLifecycleMutation,
SESSION_WORK_ADMISSION_DRAIN_TIMEOUT_MS,
} from "../sessions/session-lifecycle-admission.js";
import { handleSessionStateSessionReset } from "../sessions/session-state-events.js";
import {
handleSessionStateSessionReset,
recordSessionCreated,
} from "../sessions/session-state-events.js";
import {
forgetActiveSessionForShutdown,
listActiveSessionsForShutdown,
@@ -912,7 +917,8 @@ export async function performGatewaySessionReset(params: {
clearSpawnedCwd?: boolean;
reason: "new" | "reset";
commandSource: string;
createdBy?: SessionCreatorIdentity;
/** Trusted provenance for a reset that materializes a previously missing row. */
creation?: { via: SessionCreatedVia; actor?: SessionCreatedActor };
assertCurrent?: () => void;
onCommitted?: (commit: { key: string; sessionId: string }) => void;
}): Promise<
@@ -1166,6 +1172,7 @@ export async function performGatewaySessionReset(params: {
})
: undefined;
let createdNewEntry = false;
const lifecycle = await resetSessionEntryLifecycle({
agentId: target.agentId,
storePath,
@@ -1174,6 +1181,7 @@ export async function performGatewaySessionReset(params: {
storeKeys: target.storeKeys,
},
buildNextEntry: ({ currentEntry, primaryKey }) => {
createdNewEntry = currentEntry === undefined;
if (!isResetLifecycleCurrent() && currentEntry?.sessionId !== entry?.sessionId) {
// A newer owner already replaced or removed the session while cleanup
// targeted the old id. Preserve that newer state instead of resetting it.
@@ -1193,9 +1201,17 @@ export async function performGatewaySessionReset(params: {
sessionId: nextSessionId,
storePath,
});
const creationStamp = currentEntry
? {
createdVia: currentEntry.createdVia,
createdActor: currentEntry.createdActor,
createdAt: currentEntry.createdAt,
}
: params.creation
? buildSessionCreationStamp(params.creation)
: {};
const nextEntry: SessionEntry = {
sessionId: nextSessionId,
...(params.createdBy ? { createdBy: { ...params.createdBy } } : {}),
sessionFile,
updatedAt: now,
systemSent: false,
@@ -1249,7 +1265,10 @@ export async function performGatewaySessionReset(params: {
? undefined
: (params.worktree ?? currentEntry?.worktree),
parentSessionKey: currentEntry?.parentSessionKey,
forkedFromParent: currentEntry?.forkedFromParent,
...creationStamp,
forkSource: currentEntry?.forkSource,
previousSessionId: currentEntry?.sessionId,
forkedFromParent: sessionEntryForkedFromParent(currentEntry) ? true : undefined,
spawnDepth: currentEntry?.spawnDepth,
subagentRole: currentEntry?.subagentRole,
subagentControlScope: currentEntry?.subagentControlScope,
@@ -1295,6 +1314,13 @@ export async function performGatewaySessionReset(params: {
return nextEntry;
},
afterEntryMutation: async (mutation) => {
if (createdNewEntry) {
recordSessionCreated({
sessionKey: target.canonicalKey ?? params.key,
agentId,
entry: mutation.nextEntry,
});
}
let committedAcpResetState: { sessionKey: string; meta: SessionAcpMeta } | undefined;
if (deferredAcpResetState) {
const identity = deferredAcpResetState.meta.identity;
+19 -3
View File
@@ -1,17 +1,27 @@
import { expect, it } from "vitest";
import { expect, it, vi } from "vitest";
import type { SessionEntry } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
const getUserProfileListItem = vi.hoisted(() =>
vi.fn((profileId: string) => ({
id: profileId,
displayName: profileId === "profile-ada" ? "Ada" : "Bob",
})),
);
vi.mock("../state/user-profiles.js", () => ({ getUserProfileListItem }));
import { listSessionsFromStore } from "./session-utils.js";
it("returns the complete deterministic creator facet independently of pagination", () => {
const store: Record<string, SessionEntry> = {
"agent:main:ada": {
createdBy: { id: "profile-ada", label: "Ada" },
createdActor: { type: "human", id: "profile-ada" },
sessionId: "session-ada",
updatedAt: 2,
},
"agent:main:bob": {
createdBy: { id: "profile-bob", label: "Bob" },
createdActor: { type: "human", id: "profile-bob" },
sessionId: "session-bob",
updatedAt: 1,
},
@@ -30,6 +40,12 @@ it("returns the complete deterministic creator facet independently of pagination
{ id: "profile-ada", label: "Ada" },
{ id: "profile-bob", label: "Bob" },
]);
expect(result.sessions[0]?.createdActor).toEqual({
type: "human",
id: "profile-ada",
label: "Ada",
});
expect(getUserProfileListItem).toHaveBeenCalledTimes(2);
const filtered = listSessionsFromStore({
cfg: {} as OpenClawConfig,
+59 -2
View File
@@ -111,6 +111,59 @@ describe("listSessionsFromStore subagent metadata", () => {
}
});
test("keeps persisted navigation lineage separate from live registry control", () => {
const now = Date.now();
const childSessionKey = "agent:main:subagent:controlled-child";
const entry = {
sessionId: "sess-controlled-child",
updatedAt: now,
spawnedBy: "agent:main:subagent:persisted-spawner",
parentSessionKey: "agent:main:dashboard:navigation-parent",
createdVia: "spawn",
createdActor: { type: "agent", id: "agent:main:main" },
createdAt: now - 10_000,
forkSource: {
sessionKey: "agent:main:main",
sessionId: "sess-source",
entryId: "entry-source",
},
previousSessionId: "sess-previous",
} satisfies SessionEntry;
addSubagentRunForTests({
runId: "run-controlled-child",
childSessionKey,
controllerSessionKey: "agent:main:subagent:runtime-controller",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "controlled child",
cleanup: "keep",
createdAt: now - 5_000,
startedAt: now - 4_000,
});
const result = listSessionsFromStore({
cfg,
storePath: "/tmp/sessions.json",
store: { [childSessionKey]: entry },
opts: {},
});
const row = expectDefined(result.sessions[0], "controlled child row");
expect(row.spawnedBy).toBe("agent:main:subagent:runtime-controller");
expect(row.controlOwnerSessionKey).toBe("agent:main:subagent:runtime-controller");
expect(row.parentSessionKey).toBe("agent:main:dashboard:navigation-parent");
expect(row.createdVia).toBe("spawn");
expect(row.createdActor).toEqual({ type: "agent", id: "agent:main:main" });
expect(row.createdAt).toBe(now - 10_000);
expect(row.forkSource).toEqual({
sessionKey: "agent:main:main",
sessionId: "sess-source",
entryId: "entry-source",
});
expect(row.previousSessionId).toBe("sess-previous");
});
test("includes subagent status timing and direct child session keys", () => {
const now = Date.now();
const store: Record<string, SessionEntry> = {
@@ -576,7 +629,7 @@ describe("listSessionsFromStore subagent metadata", () => {
expect(result.sessions[0]?.spawnedBy).toBe("agent:main:subagent:new-parent-owner");
});
test("reports the newest parentSessionKey for moved child session rows", () => {
test("keeps the persisted parentSessionKey while reporting the newest runtime controller", () => {
const now = Date.now();
const childSessionKey = "agent:main:subagent:shared-child-parent";
const store: Record<string, SessionEntry> = {
@@ -621,7 +674,11 @@ describe("listSessionsFromStore subagent metadata", () => {
expect(result.sessions).toHaveLength(1);
expect(result.sessions[0]?.key).toBe(childSessionKey);
expect(result.sessions[0]?.parentSessionKey).toBe("agent:main:subagent:new-parent-parent");
expect(result.sessions[0]?.parentSessionKey).toBe("agent:main:subagent:old-parent-parent");
expect(result.sessions[0]?.spawnedBy).toBe("agent:main:subagent:new-parent-parent");
expect(result.sessions[0]?.controlOwnerSessionKey).toBe(
"agent:main:subagent:new-parent-parent",
);
});
test("preserves original session timing across follow-up replacement runs", () => {
+12
View File
@@ -227,6 +227,18 @@ describe("gateway session utils", () => {
);
});
test("emits a tombstone when a session has no current control owner", () => {
const row = buildGatewaySessionRow({
cfg: createModelDefaultsConfig({ primary: "openai/gpt-5.4" }),
storePath: "",
store: {},
key: "agent:main:child-without-owner",
entry: {} as SessionEntry,
});
expect(buildGatewaySessionEventFields({ sessionRow: row }).controlOwnerSessionKey).toBeNull();
});
test("projects only unexpired agent status", () => {
const entry = {
sessionId: "session",
+57 -11
View File
@@ -8,7 +8,7 @@ import {
} from "@openclaw/normalization-core/string-coerce";
import { uniqueStrings } from "@openclaw/normalization-core/string-normalization";
import type {
SessionCreatorIdentity,
SessionCreatedActor,
SessionsListParams,
} from "../../packages/gateway-protocol/src/index.js";
import {
@@ -96,6 +96,7 @@ import {
listSessionEntries as listAccessorSessionEntries,
listSessionEntriesReadOnly as listAccessorSessionEntriesReadOnly,
} from "../config/sessions/session-accessor.js";
import { sessionEntryForkedFromParent } from "../config/sessions/session-entry-lineage.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { projectPluginSessionExtensionsSync } from "../plugins/host-hook-state.js";
import { withPinnedActivePluginRegistryWorkspaceDir } from "../plugins/runtime-workspace-state.js";
@@ -107,6 +108,7 @@ import {
import { resolveActiveSessionAgentStatus } from "../sessions/session-agent-status.js";
import { isAcpSessionKey, isCronRunSessionKey } from "../sessions/session-key-utils.js";
import { resolveNonNegativeNumber } from "../shared/number-coercion.js";
import { getUserProfileListItem } from "../state/user-profiles.js";
import { truncateUtf16Safe } from "../utils.js";
import { normalizeSessionDeliveryFields } from "../utils/delivery-context.shared.js";
import { INTERNAL_MESSAGE_CHANNEL } from "../utils/message-channel-constants.js";
@@ -408,6 +410,7 @@ type SessionListRowContext = {
>;
displayModelIdentityByKey: Map<string, { provider?: string; model?: string }>;
modelCostConfigByModelRef: Map<string, ModelCostConfig | undefined>;
userProfileLabelById: Map<string, string | undefined>;
};
type SessionListRowContextProvider = () => SessionListRowContext;
@@ -661,6 +664,7 @@ function buildSessionListRowContextFromParts(params: {
thinkingMetadataByModelRef: new Map(),
displayModelIdentityByKey: new Map(),
modelCostConfigByModelRef: new Map(),
userProfileLabelById: new Map(),
};
}
@@ -1919,6 +1923,31 @@ export function resolveSessionDisplayModelIdentityRef(params: {
};
}
/** Adds the current human profile label without persisting rename-prone display data. */
function projectSessionCreatedActor(
actor: SessionEntry["createdActor"],
userProfileLabelById: Map<string, string | undefined> = new Map(),
): SessionCreatedActor | undefined {
if (!actor) {
return undefined;
}
const id = normalizeOptionalString(actor.id);
if (actor.type !== "human" || !id) {
return { type: actor.type, ...(id ? { id } : {}) };
}
let label = userProfileLabelById.get(id);
if (!userProfileLabelById.has(id)) {
try {
label = normalizeOptionalString(getUserProfileListItem(id).displayName);
} catch {
// Human actors can also be channel sender ids; only profile ids resolve here.
label = undefined;
}
userProfileLabelById.set(id, label);
}
return { type: actor.type, id, ...(label ? { label } : {}) };
}
export function buildGatewaySessionRow(params: {
cfg: OpenClawConfig;
storePath: string;
@@ -2225,18 +2254,24 @@ export function buildGatewaySessionRow(params: {
return {
key,
createdBy: entry?.createdBy,
spawnedBy: subagentOwner || entry?.spawnedBy,
// The live registry controller takes precedence over the persisted spawner.
controlOwnerSessionKey: subagentOwner || entry?.spawnedBy,
swarmGroupId: entry?.swarmGroupId,
spawnedWorkspaceDir: entry?.spawnedWorkspaceDir,
spawnedCwd: entry?.spawnedCwd,
worktree: entry?.worktree,
execNode: entry?.execNode,
execCwd: entry?.execCwd,
forkedFromParent: entry?.forkedFromParent,
forkedFromParent: sessionEntryForkedFromParent(entry) ? true : undefined,
spawnDepth: entry?.spawnDepth,
subagentRole: entry?.subagentRole,
subagentControlScope: entry?.subagentControlScope,
createdVia: entry?.createdVia,
createdActor: projectSessionCreatedActor(entry?.createdActor, rowContext?.userProfileLabelById),
createdAt: entry?.createdAt,
forkSource: entry?.forkSource,
previousSessionId: entry?.previousSessionId,
kind: classifySessionKey(key, entry),
label: entry?.label,
category: entry?.category,
@@ -2299,7 +2334,8 @@ export function buildGatewaySessionRow(params: {
startedAt: subagentRun ? subagentStartedAt : entry?.startedAt,
endedAt: subagentRun ? subagentEndedAt : entry?.endedAt,
runtimeMs: subagentRun ? subagentRuntimeMs : entry?.runtimeMs,
parentSessionKey: subagentOwner || entry?.parentSessionKey,
// Navigation lineage is persisted; runtime control is exposed separately above.
parentSessionKey: entry?.parentSessionKey,
childSessions,
responseUsage: entry?.responseUsage,
effectiveResponseUsage: resolveEffectiveResponseUsage(
@@ -2720,7 +2756,7 @@ function selectSessionEntries(params: {
const creatorEntries = filterSessionEntries(params);
const creatorId = normalizeOptionalString(params.opts.creatorId);
const filtered = creatorId
? creatorEntries.filter(([, entry]) => entry.createdBy?.id === creatorId)
? creatorEntries.filter(([, entry]) => entry.createdActor?.id === creatorId)
: creatorEntries;
const limit = resolveSessionsListLimit(params.opts, params.defaultLimit);
const offset = resolveSessionsListOffset(params.opts);
@@ -2743,14 +2779,16 @@ function selectSessionEntries(params: {
function listSessionCreatorIdentities(
entries: readonly SessionEntryPair[],
): SessionCreatorIdentity[] {
const creators = new Map<string, SessionCreatorIdentity>();
userProfileLabelById: Map<string, string | undefined>,
): Array<{ id: string; label?: string }> {
const creators = new Map<string, { id: string; label?: string }>();
for (const [, entry] of entries) {
const id = normalizeOptionalString(entry.createdBy?.id);
const actor = projectSessionCreatedActor(entry.createdActor, userProfileLabelById);
const id = normalizeOptionalString(actor?.id);
if (!id) {
continue;
}
const label = normalizeOptionalString(entry.createdBy?.label);
const label = normalizeOptionalString(actor?.label);
const existing = creators.get(id);
if (!existing || (label && (!existing.label || label.localeCompare(existing.label) < 0))) {
creators.set(id, { id, ...(label ? { label } : {}) });
@@ -2849,7 +2887,10 @@ export function listSessionsFromStore(params: {
offset: offset > 0 ? offset : undefined,
nextOffset,
hasMore,
creators: listSessionCreatorIdentities(creatorEntries),
creators: listSessionCreatorIdentities(
creatorEntries,
sharedRowContext?.userProfileLabelById ?? new Map(),
),
defaults: getSessionDefaults(cfg, params.modelCatalog, { allowPluginNormalization: false }),
sessions,
};
@@ -2902,7 +2943,8 @@ export async function listSessionsFromStoreAsync(params: {
: undefined,
defaultLimit: SESSIONS_LIST_DEFAULT_LIMIT,
});
const { entries, totalCount, limitApplied, offset, nextOffset, hasMore } = selection;
const { entries, creatorEntries, totalCount, limitApplied, offset, nextOffset, hasMore } =
selection;
const fullRowContext =
rowContext || hasSpawnedByFilter || entries.length > SESSIONS_LIST_YIELD_BATCH_SIZE
? getRowContext()
@@ -2981,6 +3023,10 @@ export async function listSessionsFromStoreAsync(params: {
offset: offset > 0 ? offset : undefined,
nextOffset,
hasMore,
creators: listSessionCreatorIdentities(
creatorEntries,
sharedRowContext?.userProfileLabelById ?? new Map(),
),
defaults: getSessionDefaults(cfg, params.modelCatalog, { allowPluginNormalization: false }),
sessions,
};
+22 -3
View File
@@ -1,8 +1,11 @@
// Shared Gateway session projection types.
// Keeps server methods and Control UI payloads aligned.
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
import type { SessionPlacement } from "../../packages/gateway-protocol/src/index.js";
import type { SessionCreatorIdentity } from "../../packages/gateway-protocol/src/schema/sessions.js";
import type {
SessionCreatedActor,
SessionPlacement,
SessionRow,
} from "../../packages/gateway-protocol/src/index.js";
import type { SessionObserverDigest } from "../../packages/gateway-protocol/src/schema/sessions.js";
import type { QueueMode } from "../auto-reply/reply/queue/types.js";
import type { ChatType } from "../channels/chat-type.js";
@@ -46,8 +49,9 @@ type SessionCompactionCheckpointPreview = Pick<
export type GatewaySessionRow = {
key: string;
createdBy?: SessionCreatorIdentity;
spawnedBy?: string;
/** Current runtime controller, falling back to the durable spawning session. */
controlOwnerSessionKey?: string;
/** Collector swarm group that owns this child session, when applicable. */
swarmGroupId?: string;
spawnedWorkspaceDir?: string;
@@ -62,6 +66,11 @@ export type GatewaySessionRow = {
spawnDepth?: number;
subagentRole?: SessionEntry["subagentRole"];
subagentControlScope?: SessionEntry["subagentControlScope"];
createdVia?: SessionEntry["createdVia"];
createdActor?: SessionCreatedActor;
createdAt?: SessionEntry["createdAt"];
forkSource?: SessionEntry["forkSource"];
previousSessionId?: SessionEntry["previousSessionId"];
kind: "direct" | "group" | "global" | "unknown";
label?: string;
/** User-defined organization bucket; unrelated to chat-group kind/groupChannel. */
@@ -151,6 +160,16 @@ export type GatewaySessionRow = {
pluginExtensions?: PluginSessionExtensionProjection[];
};
/**
* Compile-time drift guard: fails typecheck when the Gateway projection stops
* matching the protocol schema's documented row fields. Value-level so the
* unused-export scan sees a consumer.
*/
const sessionRowSchemaDriftGuard: Pick<GatewaySessionRow, keyof SessionRow> extends SessionRow
? true
: false = true;
void sessionRowSchemaDriftGuard;
export type GatewayAgentRow = SharedGatewayAgentRow;
export type SessionPreviewItem = {
@@ -0,0 +1,99 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import type { SessionsPatchParams } from "../../packages/gateway-protocol/src/index.js";
import {
normalizeInheritedToolAllowlist,
normalizeInheritedToolDenylist,
} from "../agents/inherited-tool-deny.js";
import type { SessionEntry } from "../config/sessions.js";
import { isAcpSessionKey, isSubagentSessionKey } from "../routing/session-key.js";
function supportsSpawnPolicy(storeKey: string): boolean {
return isSubagentSessionKey(storeKey) || isAcpSessionKey(storeKey);
}
function unsupportedField(field: string, storeKey: string): string | undefined {
return supportsSpawnPolicy(storeKey)
? undefined
: `${field} is only supported for subagent:* or acp:* sessions`;
}
/** Applies the remaining public child-policy fields after lineage became creation-only. */
export function applySessionsPatchSubagentPolicy(params: {
existing?: SessionEntry;
next: SessionEntry;
patch: SessionsPatchParams;
storeKey: string;
}): string | undefined {
const { existing, next, patch, storeKey } = params;
if ("completionOwnerSessionKey" in patch) {
const raw = patch.completionOwnerSessionKey;
if (raw === null && existing?.completionOwnerSessionKey) {
return "completionOwnerSessionKey cannot be cleared once set";
}
if (raw !== null && raw !== undefined) {
const unsupported = unsupportedField("completionOwnerSessionKey", storeKey);
if (unsupported) {
return unsupported;
}
const normalized = normalizeOptionalString(raw);
if (!normalized) {
return "invalid completionOwnerSessionKey: empty";
}
if (
existing?.completionOwnerSessionKey &&
existing.completionOwnerSessionKey !== normalized
) {
return "completionOwnerSessionKey cannot be changed once set";
}
next.completionOwnerSessionKey = normalized;
}
}
if ("inheritedToolPolicyVersion" in patch) {
const raw = patch.inheritedToolPolicyVersion;
if (raw === null && existing?.inheritedToolPolicyVersion !== undefined) {
return "inheritedToolPolicyVersion cannot be cleared once set";
}
if (raw !== null && raw !== undefined) {
const unsupported = unsupportedField("inheritedToolPolicyVersion", storeKey);
if (unsupported) {
return unsupported;
}
if (raw !== 1) {
return "invalid inheritedToolPolicyVersion (expected 1)";
}
next.inheritedToolPolicyVersion = 1;
}
}
for (const field of ["inheritedToolDeny", "inheritedToolAllow"] as const) {
if (!(field in patch)) {
continue;
}
const raw = patch[field];
if (raw === null) {
delete next[field];
continue;
}
if (raw === undefined) {
continue;
}
if (!Array.isArray(raw)) {
return `invalid ${field} (use an array of tool names)`;
}
const unsupported = unsupportedField(field, storeKey);
if (unsupported) {
return unsupported;
}
const normalized =
field === "inheritedToolDeny"
? normalizeInheritedToolDenylist(raw)
: normalizeInheritedToolAllowlist(raw);
if (normalized.length > 0) {
next[field] = normalized;
} else {
delete next[field];
}
}
return undefined;
}
-60
View File
@@ -1012,16 +1012,6 @@ describe("gateway sessions patch", () => {
expect(entry.modelOverrideSource).toBe("user");
});
test("sets spawnDepth for subagent sessions", async () => {
const entry = expectPatchOk(
await runPatch({
storeKey: "agent:main:subagent:child",
patch: { key: "agent:main:subagent:child", spawnDepth: 2 },
}),
);
expect(entry.spawnDepth).toBe(2);
});
test("validates thinking patches with live catalog reasoning metadata", async () => {
const registry = createEmptyPluginRegistry();
registry.providers.push({
@@ -1279,19 +1269,6 @@ describe("gateway sessions patch", () => {
expect(entry).toMatchObject({ label: "new label", thinkingLevel: "max" });
});
test("sets spawnedBy for ACP sessions", async () => {
const entry = expectPatchOk(
await runPatch({
storeKey: "agent:main:acp:child",
patch: {
key: "agent:main:acp:child",
spawnedBy: "agent:main:main",
},
}),
);
expect(entry.spawnedBy).toBe("agent:main:main");
});
test("sets an immutable completion owner for ACP sessions", async () => {
const entry = expectPatchOk(
await runPatch({
@@ -1315,29 +1292,6 @@ describe("gateway sessions patch", () => {
expectPatchError(result, "completionOwnerSessionKey cannot be changed once set");
});
test("sets spawnedWorkspaceDir for subagent sessions", async () => {
const entry = expectPatchOk(
await runPatch({
storeKey: "agent:main:subagent:child",
patch: {
key: "agent:main:subagent:child",
spawnedWorkspaceDir: "/tmp/subagent-workspace",
},
}),
);
expect(entry.spawnedWorkspaceDir).toBe("/tmp/subagent-workspace");
});
test("sets spawnDepth for ACP sessions", async () => {
const entry = expectPatchOk(
await runPatch({
storeKey: "agent:main:acp:child",
patch: { key: "agent:main:acp:child", spawnDepth: 2 },
}),
);
expect(entry.spawnDepth).toBe(2);
});
test("sets an immutable requester policy snapshot version for ACP sessions", async () => {
const entry = expectPatchOk(
await runPatch({
@@ -1393,20 +1347,6 @@ describe("gateway sessions patch", () => {
expect(entry.inheritedToolDeny?.at(-1)).toBe("exec");
});
test("rejects spawnDepth on non-subagent sessions", async () => {
const result = await runPatch({
patch: { key: MAIN_SESSION_KEY, spawnDepth: 1 },
});
expectPatchError(result, "spawnDepth is only supported");
});
test("rejects spawnedWorkspaceDir on non-subagent sessions", async () => {
const result = await runPatch({
patch: { key: MAIN_SESSION_KEY, spawnedWorkspaceDir: "/tmp/nope" },
});
expectPatchError(result, "spawnedWorkspaceDir is only supported");
});
test("rejects inheritedToolDeny on non-subagent sessions", async () => {
const result = await runPatch({
patch: { key: MAIN_SESSION_KEY, inheritedToolDeny: ["exec"] },
+9 -205
View File
@@ -13,10 +13,6 @@ import {
} from "../../packages/gateway-protocol/src/index.js";
import { readAcpSessionMetaForEntry } from "../acp/runtime/session-meta.js";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import {
normalizeInheritedToolAllowlist,
normalizeInheritedToolDenylist,
} from "../agents/inherited-tool-deny.js";
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
import { splitTrailingAuthProfile } from "../agents/model-ref-profile.js";
import {
@@ -40,7 +36,6 @@ import type { SessionEntry } from "../config/sessions.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizeExecTarget } from "../infra/exec-approvals.js";
import {
isAcpSessionKey,
isSubagentSessionKey,
normalizeAgentId,
parseAgentSessionKey,
@@ -74,6 +69,7 @@ import {
shouldPreserveSessionAuthProfileOverride,
snapshotAgentModelFallback,
} from "./session-model-patch-origin.js";
import { applySessionsPatchSubagentPolicy } from "./sessions-patch-subagent-policy.js";
function invalid(message: string): { ok: false; error: ErrorShape } {
return { ok: false, error: errorShape(ErrorCodes.INVALID_REQUEST, message) };
@@ -127,26 +123,6 @@ function normalizeExecAsk(raw: string): "off" | "on-miss" | "always" | undefined
return undefined;
}
function supportsSpawnLineage(storeKey: string): boolean {
return isSubagentSessionKey(storeKey) || isAcpSessionKey(storeKey);
}
function normalizeSubagentRole(raw: string): "orchestrator" | "leaf" | undefined {
const normalized = normalizeOptionalLowercaseString(raw);
if (normalized === "orchestrator" || normalized === "leaf") {
return normalized;
}
return undefined;
}
function normalizeSubagentControlScope(raw: string): "children" | "none" | undefined {
const normalized = normalizeOptionalLowercaseString(raw);
if (normalized === "children" || normalized === "none") {
return normalized;
}
return undefined;
}
type SessionPatchProjectionEntry = {
entry: SessionEntry;
sessionKey: string;
@@ -238,185 +214,14 @@ export async function projectSessionsPatchEntry(params: {
delete next.displayName;
}
type PatchError = ReturnType<typeof invalid> | null;
const checkSpawnLineage = (field: string): PatchError =>
supportsSpawnLineage(storeKey)
? null
: invalid(`${field} is only supported for subagent:* or acp:* sessions`);
const applyImmutableString = (
field: "spawnedBy" | "completionOwnerSessionKey" | "spawnedWorkspaceDir" | "spawnedCwd",
checkLineageBeforeEmpty: boolean,
): PatchError => {
if (!(field in patch)) {
return null;
}
const raw = patch[field];
if (raw === null) {
return existing?.[field] ? invalid(`${field} cannot be cleared once set`) : null;
}
if (raw === undefined) {
return null;
}
const earlyLineage = checkLineageBeforeEmpty ? checkSpawnLineage(field) : null;
if (earlyLineage) {
return earlyLineage;
}
const trimmed = normalizeOptionalString(raw) ?? "";
if (!trimmed) {
return invalid(`invalid ${field}: empty`);
}
const lateLineage = checkLineageBeforeEmpty ? null : checkSpawnLineage(field);
if (lateLineage) {
return lateLineage;
}
if (existing?.[field] && existing[field] !== trimmed) {
return invalid(`${field} cannot be changed once set`);
}
next[field] = trimmed;
return null;
};
const applyImmutableNormalized = <T extends "subagentRole" | "subagentControlScope">(
field: T,
normalize: (raw: string) => NonNullable<SessionEntry[T]> | undefined,
invalidMessage: string,
): PatchError => {
if (!(field in patch)) {
return null;
}
const raw = patch[field];
if (raw === null) {
return existing?.[field] ? invalid(`${field} cannot be cleared once set`) : null;
}
if (raw === undefined) {
return null;
}
const lineage = checkSpawnLineage(field);
if (lineage) {
return lineage;
}
const normalized = normalize(raw);
if (!normalized) {
return invalid(invalidMessage);
}
if (existing?.[field] && existing[field] !== normalized) {
return invalid(`${field} cannot be changed once set`);
}
next[field] = normalized;
return null;
};
for (const fieldParams of [
{ field: "spawnedBy" as const, checkLineageBeforeEmpty: false },
{ field: "completionOwnerSessionKey" as const, checkLineageBeforeEmpty: false },
{ field: "spawnedWorkspaceDir" as const, checkLineageBeforeEmpty: true },
{ field: "spawnedCwd" as const, checkLineageBeforeEmpty: true },
]) {
const result = applyImmutableString(fieldParams.field, fieldParams.checkLineageBeforeEmpty);
if (result) {
return result;
}
}
if ("spawnDepth" in patch) {
const raw = patch.spawnDepth;
if (raw === null) {
if (typeof existing?.spawnDepth === "number") {
return invalid("spawnDepth cannot be cleared once set");
}
} else if (raw !== undefined) {
if (!supportsSpawnLineage(storeKey)) {
return invalid("spawnDepth is only supported for subagent:* or acp:* sessions");
}
const numeric = raw;
if (!Number.isInteger(numeric) || numeric < 0) {
return invalid("invalid spawnDepth (use an integer >= 0)");
}
const normalized = numeric;
if (typeof existing?.spawnDepth === "number" && existing.spawnDepth !== normalized) {
return invalid("spawnDepth cannot be changed once set");
}
next.spawnDepth = normalized;
}
}
for (const fieldParams of [
{
field: "subagentRole" as const,
normalize: normalizeSubagentRole,
invalidMessage: 'invalid subagentRole (use "orchestrator" or "leaf")',
},
{
field: "subagentControlScope" as const,
normalize: normalizeSubagentControlScope,
invalidMessage: 'invalid subagentControlScope (use "children" or "none")',
},
]) {
const result = applyImmutableNormalized(
fieldParams.field,
fieldParams.normalize,
fieldParams.invalidMessage,
);
if (result) {
return result;
}
}
if ("inheritedToolPolicyVersion" in patch) {
const raw = patch.inheritedToolPolicyVersion;
if (raw === null) {
if (existing?.inheritedToolPolicyVersion !== undefined) {
return invalid("inheritedToolPolicyVersion cannot be cleared once set");
}
} else if (raw !== undefined) {
const lineage = checkSpawnLineage("inheritedToolPolicyVersion");
if (lineage) {
return lineage;
}
if (raw !== 1) {
return invalid("invalid inheritedToolPolicyVersion (expected 1)");
}
next.inheritedToolPolicyVersion = 1;
}
}
if ("inheritedToolDeny" in patch) {
const raw = patch.inheritedToolDeny;
if (raw === null) {
delete next.inheritedToolDeny;
} else if (raw !== undefined) {
if (!Array.isArray(raw)) {
return invalid("invalid inheritedToolDeny (use an array of tool names)");
}
if (!supportsSpawnLineage(storeKey)) {
return invalid("inheritedToolDeny is only supported for subagent:* or acp:* sessions");
}
const inheritedToolDeny = normalizeInheritedToolDenylist(raw);
if (inheritedToolDeny.length > 0) {
next.inheritedToolDeny = inheritedToolDeny;
} else {
delete next.inheritedToolDeny;
}
}
}
if ("inheritedToolAllow" in patch) {
const raw = patch.inheritedToolAllow;
if (raw === null) {
delete next.inheritedToolAllow;
} else if (raw !== undefined) {
if (!Array.isArray(raw)) {
return invalid("invalid inheritedToolAllow (use an array of tool names)");
}
if (!supportsSpawnLineage(storeKey)) {
return invalid("inheritedToolAllow is only supported for subagent:* or acp:* sessions");
}
const inheritedToolAllow = normalizeInheritedToolAllowlist(raw);
if (inheritedToolAllow.length > 0) {
next.inheritedToolAllow = inheritedToolAllow;
} else {
delete next.inheritedToolAllow;
}
}
const subagentPolicyError = applySessionsPatchSubagentPolicy({
existing,
next,
patch,
storeKey,
});
if (subagentPolicyError) {
return invalid(subagentPolicyError);
}
if ("label" in patch) {
@@ -866,4 +671,3 @@ export async function applySessionsPatchToStore(params: {
}
return projected;
}
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
@@ -145,6 +145,9 @@ describe("plugin runtime session creation", () => {
},
});
expect(created.entry).toMatchObject({
createdVia: "plugin",
createdActor: { type: "system", id: "anthropic" },
createdAt: expect.any(Number),
pluginOwnerId: "anthropic",
providerOverride: "claude-cli",
modelOverride: "claude-opus-4-8",
+7
View File
@@ -303,6 +303,13 @@ async function createSessionEntry(
},
...(harnessInitial ? { authorizedAgentHarnessId: harnessInitial.agentHarnessId } : {}),
...(cliInitial?.pluginOwnerId ? { authorizedPluginId: cliInitial.pluginOwnerId } : {}),
creation: {
via: "plugin",
actor: {
type: "system",
...(cliInitial?.pluginOwnerId ? { id: cliInitial.pluginOwnerId } : {}),
},
},
commandSource: "plugin-runtime",
...(afterCreate ? { afterCreate: runAfterCreate } : {}),
});
+5 -1
View File
@@ -14,7 +14,6 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
"pluginExtensionSlotKeys",
"pluginNextTurnInjections",
"sessionId",
"createdBy",
"lifecycleRevision",
"updatedAt",
"archivedAt",
@@ -32,6 +31,11 @@ const SESSION_ENTRY_RESERVED_SLOT_KEY_LIST = [
"spawnedCwd",
"worktree",
"parentSessionKey",
"createdVia",
"createdActor",
"createdAt",
"forkSource",
"previousSessionId",
"forkedFromParent",
"spawnDepth",
"swarmGroupId",
@@ -1,6 +1,7 @@
export type SessionStateActorType = "human" | "agent" | "system";
export type SessionStateEventKind =
| "created"
| "human_direct_message"
| "adopted"
| "run_completed"
@@ -12,6 +13,7 @@ export type SessionStateEventKind =
// Future utility-model materiality belongs at this deterministic seam; no config until then.
export const NOTIFY_BY_SESSION_STATE_EVENT_KIND: Record<SessionStateEventKind, boolean> = {
created: false,
human_direct_message: true,
upstream_missing: true,
adopted: false,

Some files were not shown because too many files have changed in this diff Show More