feat(audit): record session action decisions (#129093)

* feat(audit): record session action decisions

* fix(protocol): preserve session sharing client compatibility
This commit is contained in:
Josh Avant
2026-08-26 08:21:37 -07:00
committed by GitHub
parent 8cf6c1238f
commit e6ed7e30cc
46 changed files with 2412 additions and 662 deletions
@@ -778,6 +778,7 @@ enum class GatewayMethod(
ToolsGithubAuthorizeCancel("tools.github.authorize.cancel"),
SessionsGithubPublish("sessions.github.publish"),
DiagnosticsLanes("diagnostics.lanes"),
SessionMembersListEvidence("session.members.listEvidence"),
}
enum class GatewayEvent(
@@ -792,6 +793,7 @@ enum class GatewayEvent(
SessionObserver("session.observer"),
SessionOperation("session.operation"),
SessionSharing("session.sharing"),
SessionSharingEvidence("session.sharing.evidence"),
SessionSuggestion("session.suggestion"),
SessionTyping("session.typing"),
SessionTool("session.tool"),
@@ -54,6 +54,48 @@ class GatewayProtocolGeneratedTest {
assertNull(decoded.observedProjects)
}
@Test
fun legacyFramesDecodePrincipalLessSharingErrorsAndRefreshEvents() {
for (id in listOf("unknown", "absent")) {
val response =
json.decodeFromString(
GatewayResponseFrame.serializer(),
"""{"type":"res","id":"$id","ok":false,"error":{"code":"INVALID_REQUEST","message":"session membership includes actor evidence this client cannot represent","details":{"code":"SESSION_MEMBER_ACTOR_EVIDENCE_UNSUPPORTED","recommendedMethod":"session.members.listEvidence"}}}""",
)
assertEquals("INVALID_REQUEST", response.error?.code)
assertEquals(
"session.members.listEvidence",
response.error
?.details
?.jsonObject
?.get("recommendedMethod")
?.jsonPrimitive
?.content,
)
}
val ignoredEvidence =
json.decodeFromString(
GatewayEventFrame.serializer(),
"""{"type":"event","event":"session.sharing.evidence","payload":{"action":"member-added","sessionKey":"agent:main:main","agentId":"main","actorState":"unknown","identityId":"profile-bob","ts":2}}""",
)
val refresh =
json.decodeFromString(
GatewayEventFrame.serializer(),
"""{"type":"event","event":"sessions.changed","payload":{"reason":"sharing","sessionKey":"agent:main:main","agentId":"main","ts":2}}""",
)
assertEquals("session.sharing.evidence", ignoredEvidence.event)
assertEquals("sessions.changed", refresh.event)
assertEquals(
"sharing",
refresh.payload
?.jsonObject
?.get("reason")
?.jsonPrimitive
?.content,
)
}
@Test
fun generatedGatewayCatalogsAreCompleteAndUnique() {
val methods = GatewayMethod.entries.map { it.rawValue }
@@ -6883,6 +6883,32 @@ public struct SessionMember: Codable, Sendable {
}
}
public struct SessionMemberEvidence: Codable, Sendable {
public let identityid: String
public let addedby: String?
public let addedbystate: String?
public let addedat: Int
public init(
identityid: String,
addedby: String? = nil,
addedbystate: String? = nil,
addedat: Int)
{
self.identityid = identityid
self.addedby = addedby
self.addedbystate = addedbystate
self.addedat = addedat
}
private enum CodingKeys: String, CodingKey {
case identityid = "identityId"
case addedby = "addedBy"
case addedbystate = "addedByState"
case addedat = "addedAt"
}
}
public struct SessionMembersListResult: Codable, Sendable {
public let sessionkey: String
public let owner: SessionSharingIdentity?
@@ -6917,6 +6943,40 @@ public struct SessionMembersListResult: Codable, Sendable {
}
}
public struct SessionMembersListEvidenceResult: Codable, Sendable {
public let sessionkey: String
public let owner: SessionSharingIdentity?
public let members: [SessionMemberEvidence]
public let identities: [SessionSharingIdentity]
public let role: SessionSharingRole
public let allowedvisibilities: [SessionVisibility]
public init(
sessionkey: String,
owner: SessionSharingIdentity? = nil,
members: [SessionMemberEvidence],
identities: [SessionSharingIdentity],
role: SessionSharingRole,
allowedvisibilities: [SessionVisibility])
{
self.sessionkey = sessionkey
self.owner = owner
self.members = members
self.identities = identities
self.role = role
self.allowedvisibilities = allowedvisibilities
}
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case owner
case members
case identities
case role
case allowedvisibilities = "allowedVisibilities"
}
}
public struct SessionMemberAddParams: Codable, Sendable {
public let sessionkey: String
public let agentid: String?
@@ -7021,6 +7081,44 @@ public struct SessionSharingEvent: Codable, Sendable {
}
}
public struct SessionSharingEvidenceEvent: Codable, Sendable {
public let action: SessionSharingAction
public let sessionkey: String
public let agentid: String
public let actorstate: String?
public let visibility: SessionVisibility?
public let identityid: String?
public let ts: Int
public init(
action: SessionSharingAction,
sessionkey: String,
agentid: String,
actorstate: String? = nil,
visibility: SessionVisibility? = nil,
identityid: String? = nil,
ts: Int)
{
self.action = action
self.sessionkey = sessionkey
self.agentid = agentid
self.actorstate = actorstate
self.visibility = visibility
self.identityid = identityid
self.ts = ts
}
private enum CodingKeys: String, CodingKey {
case action
case sessionkey = "sessionKey"
case agentid = "agentId"
case actorstate = "actorState"
case visibility
case identityid = "identityId"
case ts
}
}
public struct SessionSuggestion: Codable, Sendable {
public let id: String
public let sessionkey: String
@@ -66,6 +66,62 @@ struct GatewayModelsCompatibilityTests {
#expect(mutation.sectionorder == nil)
}
@Test
func `session sharing decodes present unknown and absent actor evidence`() throws {
let presentList = try JSONDecoder().decode(
SessionMembersListResult.self,
from: Data(
#"{"sessionKey":"main","members":[{"identityId":"present","addedBy":"profile-ada","addedAt":1}],"identities":[],"role":"owner","allowedVisibilities":["shared"]}"#
.utf8))
let principalLessList = try JSONDecoder().decode(
SessionMembersListEvidenceResult.self,
from: Data(
#"{"sessionKey":"main","members":[{"identityId":"unknown","addedByState":"unknown","addedAt":2},{"identityId":"absent","addedAt":3}],"identities":[],"role":"owner","allowedVisibilities":["shared"]}"#
.utf8))
#expect(presentList.members[0].addedby == "profile-ada")
#expect(principalLessList.members[0].addedby == nil)
#expect(principalLessList.members[0].addedbystate == "unknown")
#expect(principalLessList.members[1].addedby == nil)
#expect(principalLessList.members[1].addedbystate == nil)
for member in [
#"{"identityId":"unknown","addedByState":"unknown","addedAt":2}"#,
#"{"identityId":"absent","addedAt":3}"#,
] {
#expect(throws: DecodingError.self) {
try JSONDecoder().decode(
SessionMembersListResult.self,
from: Data(
#"{"sessionKey":"main","members":[\#(member)],"identities":[],"role":"owner","allowedVisibilities":["shared"]}"#
.utf8))
}
}
let present = try JSONDecoder().decode(
SessionSharingEvent.self,
from: Data(
#"{"action":"visibility","sessionKey":"main","agentId":"main","actor":{"type":"human","id":"profile-ada"},"ts":1}"#
.utf8))
#expect(present.actor.id == "profile-ada")
let principalLess = try JSONDecoder().decode(
[SessionSharingEvidenceEvent].self,
from: Data(
#"[{"action":"member-added","sessionKey":"main","agentId":"main","actorState":"unknown","identityId":"member","ts":2},{"action":"member-removed","sessionKey":"main","agentId":"main","identityId":"member","ts":3}]"#
.utf8))
#expect(principalLess[0].actorstate == "unknown")
#expect(principalLess[1].actorstate == nil)
#expect(throws: DecodingError.self) {
try JSONDecoder().decode(
SessionSharingEvent.self,
from: Data(
#"{"action":"member-added","sessionKey":"main","agentId":"main","actorState":"unknown","identityId":"member","ts":2}"#
.utf8))
}
}
@Test
func `device pair setup results decode older gateway payloads`() throws {
let result = try JSONDecoder().decode(
+4
View File
@@ -122,6 +122,10 @@ Docking does not:
It only changes the delivery route for the current session.
Dock commands are handled directly as chat commands rather than admitted model
runs. Their success or failure remains visible in the command reply, but they
do not create a run-audit selector or decision receipt.
## Troubleshooting
**The command says the sender is not linked.**
+15
View File
@@ -30,6 +30,21 @@ These tools are still subject to the active tool profile and allow/deny policy.
Group, provider, sandbox, and per-agent policies can still remove those tools after the profile stage. Use `/tools` from the affected session to inspect the effective tool list.
Session access denials are rendered from the same typed visibility decision
used by the enforcement boundary. When execution audit collection is enabled
for an admitted run, a private queued fact retains the evaluated policy inputs
and an installation-local opaque target reference, not the raw target session
key. Public inspection renders that generic fact as an unverified
`decision.record`; it does not claim a trusted reason or target display. A
successful session operation is not labeled `enforced` merely because its
mechanics succeeded.
For the same admitted run, create, fork, send, patch, reset, archive, restore,
and delete results can queue attribution-only generic facts. The private facts
distinguish committed or scheduled work from typed lifecycle conflicts and
definitive no-ops; their public display remains generic and unverified. Direct
Gateway sharing operations are outside this run-audit boundary.
## Listing and reading sessions
`sessions_list` returns focused discovery rows: session key, durable session ID, agent, kind, channel, label/title/preview fields, sidebar category, parent and child relationships, last update, archive/pin state, state version, model, context/total token counts, run status, and whether the last run aborted. Filter by `kinds` (array; accepted values: `main`, `group`, `cron`, `hook`, `node`, `other`), exact `label`, exact `agentId`, `search` text, or recency (`activeMinutes`). Active sessions are returned by default; pass `archived: true` to inspect archived sessions instead. Set `includeDerivedTitles`, `includeLastMessage`, or `messageLimit` (capped at 20) when you need mailbox-style triage: a visibility-scoped derived title, a last-message preview snippet, or bounded recent messages on each row. Use the returned `sessionId` as `expectedSessionId` when the `sessions` tool archives, restores, or deletes another session; this prevents a stale key from targeting a replacement. Delivery routing, other internal IDs, per-run timings/settings, cost estimates, and transcript paths remain omitted; use `session_status`, conversation tools, and `sessions_history` for those owner-specific details. Derived titles and previews are produced only for sessions the caller can already see under the configured session tool visibility policy, so unrelated sessions stay hidden. When visibility is restricted, `sessions_list` returns optional `visibility` metadata showing the effective mode and a warning that results may be scope-limited.
+31
View File
@@ -205,6 +205,37 @@ outcome-affecting. Wildcard/open policy and explicit attribution-only adapters
remain `attribution-only`; mixed or missing evidence is `unknown`. Identity and
the corresponding decision share the existing audit-writer FIFO.
An admitted session-tool access denial queues a private `session` decision
through that same FIFO. The access owner supplies the reason, policy inputs,
and missing evidence; the audit writer replaces the target session reference
with an installation-local HMAC before persistence. The raw session key is not
retained. A policy denial that changed the outcome is `enforced`, while an
ownership lookup that cannot supply `session.owner` evidence remains `unknown`.
Public inspection intentionally renders generic facts as an unverified
`decision.record`; it does not expose their private reason or target display.
Calls without the exact admitted execution and its active receipt authority
create no selector or fact.
Run-bound session tools also queue their owner-returned result after the final
await and authority recheck. Create, fork, send, patch, reset, archive, restore,
and delete facts distinguish committed or scheduled work from typed lifecycle
conflicts and definitive no-ops. These mechanics are `attribution-only`; the
public generic display remains unverified rather than presenting their private
reason or target as trusted evidence.
Direct session-sharing methods and `/dock-*` commands do not admit model runs,
so they do not synthesize run selectors. Sharing events preserve a verified
profile actor when one exists; an expected but unresolved profile is reported
as unknown, while omitted principal evidence is unattributed. Neither state is
reconstructed from operator scope, a shared token, session routing, or room
metadata. Member listings use the same distinction: `addedBy` contains only a
real principal id, `addedByState: "unknown"` reports explicit principal-less
evidence, and omission means no actor evidence was supplied. Internal storage
markers are never returned by the Gateway. Beta-only `local-operator` and
`operator.admin` member-attribution values are discarded as absent evidence;
they are not migrated or presented as principals. Docking retains its normal
visible command result and session-route update without run-audit attribution.
For an admitted run with message auditing enabled, run inspection also adapts
the outbound message lifecycle. It deterministically merges the lazy progress
owner with terminal ledger rows and reports `queued`, `platform-started`,
+1 -1
View File
@@ -1291,7 +1291,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
- `suggest`: allow `suggest`, where viewers can submit suggestions for the session owner or an `operator.admin` connection to send, queue, edit, or dismiss without granting direct access to send or manage the session.
- `drafts`: allow `draft`, which hides the session from non-admin, non-owner session lists and event broadcasts.
Session visibility and membership are maintained as canonical sharing state. Structured `session.sharing` and `session.suggestion` change events refresh connected clients without adding administrative narration to conversation transcripts. These controls coordinate operators sharing one agent; they are not a security boundary between tenants. Use separate Gateways or agents when work requires isolation.
Session visibility and membership are maintained as canonical sharing state. Structured `session.sharing` events carry an attributed actor; principal-less changes use the additive `session.sharing.evidence` event. Every sharing change also emits the existing `sessions.changed` row refresh, so clients that do not recognize the evidence event still refresh canonical state. These events and `session.suggestion` do not add administrative narration to conversation transcripts. These controls coordinate operators sharing one agent; they are not a security boundary between tenants. Use separate Gateways or agents when work requires isolation.
</Accordion>
@@ -228,9 +228,12 @@ export {
SessionMemberMutationResultSchema,
SessionMemberRemoveParamsSchema,
SessionMemberSchema,
SessionMemberEvidenceSchema,
SessionMembersListParamsSchema,
SessionMembersListEvidenceResultSchema,
SessionMembersListResultSchema,
SessionSharingActionSchema,
SessionSharingEvidenceEventSchema,
SessionSharingEventSchema,
SessionSharingIdentitySchema,
SessionSharingRoleSchema,
@@ -21,12 +21,15 @@ export const SessionCollaborationProtocolSchemas = {
SessionVisibilitySetResult: sessionsSharing.SessionVisibilitySetResultSchema,
SessionMembersListParams: sessionsSharing.SessionMembersListParamsSchema,
SessionMember: sessionsSharing.SessionMemberSchema,
SessionMemberEvidence: sessionsSharing.SessionMemberEvidenceSchema,
SessionMembersListResult: sessionsSharing.SessionMembersListResultSchema,
SessionMembersListEvidenceResult: sessionsSharing.SessionMembersListEvidenceResultSchema,
SessionMemberAddParams: sessionsSharing.SessionMemberAddParamsSchema,
SessionMemberRemoveParams: sessionsSharing.SessionMemberRemoveParamsSchema,
SessionMemberMutationResult: sessionsSharing.SessionMemberMutationResultSchema,
SessionSharingAction: sessionsSharing.SessionSharingActionSchema,
SessionSharingEvent: sessionsSharing.SessionSharingEventSchema,
SessionSharingEvidenceEvent: sessionsSharing.SessionSharingEvidenceEventSchema,
SessionSuggestionState: sessionsSuggestions.SessionSuggestionStateSchema,
SessionSuggestionAction: sessionsSuggestions.SessionSuggestionActionSchema,
SessionSuggestionResolution: sessionsSuggestions.SessionSuggestionResolutionSchema,
@@ -2,10 +2,21 @@ import { Value } from "typebox/value";
import { describe, expect, it } from "vitest";
import {
SessionMemberAddParamsSchema,
SessionMembersListEvidenceResultSchema,
SessionMembersListResultSchema,
SessionSharingEvidenceEventSchema,
SessionSharingEventSchema,
SessionVisibilitySetParamsSchema,
} from "./sessions-sharing.js";
const baseEvent = {
action: "visibility",
sessionKey: "agent:main:main",
agentId: "main",
visibility: "draft",
ts: 1,
} as const;
describe("session sharing protocol", () => {
it("accepts additive visibility and membership payloads", () => {
expect(
@@ -23,12 +34,52 @@ describe("session sharing protocol", () => {
expect(
Value.Check(SessionMembersListResultSchema, {
sessionKey: "agent:main:main",
members: [],
members: [{ identityId: "alice", addedBy: "profile-ada", addedAt: 1 }],
identities: [],
role: "owner",
allowedVisibilities: ["shared", "read-only", "suggest", "draft"],
}),
).toBe(true);
expect(
Value.Check(SessionMembersListResultSchema, {
sessionKey: "agent:main:main",
members: [{ identityId: "bob", addedByState: "unknown", addedAt: 2 }],
identities: [],
role: "owner",
allowedVisibilities: [],
}),
).toBe(false);
for (const member of [
{ identityId: "alice", addedBy: "profile-ada", addedAt: 1 },
{ identityId: "bob", addedByState: "unknown", addedAt: 2 },
{ identityId: "carol", addedAt: 3 },
]) {
expect(
Value.Check(SessionMembersListEvidenceResultSchema, {
sessionKey: "agent:main:main",
members: [member],
identities: [],
role: "owner",
allowedVisibilities: ["shared", "read-only", "suggest", "draft"],
}),
).toBe(true);
}
expect(
Value.Check(SessionMembersListEvidenceResultSchema, {
sessionKey: "agent:main:main",
members: [
{
identityId: "mixed",
addedBy: "profile-ada",
addedByState: "unknown",
addedAt: 4,
},
],
identities: [],
role: "owner",
allowedVisibilities: [],
}),
).toBe(false);
});
it("rejects unknown visibility modes", () => {
@@ -39,4 +90,40 @@ describe("session sharing protocol", () => {
}),
).toBe(false);
});
it("keeps the legacy event actor required", () => {
expect(
Value.Check(SessionSharingEventSchema, {
...baseEvent,
actor: { type: "human", id: "profile-ada", label: "Ada" },
}),
).toBe(true);
expect(
Value.Check(SessionSharingEventSchema, {
...baseEvent,
actorState: "unknown",
}),
).toBe(false);
expect(Value.Check(SessionSharingEventSchema, baseEvent)).toBe(false);
expect(
Value.Check(SessionSharingEventSchema, {
...baseEvent,
actor: { type: "human", id: "profile-ada" },
actorState: "unknown",
}),
).toBe(false);
expect(
Value.Check(SessionSharingEvidenceEventSchema, {
...baseEvent,
actorState: "unknown",
}),
).toBe(true);
expect(Value.Check(SessionSharingEvidenceEventSchema, baseEvent)).toBe(true);
expect(
Value.Check(SessionSharingEvidenceEventSchema, {
...baseEvent,
actor: { type: "human", id: "profile-ada" },
}),
).toBe(false);
});
});
@@ -49,6 +49,17 @@ export const SessionMemberSchema = closedObject({
addedAt: Type.Integer({ minimum: 0 }),
});
export const SessionMemberEvidenceSchema = Object.assign(
closedObject({
identityId: NonEmptyString,
addedBy: Type.Optional(NonEmptyString),
/** Explicit principal-less evidence; omission means no actor evidence was supplied. */
addedByState: Type.Optional(Type.Literal("unknown")),
addedAt: Type.Integer({ minimum: 0 }),
}),
{ not: { required: ["addedBy", "addedByState"] } },
);
export const SessionMembersListResultSchema = closedObject({
sessionKey: NonEmptyString,
owner: Type.Optional(SessionSharingIdentitySchema),
@@ -58,6 +69,15 @@ export const SessionMembersListResultSchema = closedObject({
allowedVisibilities: Type.Array(SessionVisibilitySchema),
});
export const SessionMembersListEvidenceResultSchema = closedObject({
sessionKey: NonEmptyString,
owner: Type.Optional(SessionSharingIdentitySchema),
members: Type.Array(SessionMemberEvidenceSchema),
identities: Type.Array(SessionSharingIdentitySchema),
role: SessionSharingRoleSchema,
allowedVisibilities: Type.Array(SessionVisibilitySchema),
});
export const SessionMemberAddParamsSchema = closedObject({
...SessionSharingTargetParamsSchema,
identityId: NonEmptyString,
@@ -71,14 +91,31 @@ export const SessionMemberMutationResultSchema = closedObject({
identityId: NonEmptyString,
});
export const SessionSharingEventSchema = closedObject({
const SessionSharingEventTargetFields = {
action: SessionSharingActionSchema,
sessionKey: NonEmptyString,
agentId: NonEmptyString,
actor: SessionSharingIdentitySchema,
};
const SessionSharingEventChangeFields = {
visibility: Type.Optional(SessionVisibilitySchema),
identityId: Type.Optional(NonEmptyString),
ts: Type.Integer({ minimum: 0 }),
};
/** Original sharing event contract. Older generated clients require `actor`. */
export const SessionSharingEventSchema = closedObject({
...SessionSharingEventTargetFields,
actor: SessionSharingIdentitySchema,
...SessionSharingEventChangeFields,
});
/** Principal-less sharing changes use a distinct additive event name. */
export const SessionSharingEvidenceEventSchema = closedObject({
...SessionSharingEventTargetFields,
/** Explicit principal-less evidence; omission means no actor evidence was supplied. */
actorState: Type.Optional(Type.Literal("unknown")),
...SessionSharingEventChangeFields,
});
export type SessionSharingIdentity = Static<typeof SessionSharingIdentitySchema>;
@@ -87,8 +124,13 @@ export type SessionVisibilitySetParams = Static<typeof SessionVisibilitySetParam
export type SessionVisibilitySetResult = Static<typeof SessionVisibilitySetResultSchema>;
export type SessionMembersListParams = Static<typeof SessionMembersListParamsSchema>;
export type SessionMember = Static<typeof SessionMemberSchema>;
export type SessionMemberEvidence = Static<typeof SessionMemberEvidenceSchema>;
export type SessionMembersListResult = Static<typeof SessionMembersListResultSchema>;
export type SessionMembersListEvidenceResult = Static<
typeof SessionMembersListEvidenceResultSchema
>;
export type SessionMemberAddParams = Static<typeof SessionMemberAddParamsSchema>;
export type SessionMemberRemoveParams = Static<typeof SessionMemberRemoveParamsSchema>;
export type SessionMemberMutationResult = Static<typeof SessionMemberMutationResultSchema>;
export type SessionSharingEvent = Static<typeof SessionSharingEventSchema>;
export type SessionSharingEvidenceEvent = Static<typeof SessionSharingEvidenceEventSchema>;
@@ -22,6 +22,7 @@
"session.approval": "Native approval review uses exec.approval push/nudge delivery; the session-scoped approval stream is a Control UI chat surface.",
"session.operation": "Chat UI derives run state from chat/agent events; no session.operation consumer yet.",
"session.sharing": "Session visibility/membership management is a Control UI operator surface; iOS reads visibility/sharingRole from session rows and has no sharing editor.",
"session.sharing.evidence": "Principal-less sharing evidence is a Control UI operator surface; iOS refreshes canonical session rows through sessions.changed.",
"session.suggestion": "The suggestion queue is a Control UI collaboration surface; iOS does not render or resolve session suggestions.",
"session.tool": "Session tool stream is not rendered by the iOS chat surface yet.",
"session.typing": "Collaborative typing state is a Control UI-only ephemeral indicator; iOS does not render it.",
@@ -58,6 +59,7 @@
"session.approval": "Native approval review uses exec.approval push/nudge delivery; the session-scoped approval stream is a Control UI chat surface.",
"session.operation": "Chat UI derives run state from chat/agent events; no session.operation consumer yet.",
"session.sharing": "Session visibility/membership management is a Control UI operator surface; Android reads visibility/sharingRole from session rows and has no sharing editor.",
"session.sharing.evidence": "Principal-less sharing evidence is a Control UI operator surface; Android refreshes canonical session rows through sessions.changed.",
"session.suggestion": "The suggestion queue is a Control UI collaboration surface; Android does not render or resolve session suggestions.",
"session.tool": "Session tool stream is not rendered by the Android chat surface yet.",
"session.typing": "Collaborative typing state is a Control UI-only ephemeral indicator; Android does not render it.",
+102 -5
View File
@@ -4,6 +4,11 @@ import os from "node:os";
import path from "node:path";
import { Value } from "typebox/value";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
configureExecutionDecisionWorkSink,
type ExecutionDecisionWork,
} from "../audit/execution-decision-work.js";
import { createExecutionIdentityAdmissionToken } from "../audit/execution-identity-admission.js";
import type { ChannelMessagingAdapter } from "../channels/plugins/types.public.js";
import type { OpenClawConfig } from "../config/config.js";
import {
@@ -50,6 +55,7 @@ import { setActiveEmbeddedRun } from "./embedded-agent-runner/runs.js";
import { testing as embeddedRunsTesting } from "./embedded-agent-runner/runs.test-support.js";
import { compactToolOutputHint } from "./tool-schema-hints.js";
import { testing as agentStepTesting } from "./tools/agent-step.test-support.js";
import { withGatewayToolCallerIdentity } from "./tools/gateway-caller-context.js";
import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
import { createSessionsSearchTool } from "./tools/sessions-search-tool.js";
@@ -1198,6 +1204,11 @@ describe("sessions tools", () => {
}
return {};
});
const decisionWork: ExecutionDecisionWork[] = [];
const clearDecisionSink = configureExecutionDecisionWorkSink((work) => {
decisionWork.push(work);
return true;
});
try {
const tool = getSessionTool("sessions_send", {
agentSessionKey: requesterSessionKey,
@@ -1209,12 +1220,25 @@ describe("sessions tools", () => {
} as OpenClawConfig,
});
const result = await tool.execute("scoped-send", {
sessionKey: targetSessionKey,
message: "Please check the main session",
timeoutSeconds: 0,
watch: true,
const token = createExecutionIdentityAdmissionToken("scoped-session-send", {
contextId: "scoped-session-send-context",
executionId: "scoped-session-send-execution",
});
const result = await withGatewayToolCallerIdentity(
{
agentId: "main",
sessionKey: requesterSessionKey,
executionIdentityToken: token,
receiptAuthority: () => true,
},
async () =>
await tool.execute("scoped-send", {
sessionKey: targetSessionKey,
message: "Please check the main session",
timeoutSeconds: 0,
watch: true,
}),
);
expect(result.details).toMatchObject({
status: "accepted",
@@ -1222,12 +1246,85 @@ describe("sessions tools", () => {
watched: false,
});
expect(calls.map((call) => call.method)).toEqual(["agent"]);
expect(decisionWork).toHaveLength(1);
expect(decisionWork[0]).toMatchObject({
receipt: {
action: { family: "session", operation: "send" },
decision: { outcome: "allowed", reasonCode: "session_send_committed" },
enforcement: { coverageState: "attribution-only" },
},
refs: {
target: { namespace: "session", value: `["main","${targetSessionKey}"]` },
},
});
} finally {
clearDecisionSink();
unregister();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
it("records the admitted target on the waited-send branch", async () => {
const targetSessionKey = "agent:main:main";
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: { runId?: string } };
if (request.method === "agent") {
return { runId: "run-waited-audit", status: "accepted", acceptedAt: 1 };
}
if (request.method === "agent.wait") {
return { runId: request.params?.runId, status: "ok" };
}
if (request.method === "chat.history") {
return { messages: [{ role: "assistant", content: "REPLY_SKIP", timestamp: 2 }] };
}
return {};
});
const decisionWork: ExecutionDecisionWork[] = [];
const clearDecisionSink = configureExecutionDecisionWorkSink((work) => {
decisionWork.push(work);
return true;
});
const token = createExecutionIdentityAdmissionToken("waited-session-send", {
contextId: "waited-session-send-context",
executionId: "waited-session-send-execution",
});
const tool = getSessionTool("sessions_send", {
agentSessionKey: "agent:main:dashboard:requester",
config: TEST_CONFIG,
});
try {
const result = await withGatewayToolCallerIdentity(
{
agentId: "main",
sessionKey: "agent:main:dashboard:requester",
executionIdentityToken: token,
receiptAuthority: () => true,
},
async () =>
await tool.execute("waited-send-audit", {
sessionKey: targetSessionKey,
message: "wait without reply-back",
timeoutSeconds: 1,
}),
);
expect(result.details).toMatchObject({ status: "no_reply", sessionKey: targetSessionKey });
expect(decisionWork).toHaveLength(1);
expect(decisionWork[0]).toMatchObject({
receipt: {
action: { family: "session", operation: "send" },
decision: { outcome: "allowed", reasonCode: "session_send_committed" },
enforcement: { coverageState: "attribution-only" },
},
refs: {
target: { namespace: "session", value: `["main","${targetSessionKey}"]` },
},
});
} finally {
clearDecisionSink();
}
});
it("sessions_send returns pending agent error diagnostics on timeout", async () => {
const calls: Array<{ method?: string; params?: unknown }> = [];
callGatewayMock.mockImplementation(async (opts: unknown) => {
+20 -26
View File
@@ -96,6 +96,7 @@ import {
} from "./session-status-session-resolve.js";
import {
createAgentToAgentPolicy,
formatSessionToolAccessDenial,
resolveCurrentSessionClientAlias,
resolveEffectiveSessionToolsVisibility,
resolveSandboxedSessionToolContext,
@@ -668,7 +669,7 @@ export function createSessionStatusTool(opts?: {
if (cached) {
return cached;
}
let access = await resolveSessionToolAccess({
const access = await resolveSessionToolAccess({
action: "status",
requesterAgentId,
requesterSessionKey: visibilityRequesterKey,
@@ -681,28 +682,6 @@ export function createSessionStatusTool(opts?: {
a2aPolicy,
callGateway: gatewayCall,
});
if (
!access.allowed &&
target.targetAgentId !== requesterAgentId &&
!target.requesterOwned &&
!target.authorizationTargetSessionKey.startsWith("agent:") &&
!access.error.includes("ownership lookup failed")
) {
if (!a2aPolicy.enabled) {
access = {
allowed: false,
status: "forbidden",
error:
"Agent-to-agent status is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.",
};
} else if (!a2aPolicy.isAllowed(requesterAgentId, target.targetAgentId)) {
access = {
allowed: false,
status: "forbidden",
error: "Agent-to-agent session status denied by tools.agentToAgent.allow.",
};
}
}
accessByTarget.set(cacheKey, access);
return access;
};
@@ -779,7 +758,12 @@ export function createSessionStatusTool(opts?: {
requesterOwned: false,
});
if (!access.allowed) {
throw new Error(access.error);
throw new Error(
formatSessionToolAccessDenial(access, {
action: "status",
targetSessionKey: requestedKeyInput,
}),
);
}
}
let storePath = resolveSessionStorePathCore(cfg.session?.store, { agentId });
@@ -849,7 +833,12 @@ export function createSessionStatusTool(opts?: {
requesterOwned: visibleSession.requesterOwned,
});
if (!access.allowed) {
throw new Error(access.error);
throw new Error(
formatSessionToolAccessDenial(access, {
action: "status",
targetSessionKey: visibleSession.displayKey,
}),
);
}
}
resolvedRequesterOwned = visibleSession.requesterOwned;
@@ -966,7 +955,12 @@ export function createSessionStatusTool(opts?: {
requesterOwned: resolvedRequesterOwned,
});
if (!access.allowed) {
throw new Error(access.error);
throw new Error(
formatSessionToolAccessDenial(access, {
action: "status",
targetSessionKey: requestedKeyInput,
}),
);
}
let scopedResolved = resolved;
+212 -7
View File
@@ -1,6 +1,14 @@
// Sessions access tests cover session-tool visibility policy, sandbox clamps,
// and agent-to-agent allow rules.
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../../test/helpers/temp-dir.js";
import { pageExecutionDecisionFactsForContext } from "../../audit/execution-decision-facts.js";
import { configureExecutionDecisionWorkSink } from "../../audit/execution-decision-work.js";
import {
createExecutionIdentityAdmissionToken,
enqueueExecutionIdentityContextAtAdmission,
} from "../../audit/execution-identity-admission.js";
import { startAgentLocalAuditWriter } from "../../commands/agent-local-audit.js";
import type { OpenClawConfig } from "../../config/config.js";
import { GatewayCredentialsRequiredError } from "../../gateway/call.js";
import { GatewayClientRequestError } from "../../gateway/client.js";
@@ -13,13 +21,30 @@ import {
resolveSandboxSessionToolsVisibility,
resolveSessionToolsVisibility,
} from "../../plugin-sdk/session-visibility.js";
import { resolveSandboxedSessionToolContext, resolveSessionToolAccess } from "./sessions-access.js";
import { withGatewayToolCallerIdentity } from "./gateway-caller-context.js";
import {
formatSessionToolAccessDenial,
resolveSandboxedSessionToolContext,
resolveSessionToolAccess,
} from "./sessions-access.js";
const loggerMocks = vi.hoisted(() => ({ logWarn: vi.fn() }));
const gatewayMocks = vi.hoisted(() => ({ callGateway: vi.fn() }));
vi.mock("../../logger.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../logger.js")>()),
logWarn: loggerMocks.logWarn,
}));
vi.mock("../../gateway/call.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../gateway/call.js")>()),
callGateway: gatewayMocks.callGateway,
}));
beforeEach(() => {
gatewayMocks.callGateway.mockReset();
});
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
const AUDIT_REF_RE = /^hmac-sha256:v1:[a-f0-9]{32}:[a-f0-9]{64}$/u;
function makeConfig(overrides: Partial<OpenClawConfig> = {}): OpenClawConfig {
return overrides;
@@ -471,6 +496,161 @@ describe("createSessionVisibilityGuard", () => {
expect(gateway).toHaveBeenCalledWith(expect.objectContaining({ method: "sessions.resolve" }));
});
it("returns a private typed denial without presentation text", async () => {
const access = await resolveSessionToolAccess({
action: "send",
requesterAgentId: "main",
requesterSessionKey: "agent:main:main",
targetAgentId: "ops",
targetSessionKey: "agent:ops:main",
requesterOwned: false,
visibility: "all",
a2aPolicy: createAgentToAgentPolicy(makeConfig()),
});
expect(access).toMatchObject({
allowed: false,
status: "forbidden",
reasonCode: expect.any(String),
policyRefs: expect.arrayContaining(["tools.agentToAgent.enabled"]),
});
expect(access).not.toHaveProperty("error");
});
it("persists unknown ownership evidence with an opaque target through the local writer", async () => {
const now = Date.now();
const stateDir = tempDirs.make("openclaw-session-access-audit-");
const database = { env: { OPENCLAW_STATE_DIR: stateDir } };
const stopWriter = startAgentLocalAuditWriter({ stateDir });
if (!stopWriter) {
throw new Error("expected an isolated direct-local audit writer");
}
const token = createExecutionIdentityAdmissionToken("session-access-run", {
contextId: "session-access-context",
executionId: "session-access-execution",
now,
});
expect(
enqueueExecutionIdentityContextAtAdmission(
{
runId: token.runId,
agentId: "main",
ingress: { kind: "local-cli", boundary: "agent-command.local" },
runtime: { kind: "embedded" },
},
{ enabled: true, token, runtimeInstanceId: "session-access-runtime" },
),
).toMatchObject({ accepted: true });
const targetSessionKey = "agent:main:dashboard:owner-unknown";
gatewayMocks.callGateway.mockRejectedValue(
new GatewayClientRequestError({
code: "UNAVAILABLE",
message: "transport timeout",
retryable: true,
}),
);
try {
await withGatewayToolCallerIdentity(
{
agentId: "main",
sessionKey: "agent:main:main",
executionIdentityToken: token,
receiptAuthority: () => true,
},
async () => {
const access = await resolveSessionToolAccess({
action: "send",
requesterAgentId: "main",
requesterSessionKey: "agent:main:main",
targetAgentId: "main",
targetSessionKey,
requesterOwned: false,
visibility: "tree",
a2aPolicy: createAgentToAgentPolicy(makeConfig()),
});
expect(access).toMatchObject({
allowed: false,
reasonCode: "session_ownership_lookup_failed_transient",
contextFieldsUsed: ["requesterSessionKey", "targetSessionKey"],
missingEvidence: ["session.owner"],
});
},
);
} finally {
await stopWriter();
}
const page = pageExecutionDecisionFactsForContext({
context: token,
limit: 10,
now: now + 1,
database,
});
expect(page.receipts).toHaveLength(1);
expect(page.receipts[0]).toMatchObject({
contextId: token.contextId,
executionId: token.executionId,
runId: token.runId,
action: {
family: "session",
operation: "send",
targetRef: expect.stringMatching(AUDIT_REF_RE),
},
decision: { outcome: "denied", reasonCode: "session_ownership_lookup_failed_transient" },
enforcement: {
coverageState: "unknown",
policyRefs: ["tools.sessions.visibility"],
contextFieldsUsed: ["requesterSessionKey", "targetSessionKey"],
},
missingEvidence: ["session.owner"],
});
expect(JSON.stringify(page.receipts)).not.toContain(targetSessionKey);
});
it("revalidates the exact receipt authority after awaited ownership lookup", async () => {
const decisionWork: unknown[] = [];
const clear = configureExecutionDecisionWorkSink((work) => {
decisionWork.push(work);
return true;
});
const token = createExecutionIdentityAdmissionToken("session-access-closed-run", {
contextId: "session-access-closed-context",
executionId: "session-access-closed-execution",
});
let authorityActive = true;
gatewayMocks.callGateway.mockImplementation(async () => {
authorityActive = false;
return { sessions: [] };
});
try {
const access = await withGatewayToolCallerIdentity(
{
agentId: "main",
sessionKey: "agent:main:main",
executionIdentityToken: token,
receiptAuthority: () => authorityActive,
},
async () =>
await resolveSessionToolAccess({
action: "history",
requesterAgentId: "main",
requesterSessionKey: "agent:main:main",
targetAgentId: "main",
targetSessionKey: "agent:main:dashboard:unowned",
requesterOwned: false,
visibility: "tree",
a2aPolicy: createAgentToAgentPolicy(makeConfig()),
}),
);
expect(access).toMatchObject({ allowed: false, reasonCode: "tree_visibility_restricted" });
expect(decisionWork).toEqual([]);
} finally {
clear();
}
});
it("falls back to spawned-session listing when the exact resolver is unavailable", async () => {
const gateway = vi.fn(async (request: { method?: string }) => {
if (request.method === "sessions.resolve") {
@@ -525,9 +705,16 @@ describe("createSessionVisibilityGuard", () => {
expect(access).toEqual({
allowed: false,
status: "forbidden",
error:
"Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.",
reasonCode: "agent_to_agent_disabled",
policyRefs: ["tools.agentToAgent.enabled"],
contextFieldsUsed: ["requesterAgentId", "targetAgentId"],
missingEvidence: [],
});
if (!access.allowed) {
expect(formatSessionToolAccessDenial(access, { action: "send" })).toBe(
"Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.",
);
}
expect(gateway).not.toHaveBeenCalled();
});
@@ -582,8 +769,19 @@ describe("createSessionVisibilityGuard", () => {
expect(access).toEqual({
allowed: false,
status: "forbidden",
error: `Session not visible from session tools: ${targetSessionKey}`,
reasonCode: "incognito_session",
policyRefs: ["sessions.incognito"],
contextFieldsUsed: ["targetSessionKey"],
missingEvidence: [],
});
if (!access.allowed) {
expect(
formatSessionToolAccessDenial(access, {
action: "history",
targetSessionKey,
}),
).toBe(`Session not visible from session tools: ${targetSessionKey}`);
}
expect(gateway).not.toHaveBeenCalled();
} finally {
unregister();
@@ -614,9 +812,16 @@ describe("createSessionVisibilityGuard", () => {
expect(access).toEqual({
allowed: false,
status: "forbidden",
error:
"Session history denied because spawned-session ownership lookup failed (transient); retry once, then ask the operator to inspect OpenClaw logs.",
reasonCode: "session_ownership_lookup_failed_transient",
policyRefs: ["tools.sessions.visibility"],
contextFieldsUsed: ["requesterSessionKey", "targetSessionKey"],
missingEvidence: ["session.owner"],
});
if (!access.allowed) {
expect(formatSessionToolAccessDenial(access, { action: "history" })).toBe(
"Session history denied because spawned-session ownership lookup failed (transient); retry once, then ask the operator to inspect OpenClaw logs.",
);
}
expect(gateway).toHaveBeenCalledTimes(1);
});
+180 -14
View File
@@ -3,24 +3,32 @@
*
* Adds OpenClaw session-key alias normalization and sandbox requester scoping over SDK visibility contracts.
*/
import { randomUUID } from "node:crypto";
import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { recordExecutionDecisionWork } from "../../audit/execution-decision-work.js";
import { SESSION_LIFECYCLE_CHANGED_ERROR_REASON } from "../../config/sessions/lifecycle.js";
import { resolveCanonicalMainSessionKey } from "../../config/sessions/main-session-key.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { isGatewayClientRequestError } from "../../gateway/call.js";
import {
createSessionVisibilityDecisionChecker,
logSessionOwnershipLookupFailure,
lookupFailedDenialMessage,
renderSessionVisibilityDenial,
sessionOwnershipLookupDenied,
type SessionVisibilityDecision,
type SessionVisibilityDecisionPresentationAction,
} from "../../plugin-sdk/session-visibility-internal.js";
import {
createSessionVisibilityChecker,
createSessionVisibilityRowChecker,
resolveSandboxSessionToolsVisibility,
type AgentToAgentPolicy,
type SessionAccessAction,
type SessionAccessResult,
type SessionToolsVisibility,
} from "../../plugin-sdk/session-visibility.js";
import { isSubagentSessionKey, parseAgentSessionKey } from "../../routing/session-key.js";
import { resolveSessionAgentId } from "../agent-scope.js";
import { getGatewayToolCallerIdentity } from "./gateway-caller-context.js";
import type { AgentToolGatewayRequestCaller } from "./in-process-gateway.js";
import {
lookupRequesterSessionOwnership,
@@ -34,6 +42,152 @@ export {
resolveEffectiveSessionToolsVisibility,
} from "../../plugin-sdk/session-visibility.js";
type SessionToolAccessDenied = Extract<SessionVisibilityDecision, { allowed: false }>;
export type SessionToolAccessResult = SessionVisibilityDecision;
export type SessionToolActionOperation =
| "archive"
| "create"
| "delete"
| "fork"
| "patch"
| "reset"
| "restore"
| "send";
export type SessionToolActionFact = "committed" | "conflict" | "no-op" | "scheduled";
/** Render operator guidance only when a tool presents a private access decision. */
export const formatSessionToolAccessDenial = renderSessionVisibilityDenial;
function recordAdmittedSessionDecision(params: {
action: SessionVisibilityDecisionPresentationAction | SessionToolActionOperation;
targetAgentId: string;
targetSessionKey: string;
outcome: "allowed" | "denied" | "not-applicable";
reasonCode: string;
coverageState: "attribution-only" | "enforced" | "unknown";
policyRefs?: string[];
contextFieldsUsed: string[];
missingEvidence?: string[];
owner: "session-access" | "session-action";
decisionBoundary: "session-tool.access" | "session-tool.result";
}): boolean {
const caller = getGatewayToolCallerIdentity();
if (!caller?.executionIdentityToken || !caller.receiptAuthority) {
return false;
}
try {
if (caller.receiptAuthority() === false) {
return false;
}
} catch {
return false;
}
const receiptId = `${params.owner}:${randomUUID()}`;
return recordExecutionDecisionWork({
workVersion: 1,
token: caller.executionIdentityToken,
receipt: {
schemaVersion: 1,
receiptId,
occurredAt: Date.now(),
action: { family: "session", operation: params.action },
decision: { outcome: params.outcome, reasonCode: params.reasonCode },
enforcement: {
coverageState: params.coverageState,
policyRefs: params.policyRefs ?? [],
grantRefs: [],
contextFieldsUsed: params.contextFieldsUsed,
},
source: {
owner: params.owner,
recordRef: receiptId,
decisionBoundary: params.decisionBoundary,
},
missingEvidence: params.missingEvidence ?? [],
remediation: [],
},
refs: {
target: {
namespace: "session",
value: JSON.stringify([params.targetAgentId, params.targetSessionKey]),
},
},
});
}
function recordAdmittedSessionAccessDenial(params: {
action: SessionVisibilityDecisionPresentationAction;
targetAgentId: string;
targetSessionKey: string;
denial: SessionToolAccessDenied;
}): boolean {
return recordAdmittedSessionDecision({
action: params.action,
targetAgentId: params.targetAgentId,
targetSessionKey: params.targetSessionKey,
outcome: "denied",
reasonCode: params.denial.reasonCode,
coverageState: params.denial.missingEvidence.length > 0 ? "unknown" : "enforced",
policyRefs: params.denial.policyRefs,
contextFieldsUsed: params.denial.contextFieldsUsed,
missingEvidence: params.denial.missingEvidence,
owner: "session-access",
decisionBoundary: "session-tool.access",
});
}
/** Queue an owner-native model-mediated session result after its final await. */
export function recordSessionToolActionFact(params: {
operation: SessionToolActionOperation;
fact: SessionToolActionFact;
targetAgentId: string;
targetSessionKey: string;
}): boolean {
const reasonCode = `session_${params.operation.replaceAll("-", "_")}_${params.fact.replaceAll("-", "_")}`;
return recordAdmittedSessionDecision({
action: params.operation,
targetAgentId: params.targetAgentId,
targetSessionKey: params.targetSessionKey,
outcome:
params.fact === "conflict"
? "denied"
: params.fact === "no-op"
? "not-applicable"
: "allowed",
reasonCode,
coverageState: "attribution-only",
contextFieldsUsed: ["targetAgentId", "sessionActionResult"],
owner: "session-action",
decisionBoundary: "session-tool.result",
});
}
/** Record owner-native lifecycle conflicts without classifying presentation text. */
export async function runSessionToolActionWithConflictReceipt<T>(params: {
operation: "archive" | "delete" | "patch" | "reset" | "restore";
targetAgentId: string;
targetSessionKey: string;
run: () => Promise<T>;
}): Promise<T> {
try {
return await params.run();
} catch (error) {
if (
isGatewayClientRequestError(error) &&
isRecord(error.details) &&
error.details.reason === SESSION_LIFECYCLE_CHANGED_ERROR_REASON
) {
recordSessionToolActionFact({
operation: params.operation,
fact: "conflict",
targetAgentId: params.targetAgentId,
targetSessionKey: params.targetSessionKey,
});
}
throw error;
}
}
/** Check one prepared target without re-listing the requester's spawned sessions. */
export async function resolveSessionToolAccess(params: {
action: Exclude<SessionAccessAction, "list">;
@@ -48,9 +202,18 @@ export async function resolveSessionToolAccess(params: {
visibility: SessionToolsVisibility;
a2aPolicy: AgentToAgentPolicy;
callGateway?: AgentToolGatewayRequestCaller;
}): Promise<SessionAccessResult> {
}): Promise<SessionToolAccessResult> {
const authorizationTargetSessionKey =
params.authorizationTargetSessionKey ?? params.targetSessionKey;
const deny = (denial: SessionToolAccessDenied) => {
recordAdmittedSessionAccessDenial({
action: params.displayAction ?? params.action,
targetAgentId: params.targetAgentId,
targetSessionKey: authorizationTargetSessionKey,
denial,
});
return denial;
};
const scoped = createSessionVisibilityChecker.resolveScopedAccess({
action: params.action,
requesterSessionKey: params.requesterSessionKey,
@@ -61,17 +224,18 @@ export async function resolveSessionToolAccess(params: {
if (scoped) {
return { allowed: true, expectedSessionId: scoped.expectedSessionId };
}
const rowChecker = createSessionVisibilityRowChecker({
const decisionChecker = createSessionVisibilityDecisionChecker({
action: params.action,
defaultAgentId: params.targetAgentId,
requesterAgentId: params.requesterAgentId,
requesterSessionKey: params.requesterSessionKey,
mainSessionKey: params.mainSessionKey,
explicitTargetAgentOwnership: !parseAgentSessionKey(authorizationTargetSessionKey),
visibility: params.visibility,
a2aPolicy: params.a2aPolicy,
});
const check = (requesterOwned: boolean) =>
rowChecker.check({
decisionChecker.check({
key: authorizationTargetSessionKey,
agentId: params.targetAgentId,
...(requesterOwned ? { spawnedBy: params.requesterSessionKey } : {}),
@@ -82,12 +246,15 @@ export async function resolveSessionToolAccess(params: {
}
const requesterOwnedAccess = check(true);
if (params.requesterOwned) {
return requesterOwnedAccess;
if (requesterOwnedAccess.allowed) {
return requesterOwnedAccess;
}
return deny(requesterOwnedAccess);
}
// Ownership proof can only widen tree visibility; do not let an operational
// lookup failure replace a deterministic self/A2A policy denial.
if (!requesterOwnedAccess.allowed) {
return initial;
return deny(initial);
}
const ownership = await lookupRequesterSessionOwnership({
requesterSessionKey: params.requesterSessionKey,
@@ -101,13 +268,12 @@ export async function resolveSessionToolAccess(params: {
requesterSessionKey: params.requesterSessionKey,
failure: ownership.error,
});
return {
allowed: false,
status: "forbidden",
error: lookupFailedDenialMessage(params.displayAction ?? params.action, ownership.error.kind),
};
return deny(sessionOwnershipLookupDenied(ownership.error.kind));
}
return ownership.value ? requesterOwnedAccess : initial;
if (ownership.value) {
return requesterOwnedAccess;
}
return deny(initial);
}
/** Resolves the requester context used to filter sandboxed session-tool access. */
+2
View File
@@ -16,6 +16,8 @@ import { resolveSandboxedSessionToolContext } from "./sessions-access.js";
export {
createAgentToAgentPolicy,
createSessionVisibilityRowChecker,
formatSessionToolAccessDenial,
recordSessionToolActionFact,
resolveEffectiveSessionToolsVisibility,
resolveSandboxedSessionToolContext,
resolveSessionToolAccess,
+5 -1
View File
@@ -40,6 +40,7 @@ import {
import {
createSessionVisibilityRowChecker,
createAgentToAgentPolicy,
formatSessionToolAccessDenial,
resolveEffectiveSessionToolsVisibility,
resolveSessionReference,
resolveSandboxedSessionToolContext,
@@ -497,7 +498,10 @@ export function createSessionsHistoryTool(opts?: {
if (!access.allowed) {
return jsonResult({
status: access.status,
error: access.error,
error: formatSessionToolAccessDenial(access, {
action: "history",
targetSessionKey: displayKey,
}),
});
}
+8 -1
View File
@@ -35,6 +35,7 @@ import {
import {
createAgentToAgentPolicy,
createSessionVisibilityRowChecker,
formatSessionToolAccessDenial,
resolveDisplaySessionKey,
resolveEffectiveSessionToolsVisibility,
resolveSandboxedSessionToolContext,
@@ -482,7 +483,13 @@ export function createSessionsSearchTool(opts?: {
callGateway: gatewayCall,
});
if (!access.allowed) {
return jsonResult({ status: access.status, error: access.error });
return jsonResult({
status: access.status,
error: formatSessionToolAccessDenial(access, {
action: "search",
targetSessionKey: key,
}),
});
}
if (access.expectedSessionId) {
sessionTarget.expectedSessionId = access.expectedSessionId;
+18 -1
View File
@@ -81,7 +81,9 @@ import { runWithScopedSessionAccess } from "./scoped-session-access.js";
import {
createSessionVisibilityRowChecker,
createAgentToAgentPolicy,
formatSessionToolAccessDenial,
isExpectedSessionLookupMiss,
recordSessionToolActionFact,
resolveDisplaySessionKey,
resolveEffectiveSessionToolsVisibility,
resolveSessionReference,
@@ -918,7 +920,10 @@ export function createSessionsSendTool(opts?: {
return jsonResult({
runId: crypto.randomUUID(),
status: access.status,
error: access.error,
error: formatSessionToolAccessDenial(access, {
action: "send",
targetSessionKey: unresolvedDisplayKey,
}),
sessionKey: unresolvedDisplayKey,
});
}
@@ -1149,6 +1154,12 @@ export function createSessionsSendTool(opts?: {
if (!start.ok) {
return start.result;
}
recordSessionToolActionFact({
operation: "send",
fact: "committed",
targetAgentId,
targetSessionKey: start.a2aSessionKey ?? resolvedKey,
});
recordSessionsSendParticipant({
cfg,
requesterAgentId,
@@ -1179,6 +1190,12 @@ export function createSessionsSendTool(opts?: {
if (!start.ok) {
return start.result;
}
recordSessionToolActionFact({
operation: "send",
fact: "committed",
targetAgentId,
targetSessionKey: start.a2aSessionKey ?? resolvedKey,
});
recordSessionsSendParticipant({
cfg,
requesterAgentId,
+97 -23
View File
@@ -3,6 +3,10 @@ import path from "node:path";
// dispatch, and result details for spawned child sessions.
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
configureExecutionDecisionWorkSink,
type ExecutionDecisionWork,
} from "../../audit/execution-decision-work.js";
import { createExecutionIdentityAdmissionToken } from "../../audit/execution-identity-admission.js";
import { upsertSessionEntryCore } from "../../config/sessions/session-accessor.js";
import { GatewayClientRequestError } from "../../gateway/client.js";
@@ -69,6 +73,35 @@ vi.mock("../../plugins/hook-runner-global.js", () => ({
let createSessionsSpawnTool: typeof import("./sessions-spawn-tool.js").createSessionsSpawnTool;
let acpRuntimeRegistry: typeof import("../../acp/runtime/registry.js");
async function captureSessionDecisionWork<T>(run: () => Promise<T>): Promise<{
result: T;
work: ExecutionDecisionWork[];
}> {
const work: ExecutionDecisionWork[] = [];
const clear = configureExecutionDecisionWorkSink((item) => {
work.push(item);
return true;
});
try {
const token = createExecutionIdentityAdmissionToken("sessions-spawn-action", {
contextId: "sessions-spawn-context",
executionId: "sessions-spawn-execution",
});
const result = await withGatewayToolCallerIdentity(
{
agentId: "main",
sessionKey: "agent:main:main",
executionIdentityToken: token,
receiptAuthority: () => true,
},
run,
);
return { result, work };
} finally {
clear();
}
}
describe("sessions_spawn tool", () => {
beforeAll(async () => {
({ createSessionsSpawnTool } = await import("./sessions-spawn-tool.js"));
@@ -372,13 +405,20 @@ describe("sessions_spawn tool", () => {
await expect(tool.execute("normal-child", { task: "ask for approval" })).rejects.toThrow(
"requires collect=true",
);
await tool.execute("collector-child", { task: "collect safely", collect: true });
const { work } = await captureSessionDecisionWork(
async () => await tool.execute("collector-child", { task: "collect safely", collect: true }),
);
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledOnce();
expect(hoisted.spawnSubagentDirectMock).toHaveBeenCalledWith(
expect.objectContaining({ collect: true }),
expect.any(Object),
);
expect(work[0]?.receipt).toMatchObject({
action: { family: "session", operation: "create" },
decision: { outcome: "allowed", reasonCode: "session_create_committed" },
enforcement: { coverageState: "attribution-only" },
});
});
it("forwards collector parameters and requesting run identity when enabled", async () => {
@@ -531,19 +571,22 @@ describe("sessions_spawn tool", () => {
countActiveRuns: () => 0,
});
const result = await tool.execute("visible", {
task: "inspect issue",
label: "Issue review",
category: "P1 issues from beta feedback",
model: "anthropic/claude-sonnet-4-6",
cwd: dir,
context: "fork",
visible: true,
worktree: true,
worktreeName: "issue-review",
worktreeBaseRef: "main",
cleanup: "delete",
});
const { result, work } = await captureSessionDecisionWork(
async () =>
await tool.execute("visible", {
task: "inspect issue",
label: "Issue review",
category: "P1 issues from beta feedback",
model: "anthropic/claude-sonnet-4-6",
cwd: dir,
context: "fork",
visible: true,
worktree: true,
worktreeName: "issue-review",
worktreeBaseRef: "main",
cleanup: "delete",
}),
);
expect(result.details).toMatchObject({
status: "accepted",
@@ -583,6 +626,20 @@ describe("sessions_spawn tool", () => {
}),
);
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
expect(work).toHaveLength(1);
expect(work[0]).toMatchObject({
receipt: {
action: { family: "session", operation: "fork" },
decision: { outcome: "allowed", reasonCode: "session_fork_committed" },
enforcement: { coverageState: "attribution-only" },
},
refs: {
target: {
namespace: "session",
value: '["main","agent:main:dashboard:child"]',
},
},
});
});
});
@@ -1835,15 +1892,18 @@ describe("sessions_spawn tool", () => {
currentMessageId: "message-789",
});
const result = await tool.execute("call-2", {
runtime: "acp",
task: "investigate the failing CI run",
agentId: "codex",
cwd: "/workspace",
thread: true,
mode: "session",
streamTo: "parent",
});
const { result, work } = await captureSessionDecisionWork(
async () =>
await tool.execute("call-2", {
runtime: "acp",
task: "investigate the failing CI run",
agentId: "codex",
cwd: "/workspace",
thread: true,
mode: "session",
streamTo: "parent",
}),
);
expectDetailFields(result.details, {
status: "accepted",
@@ -1867,6 +1927,20 @@ describe("sessions_spawn tool", () => {
expect(spawnContext.currentChannelId).toBe("source-native");
expect(spawnContext.currentMessageId).toBe("message-789");
expect(hoisted.spawnSubagentDirectMock).not.toHaveBeenCalled();
expect(work).toHaveLength(1);
expect(work[0]).toMatchObject({
receipt: {
action: { family: "session", operation: "create" },
decision: { outcome: "allowed", reasonCode: "session_create_committed" },
enforcement: { coverageState: "attribution-only" },
},
refs: {
target: {
namespace: "session",
value: '["codex","agent:codex:acp:1"]',
},
},
});
// Registration and progress hooks now belong to the shared backend pipeline.
expect(hoisted.registerSubagentRunMock).not.toHaveBeenCalled();
expect(hoisted.runSubagentProgressMock).not.toHaveBeenCalled();
+24
View File
@@ -52,6 +52,7 @@ import {
import { getGatewayToolCallerIdentity } from "./gateway-caller-context.js";
import { runWithScopedSessionAccess } from "./scoped-session-access.js";
import {
recordSessionToolActionFact,
resolveEffectiveSessionToolsVisibility,
resolveSandboxedSessionToolContext,
} from "./sessions-helpers.js";
@@ -95,6 +96,26 @@ function addRoleToFailureResult<T extends { status: string }>(
return { ...result, role };
}
function recordAcceptedSessionSpawn(
result: Record<string, unknown>,
context: "fork" | "isolated" | undefined,
): void {
const childSessionKey =
typeof result.childSessionKey === "string" ? result.childSessionKey.trim() : "";
const targetAgentId = childSessionKey
? parseAgentSessionKey(childSessionKey)?.agentId
: undefined;
if (result.status !== "accepted" || !childSessionKey || !targetAgentId) {
return;
}
recordSessionToolActionFact({
operation: context === "fork" ? "fork" : "create",
fact: "committed",
targetAgentId,
targetSessionKey: childSessionKey,
});
}
type SessionsSpawnThreadAvailability = {
subagent: boolean;
acp: boolean;
@@ -443,6 +464,7 @@ export function createSessionsSpawnTool(
})
: await spawnVisible();
if (visibleResult) {
recordAcceptedSessionSpawn(visibleResult, context);
return jsonResult(
addRoleToFailureResult(visibleResult as { status: string }, requestedAgentId),
);
@@ -548,6 +570,7 @@ export function createSessionsSpawnTool(
parentExecutionIdentityToken,
),
);
recordAcceptedSessionSpawn(result, context);
return jsonResult(addRoleToFailureResult(result, requestedAgentId));
}
@@ -619,6 +642,7 @@ export function createSessionsSpawnTool(
),
);
recordAcceptedSessionSpawn(result, context);
return jsonResult(addRoleToFailureResult(result, requestedAgentId));
},
};
@@ -0,0 +1,205 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createSessionsTool } from "./sessions-tool.js";
import {
adversarialResolved,
escapeHeavyResolved,
expectExactResolvedAcknowledgement,
expectOmittedResolvedAcknowledgement,
expectedResolvedOmission,
} from "./sessions-tool.test-helpers.js";
const gatewayMocks = vi.hoisted(() => ({ callGateway: vi.fn() }));
vi.mock("../../gateway/call.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../gateway/call.js")>()),
callGateway: gatewayMocks.callGateway,
}));
beforeEach(() => {
gatewayMocks.callGateway.mockReset();
});
function createTool() {
return createSessionsTool({
agentSessionKey: "agent:main:main",
config: {},
hasInProcessGatewayContext: () => true,
});
}
describe("sessions tool responses", () => {
it("routes group actions to existing gateway methods", async () => {
gatewayMocks.callGateway.mockImplementation(async (request) => request);
const tool = createTool();
await tool.execute("list", { action: "group_list" });
await tool.execute("set", { action: "group_set", names: ["Now", "Later"] });
await tool.execute("rename", { action: "group_rename", name: "Now", to: "Next" });
await tool.execute("delete", { action: "group_delete", name: "Later" });
expect(gatewayMocks.callGateway.mock.calls).toEqual([
[{ method: "sessions.groups.list", params: {} }],
[{ method: "sessions.groups.put", params: { names: ["Now", "Later"] } }],
[{ method: "sessions.groups.rename", params: { name: "Now", to: "Next" } }],
[{ method: "sessions.groups.delete", params: { name: "Later" } }],
]);
await expect(tool.execute("set-missing", { action: "group_set" })).rejects.toThrow(
"names required",
);
await expect(
tool.execute("set-invalid", { action: "group_set", names: ["Now", null] }),
).rejects.toThrow("names[1] required");
expect(gatewayMocks.callGateway).toHaveBeenCalledTimes(4);
});
it("returns a bounded acknowledgement instead of the patched session entry", async () => {
gatewayMocks.callGateway.mockResolvedValue({
ok: true,
path: `/sessions/${"p".repeat(10_000)}`,
key: "agent:main:main",
entry: {
skillsSnapshot: "s".repeat(47_469),
sessionDiffBaseline: "b".repeat(3_665),
},
resolved: { modelProvider: "openai", model: "gpt-5.6-luna" },
});
const tool = createTool();
const result = await tool.execute("patch-sidebar", { action: "patch", label: "Movies" });
expect(gatewayMocks.callGateway).toHaveBeenCalledWith({
method: "sessions.patch",
params: { key: "agent:main:main", label: "Movies" },
});
expect(result.details).toEqual({
status: "updated",
sessionKey: "agent:main:main",
updated: ["label"],
});
const text = (result.content[0] as { text?: string } | undefined)?.text ?? "";
expect(text).not.toContain('"entry"');
expect(text).not.toContain('"path"');
expect(text).not.toContain('"resolved"');
expect(text).not.toContain("skillsSnapshot");
expect(text).not.toContain("sessionDiffBaseline");
expect(Buffer.byteLength(text, "utf8")).toBeLessThan(512);
});
it("returns authoritative resolved model and thinking metadata without the patched entry", async () => {
const resolved = {
modelProvider: "openai",
model: "gpt-5.6-luna",
agentRuntime: { id: "codex", fallback: "openclaw" as const, source: "session" as const },
thinkingLevel: "medium",
thinkingLevels: [
{ id: "off", label: "Off" },
{ id: "medium", label: "Medium" },
],
};
gatewayMocks.callGateway.mockResolvedValue({
ok: true,
path: `/sessions/${"p".repeat(10_000)}`,
key: "agent:main:main",
entry: { skillsSnapshot: "s".repeat(47_469) },
resolved,
});
const tool = createTool();
const result = await tool.execute("patch-model-thinking", {
action: "patch",
model: "openai/luna",
thinkingLevel: "med",
});
expect(result.details).toEqual({
status: "updated",
sessionKey: "agent:main:main",
updated: ["model", "thinkingLevel"],
resolved,
});
const text = (result.content[0] as { text?: string } | undefined)?.text ?? "";
expect(text).not.toContain('"entry"');
expect(text).not.toContain('"path"');
expect(text).not.toContain("skillsSnapshot");
expect(Buffer.byteLength(text, "utf8")).toBeLessThan(1_024);
});
it("preserves the complete canonical thinking catalog through ultra", async () => {
const thinkingLevels = [
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"adaptive",
"max",
"ultra",
].map((id) => ({ id, label: id }));
gatewayMocks.callGateway.mockResolvedValue({
ok: true,
path: "/sessions/main",
key: "agent:main:main",
entry: {},
resolved: { thinkingLevel: "ultra", thinkingLevels },
});
const tool = createTool();
const result = await tool.execute("patch-ultra-thinking", {
action: "patch",
thinkingLevel: "ultra",
});
expect(result.details).toMatchObject({
resolved: { thinkingLevel: "ultra", thinkingLevels },
});
});
it("preserves long resolved identifiers and complete catalogs exactly when they fit", async () => {
gatewayMocks.callGateway.mockResolvedValue({
ok: true,
path: `/sessions/${"p".repeat(10_000)}`,
key: "agent:main:main",
entry: { skillsSnapshot: "s".repeat(47_469) },
resolved: adversarialResolved,
});
const tool = createTool();
const result = await tool.execute("patch-adversarial-model-thinking", {
action: "patch",
model: "openai/luna",
thinkingLevel: "med",
});
expect(result.details).toMatchObject({
status: "updated",
sessionKey: "agent:main:main",
updated: ["model", "thinkingLevel"],
});
expectExactResolvedAcknowledgement(result, adversarialResolved);
});
it("omits oversized resolved metadata instead of changing authoritative identifiers", async () => {
gatewayMocks.callGateway.mockResolvedValue({
ok: true,
path: `/sessions/${"p".repeat(10_000)}`,
key: "agent:main:main",
entry: { skillsSnapshot: "s".repeat(47_469) },
resolved: escapeHeavyResolved,
});
const tool = createTool();
const result = await tool.execute("patch-oversized-model-thinking", {
action: "patch",
model: "openai/luna",
thinkingLevel: "med",
});
expect(result.details).toMatchObject({
status: "updated",
sessionKey: "agent:main:main",
updated: ["model", "thinkingLevel"],
resolvedOmitted: expectedResolvedOmission,
});
expectOmittedResolvedAcknowledgement(result);
});
});
+237 -232
View File
@@ -1,5 +1,10 @@
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
configureExecutionDecisionWorkSink,
type ExecutionDecisionWork,
} from "../../audit/execution-decision-work.js";
import { createExecutionIdentityAdmissionToken } from "../../audit/execution-identity-admission.js";
import {
loadSessionEntry,
loadTranscriptEvents,
@@ -7,22 +12,60 @@ import {
upsertSessionEntryCore,
} from "../../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { GatewayClientRequestError } from "../../gateway/client.js";
import { isAgentSessionModelPatchOrigin } from "../../gateway/session-model-patch-origin.js";
import { beginSessionWorkAdmission } from "../../sessions/session-lifecycle-admission.js";
import { withTestDir } from "../../test-helpers/temp-dir.js";
import { createAgentPatchedSessionModelRunGuard } from "../session-model-auto-revert.js";
import { withGatewayToolCallerIdentity } from "./gateway-caller-context.js";
import type { AgentToolGatewayRequestCaller } from "./in-process-gateway.js";
import { createSessionsTool } from "./sessions-tool.js";
import {
adversarialResolved,
escapeHeavyResolved,
expectExactResolvedAcknowledgement,
expectOmittedResolvedAcknowledgement,
expectedResolvedOmission,
} from "./sessions-tool.test-helpers.js";
const gatewayMocks = vi.hoisted(() => ({ callGateway: vi.fn() }));
vi.mock("../../gateway/call.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../../gateway/call.js")>()),
callGateway: gatewayMocks.callGateway,
}));
beforeEach(() => {
gatewayMocks.callGateway.mockReset();
});
type AgentToolGatewayRequest = Parameters<AgentToolGatewayRequestCaller>[0];
async function captureSessionDecisionWork<T>(run: () => Promise<T>): Promise<{
result: T;
work: ExecutionDecisionWork[];
}> {
const work: ExecutionDecisionWork[] = [];
const clear = configureExecutionDecisionWorkSink((item) => {
work.push(item);
return true;
});
try {
const token = createExecutionIdentityAdmissionToken("sessions-tool-action", {
contextId: "sessions-tool-context",
executionId: "sessions-tool-execution",
});
const result = await withGatewayToolCallerIdentity(
{
agentId: "main",
sessionKey: "agent:main:main",
executionIdentityToken: token,
receiptAuthority: () => true,
},
run,
);
return { result, work };
} finally {
clear();
}
}
describe("sessions tool", () => {
it("carries the persisted fixed-store owner for a bare patch key", async () => {
const callGateway = vi.fn().mockResolvedValue({});
@@ -257,11 +300,14 @@ describe("sessions tool", () => {
callGateway: callGateway as never,
});
await tool.execute("delete-session", {
action: "delete",
sessionKey,
expectedSessionId: sessionId,
});
const { work } = await captureSessionDecisionWork(
async () =>
await tool.execute("delete-session", {
action: "delete",
sessionKey,
expectedSessionId: sessionId,
}),
);
expect(callGateway.mock.calls).toEqual([
[
@@ -283,6 +329,15 @@ describe("sessions tool", () => {
},
],
]);
expect(work).toHaveLength(1);
expect(work[0]).toMatchObject({
receipt: {
action: { family: "session", operation: "delete" },
decision: { outcome: "allowed", reasonCode: "session_delete_committed" },
enforcement: { coverageState: "attribution-only" },
},
refs: { target: { namespace: "session", value: `["main","${sessionKey}"]` } },
});
});
it("does not discover a lifecycle identity while deleting another session", async () => {
@@ -308,7 +363,7 @@ describe("sessions tool", () => {
const callGateway = vi.fn(async (request: { method: string }) =>
request.method === "sessions.patch"
? { ok: true, entry: { sessionId } }
: { ok: true, deleted: true },
: { ok: true, deleted: false },
);
const tool = createSessionsTool({
agentSessionKey: "agent:main:main",
@@ -316,12 +371,15 @@ describe("sessions tool", () => {
callGateway: callGateway as never,
});
await tool.execute("delete-preserve", {
action: "delete",
sessionKey,
expectedSessionId: sessionId,
deleteTranscript: false,
});
const { work } = await captureSessionDecisionWork(
async () =>
await tool.execute("delete-preserve", {
action: "delete",
sessionKey,
expectedSessionId: sessionId,
deleteTranscript: false,
}),
);
expect(callGateway).toHaveBeenLastCalledWith({
method: "sessions.delete",
@@ -332,6 +390,10 @@ describe("sessions tool", () => {
deleteTranscript: false,
},
});
expect(work[0]?.receipt).toMatchObject({
decision: { outcome: "allowed", reasonCode: "session_delete_committed" },
enforcement: { coverageState: "attribution-only" },
});
});
it("does not delete a session when archive cannot identify its generation", async () => {
@@ -372,7 +434,9 @@ describe("sessions tool", () => {
callGateway: callGateway as never,
});
await tool.execute("reset-session", { action: "reset", sessionKey });
const { work } = await captureSessionDecisionWork(
async () => await tool.execute("reset-session", { action: "reset", sessionKey }),
);
expect(callGateway).toHaveBeenCalledWith({
method: "sessions.reset",
@@ -381,6 +445,143 @@ describe("sessions tool", () => {
reason: "reset",
},
});
expect(work[0]?.receipt).toMatchObject({
action: { family: "session", operation: "reset" },
decision: { outcome: "allowed", reasonCode: "session_reset_committed" },
enforcement: { coverageState: "attribution-only" },
});
});
it("records a typed lifecycle conflict without classifying error prose", async () => {
const sessionKey = "agent:main:dashboard:changed";
const callGateway: AgentToolGatewayRequestCaller = vi.fn(async () => {
throw new GatewayClientRequestError({
code: "INVALID_REQUEST",
message: "arbitrary presentation text",
retryable: false,
details: { reason: "session-changed" },
});
});
const tool = createSessionsTool({
agentSessionKey: "agent:main:main",
config: { tools: { sessions: { visibility: "agent" } } },
callGateway,
});
const { work } = await captureSessionDecisionWork(async () => {
await expect(tool.execute("reset-changed", { action: "reset", sessionKey })).rejects.toThrow(
"arbitrary presentation text",
);
});
expect(work[0]?.receipt).toMatchObject({
decision: { outcome: "denied", reasonCode: "session_reset_conflict" },
enforcement: { coverageState: "attribution-only" },
});
});
it.each([
{ operation: "patch", args: { label: "Updated" } },
{ operation: "archive", args: { archived: true } },
{ operation: "restore", args: { archived: false } },
] as const)("records a committed $operation result", async ({ operation, args }) => {
gatewayMocks.callGateway.mockResolvedValue({
ok: true,
path: "/tmp/sessions.sqlite",
key: "agent:main:main",
entry: { sessionId: "session-main" },
});
const tool = createSessionsTool({
agentSessionKey: "agent:main:main",
agentSessionId: "session-main",
config: {},
});
const { work } = await captureSessionDecisionWork(
async () => await tool.execute(`committed-${operation}`, { action: "patch", ...args }),
);
expect(work).toHaveLength(1);
expect(work[0]?.receipt).toMatchObject({
action: { family: "session", operation },
decision: { outcome: "allowed", reasonCode: `session_${operation}_committed` },
enforcement: { coverageState: "attribution-only" },
});
});
it.each([
{ operation: "patch", args: { label: "Updated" } },
{ operation: "archive", args: { archived: true } },
{ operation: "restore", args: { archived: false } },
] as const)("records a typed $operation conflict", async ({ operation, args }) => {
const callGateway: AgentToolGatewayRequestCaller = vi.fn(async () => {
throw new GatewayClientRequestError({
code: "INVALID_REQUEST",
message: "arbitrary presentation text",
retryable: false,
details: { reason: "session-changed" },
});
});
const tool = createSessionsTool({
agentSessionKey: "agent:main:main",
agentSessionId: "session-main",
config: {},
callGateway,
});
const { work } = await captureSessionDecisionWork(async () => {
await expect(
tool.execute(`conflict-${operation}`, { action: "patch", ...args }),
).rejects.toThrow("arbitrary presentation text");
});
expect(work).toHaveLength(1);
expect(work[0]?.receipt).toMatchObject({
action: { family: "session", operation },
decision: { outcome: "denied", reasonCode: `session_${operation}_conflict` },
enforcement: { coverageState: "attribution-only" },
});
});
it("drops a session result when receipt authority closes across its awaited RPC", async () => {
const work: ExecutionDecisionWork[] = [];
const clear = configureExecutionDecisionWorkSink((item) => {
work.push(item);
return true;
});
const token = createExecutionIdentityAdmissionToken("sessions-tool-closed", {
contextId: "sessions-tool-closed-context",
executionId: "sessions-tool-closed-execution",
});
let authorityActive = true;
gatewayMocks.callGateway.mockImplementation(async () => {
authorityActive = false;
return {
ok: true,
path: "/tmp/sessions.sqlite",
key: "agent:main:main",
entry: { sessionId: "session-main" },
};
});
const tool = createSessionsTool({
agentSessionKey: "agent:main:main",
config: {},
});
try {
await withGatewayToolCallerIdentity(
{
agentId: "main",
sessionKey: "agent:main:main",
executionIdentityToken: token,
receiptAuthority: () => authorityActive,
},
async () => await tool.execute("closed-patch", { action: "patch", label: "Updated" }),
);
expect(gatewayMocks.callGateway).toHaveBeenCalledOnce();
expect(work).toEqual([]);
} finally {
clear();
}
});
it.each(["delete", "reset"])("refuses to %s its currently running session", async (action) => {
@@ -726,214 +927,6 @@ describe("sessions tool", () => {
});
});
it("routes group actions to existing gateway methods", async () => {
const callGateway = vi.fn(async (request: { method: string; params: unknown }) => request);
const tool = createSessionsTool({
agentSessionKey: "agent:main:main",
callGateway: callGateway as never,
});
await tool.execute("list", { action: "group_list" });
await tool.execute("set", { action: "group_set", names: ["Now", "Later"] });
await tool.execute("rename", { action: "group_rename", name: "Now", to: "Next" });
await tool.execute("delete", { action: "group_delete", name: "Later" });
expect(callGateway.mock.calls).toEqual([
[{ method: "sessions.groups.list", params: {} }],
[{ method: "sessions.groups.put", params: { names: ["Now", "Later"] } }],
[{ method: "sessions.groups.rename", params: { name: "Now", to: "Next" } }],
[{ method: "sessions.groups.delete", params: { name: "Later" } }],
]);
await expect(tool.execute("set-missing", { action: "group_set" })).rejects.toThrow(
"names required",
);
await expect(
tool.execute("set-invalid", { action: "group_set", names: ["Now", null] }),
).rejects.toThrow("names[1] required");
expect(callGateway).toHaveBeenCalledTimes(4);
});
it("returns a bounded acknowledgement instead of the patched session entry", async () => {
const callGateway = vi.fn(async () => ({
ok: true,
path: `/sessions/${"p".repeat(10_000)}`,
key: "agent:main:main",
entry: {
skillsSnapshot: "s".repeat(47_469),
sessionDiffBaseline: "b".repeat(3_665),
},
resolved: {
modelProvider: "openai",
model: "gpt-5.6-luna",
},
}));
const tool = createSessionsTool({
agentSessionKey: "agent:main:main",
config: {},
callGateway: callGateway as never,
});
const result = await tool.execute("patch-sidebar", {
action: "patch",
label: "Movies",
});
expect(callGateway).toHaveBeenCalledWith({
method: "sessions.patch",
params: {
key: "agent:main:main",
label: "Movies",
},
});
expect(result.details).toEqual({
status: "updated",
sessionKey: "agent:main:main",
updated: ["label"],
});
const text = (result.content[0] as { text?: string } | undefined)?.text ?? "";
expect(text).not.toContain('"entry"');
expect(text).not.toContain('"path"');
expect(text).not.toContain('"resolved"');
expect(text).not.toContain("skillsSnapshot");
expect(text).not.toContain("sessionDiffBaseline");
expect(Buffer.byteLength(text, "utf8")).toBeLessThan(512);
});
it("returns authoritative resolved model and thinking metadata without the patched entry", async () => {
const resolved = {
modelProvider: "openai",
model: "gpt-5.6-luna",
agentRuntime: { id: "codex", fallback: "openclaw" as const, source: "session" as const },
thinkingLevel: "medium",
thinkingLevels: [
{ id: "off", label: "Off" },
{ id: "medium", label: "Medium" },
],
};
const callGateway = vi.fn(async () => ({
ok: true as const,
path: `/sessions/${"p".repeat(10_000)}`,
key: "agent:main:main",
entry: { skillsSnapshot: "s".repeat(47_469) },
resolved,
}));
const tool = createSessionsTool({
agentSessionKey: "agent:main:main",
config: {},
callGateway: callGateway as never,
});
const result = await tool.execute("patch-model-thinking", {
action: "patch",
model: "openai/luna",
thinkingLevel: "med",
});
expect(result.details).toEqual({
status: "updated",
sessionKey: "agent:main:main",
updated: ["model", "thinkingLevel"],
resolved,
});
const text = (result.content[0] as { text?: string } | undefined)?.text ?? "";
expect(text).not.toContain('"entry"');
expect(text).not.toContain('"path"');
expect(text).not.toContain("skillsSnapshot");
expect(Buffer.byteLength(text, "utf8")).toBeLessThan(1_024);
});
it("preserves the complete canonical thinking catalog through ultra", async () => {
const thinkingLevels = [
"off",
"minimal",
"low",
"medium",
"high",
"xhigh",
"adaptive",
"max",
"ultra",
].map((id) => ({ id, label: id }));
const callGateway = vi.fn(async () => ({
ok: true as const,
path: "/sessions/main",
key: "agent:main:main",
entry: {},
resolved: { thinkingLevel: "ultra", thinkingLevels },
}));
const tool = createSessionsTool({
agentSessionKey: "agent:main:main",
config: {},
callGateway: callGateway as never,
});
const result = await tool.execute("patch-ultra-thinking", {
action: "patch",
thinkingLevel: "ultra",
});
expect(result.details).toMatchObject({
resolved: { thinkingLevel: "ultra", thinkingLevels },
});
});
it("preserves long resolved identifiers and complete catalogs exactly when they fit", async () => {
const callGateway = vi.fn(async () => ({
ok: true as const,
path: `/sessions/${"p".repeat(10_000)}`,
key: "agent:main:main",
entry: { skillsSnapshot: "s".repeat(47_469) },
resolved: adversarialResolved,
}));
const tool = createSessionsTool({
agentSessionKey: "agent:main:main",
config: {},
callGateway: callGateway as never,
});
const result = await tool.execute("patch-adversarial-model-thinking", {
action: "patch",
model: "openai/luna",
thinkingLevel: "med",
});
expect(result.details).toMatchObject({
status: "updated",
sessionKey: "agent:main:main",
updated: ["model", "thinkingLevel"],
});
expectExactResolvedAcknowledgement(result, adversarialResolved);
});
it("omits oversized resolved metadata instead of changing authoritative identifiers", async () => {
const callGateway = vi.fn(async () => ({
ok: true as const,
path: `/sessions/${"p".repeat(10_000)}`,
key: "agent:main:main",
entry: { skillsSnapshot: "s".repeat(47_469) },
resolved: escapeHeavyResolved,
}));
const tool = createSessionsTool({
agentSessionKey: "agent:main:main",
config: {},
callGateway: callGateway as never,
});
const result = await tool.execute("patch-oversized-model-thinking", {
action: "patch",
model: "openai/luna",
thinkingLevel: "med",
});
expect(result.details).toMatchObject({
status: "updated",
sessionKey: "agent:main:main",
updated: ["model", "thinkingLevel"],
resolvedOmitted: expectedResolvedOmission,
});
expectOmittedResolvedAcknowledgement(result);
});
it("keeps resolved model and thinking metadata when self-archive is deferred", async () => {
await withTestDir({ prefix: "openclaw-sessions-tool-archive-" }, async (dir) => {
const storePath = path.join(dir, "sessions.json");
@@ -963,14 +956,17 @@ describe("sessions tool", () => {
});
try {
const result = await admission.run(
const { result, work } = await captureSessionDecisionWork(
async () =>
await tool.execute("patch-model-thinking-archive", {
action: "patch",
archived: true,
model: "openai/luna",
thinkingLevel: "med",
}),
await admission.run(
async () =>
await tool.execute("patch-model-thinking-archive", {
action: "patch",
archived: true,
model: "openai/luna",
thinkingLevel: "med",
}),
),
);
expect(result.details).toEqual({
status: "scheduled",
@@ -980,6 +976,15 @@ describe("sessions tool", () => {
});
expectExactResolvedAcknowledgement(result, adversarialResolved);
expect(callGateway).toHaveBeenCalledTimes(1);
expect(work).toHaveLength(1);
expect(work[0]).toMatchObject({
receipt: {
action: { family: "session", operation: "archive" },
decision: { outcome: "allowed", reasonCode: "session_archive_scheduled" },
enforcement: { coverageState: "attribution-only" },
},
refs: { target: { namespace: "session", value: `["main","${sessionKey}"]` } },
});
} finally {
admission.release();
}
+84 -22
View File
@@ -39,8 +39,11 @@ import {
import { resolveSessionToolTargetAgentId } from "./scoped-session-access.js";
import {
createAgentToAgentPolicy,
formatSessionToolAccessDenial,
recordSessionToolActionFact,
resolveEffectiveSessionToolsVisibility,
resolveSessionToolAccess,
runSessionToolActionWithConflictReceipt,
} from "./sessions-access.js";
import { resolveSessionToolContext } from "./sessions-helpers.js";
import { resolveSessionReference, shouldResolveSessionIdInput } from "./sessions-resolution.js";
@@ -310,7 +313,12 @@ async function resolvePatchTarget(
callGateway,
});
if (!access.allowed) {
throw new ToolAuthorizationError(access.error);
throw new ToolAuthorizationError(
formatSessionToolAccessDenial(access, {
action: "status",
targetSessionKey: resolved.displayKey,
}),
);
}
}
return {
@@ -350,9 +358,24 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool
}
const agentScope = parseAgentSessionKey(key) ? {} : { agentId };
if (action === "reset") {
return jsonResult(
await callGateway("sessions.reset", { key, ...agentScope, reason: "reset" }),
);
const result = await runSessionToolActionWithConflictReceipt({
operation: "reset",
targetAgentId: agentId,
targetSessionKey: key,
run: async () =>
await callGateway("sessions.reset", {
key,
...agentScope,
reason: "reset",
}),
});
recordSessionToolActionFact({
operation: "reset",
fact: "committed",
targetAgentId: agentId,
targetSessionKey: key,
});
return jsonResult(result);
}
// Archive returns the exact row generation. Carry it into the locked
// delete so a concurrent reset cannot delete a replacement session.
@@ -362,13 +385,19 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool
if (!expectedSessionId) {
throw new ToolInputError("Session lifecycle action requires a durable session identity");
}
const archived = await callGateway<{
entry?: { sessionId?: string; lifecycleRevision?: string };
}>("sessions.patch", {
key,
...agentScope,
expectedSessionId,
archived: true,
const archived = await runSessionToolActionWithConflictReceipt({
operation: "delete",
targetAgentId: agentId,
targetSessionKey: key,
run: async () =>
await callGateway<{
entry?: { sessionId?: string; lifecycleRevision?: string };
}>("sessions.patch", {
key,
...agentScope,
expectedSessionId,
archived: true,
}),
});
const archivedSessionId = normalizeOptionalString(archived.entry?.sessionId);
if (!archivedSessionId) {
@@ -377,16 +406,29 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool
const expectedLifecycleRevision = normalizeOptionalString(
archived.entry?.lifecycleRevision,
);
return jsonResult(
await callGateway("sessions.delete", {
key,
...agentScope,
archivedOnly: true,
expectedSessionId: archivedSessionId,
...(expectedLifecycleRevision ? { expectedLifecycleRevision } : {}),
deleteTranscript: readBooleanParam(params, "deleteTranscript") ?? true,
}),
);
const result = await runSessionToolActionWithConflictReceipt({
operation: "delete",
targetAgentId: agentId,
targetSessionKey: key,
run: async () =>
await callGateway<{ deleted?: boolean }>("sessions.delete", {
key,
...agentScope,
archivedOnly: true,
expectedSessionId: archivedSessionId,
...(expectedLifecycleRevision ? { expectedLifecycleRevision } : {}),
deleteTranscript: readBooleanParam(params, "deleteTranscript") ?? true,
}),
});
recordSessionToolActionFact({
operation: "delete",
// Archive is part of this composite action and already committed.
// A delete miss therefore cannot make the whole operation a no-op.
fact: "committed",
targetAgentId: agentId,
targetSessionKey: key,
});
return jsonResult(result);
}
if (action === "group_list") {
return jsonResult(await callGateway("sessions.groups.list", {}));
@@ -635,6 +677,13 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool
log.warn(`deferred self-archive failed for ${key}: ${formatErrorMessage(error)}`);
});
recordSessionToolActionFact({
operation: "archive",
fact: "scheduled",
targetAgentId: agentId,
targetSessionKey: key,
});
return jsonResult(
withBoundedSessionsResolved(
{
@@ -649,7 +698,20 @@ export function createSessionsTool(opts: SessionsToolOptions = {}): AnyAgentTool
}
}
const result = await callSessionPatch({ ...patch, ...agentScope });
const operation =
archived === true ? "archive" : archived === false ? "restore" : ("patch" as const);
const result = await runSessionToolActionWithConflictReceipt({
operation,
targetAgentId: agentId,
targetSessionKey: key,
run: async () => await callSessionPatch({ ...patch, ...agentScope }),
});
recordSessionToolActionFact({
operation,
fact: "committed",
targetAgentId: agentId,
targetSessionKey: key,
});
return jsonResult(
withBoundedSessionsResolved(
{
@@ -1,6 +1,14 @@
// Tests runtime-loaded fast-path command behavior for get-reply.
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
import {
createChannelTestPluginBase,
createTestRegistry,
} from "../../test-utils/channel-plugins.js";
import { projectSessionDeliveryFields } from "../../utils/delivery-context.shared.js";
import {
createReplyRuntimeMocks,
createTempHomeHarness,
@@ -47,6 +55,7 @@ describe("getReplyFromConfig fast-path runtime", () => {
beforeEach(async () => {
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
resetReplyRuntimeMocks(agentMocks);
setActivePluginRegistry(createTestRegistry([]));
});
afterEach(() => {
@@ -90,6 +99,95 @@ describe("getReplyFromConfig fast-path runtime", () => {
});
});
it("handles dock on the native fast path before agent admission", async () => {
await withTempHome(async (home) => {
const cfg = makeReplyConfig(home) as OpenClawConfig;
const storePath = `${home}/sessions.sqlite`;
const sessionKey = "agent:main:telegram:123";
cfg.session = {
store: storePath,
identityLinks: { alice: ["telegram:UserCase123", "discord:UserCase123"] },
};
const entry = {
sessionId: "session-dock-fast-path",
updatedAt: 1,
delivery: {
kind: "external",
route: {
channel: "telegram",
accountId: "primary",
target: { to: "UserCase123", chatType: "direct" },
},
context: { channel: "telegram", to: "UserCase123", accountId: "primary" },
origin: {
provider: "telegram",
surface: "telegram",
chatType: "direct",
from: "telegram:UserCase123",
to: "UserCase123",
accountId: "primary",
},
},
} satisfies SessionEntry;
await replaceSessionEntry({ storePath, sessionKey }, entry);
setActivePluginRegistry(
createTestRegistry([
{
pluginId: "telegram",
plugin: createChannelTestPluginBase({
id: "telegram",
capabilities: { nativeCommands: true, chatTypes: ["direct"] },
config: { defaultAccountId: () => "primary" },
}),
source: "test",
},
{
pluginId: "discord",
plugin: createChannelTestPluginBase({
id: "discord",
capabilities: { nativeCommands: true, chatTypes: ["direct"] },
config: { defaultAccountId: () => "default" },
}),
source: "test",
},
]),
);
const reply = await getReplyFromConfig(
{
Body: "/dock-discord",
BodyForAgent: "/dock-discord",
RawBody: "/dock-discord",
CommandBody: "/dock-discord",
CommandSource: "native",
CommandAuthorized: true,
SenderId: "UserCase123",
From: "telegram:UserCase123",
SessionKey: sessionKey,
Provider: "telegram",
Surface: "telegram",
ChatType: "direct",
},
undefined,
cfg,
);
expect(reply).toEqual({
text: "Docked replies to discord.",
replyToId: undefined,
replyToCurrent: false,
});
expect(agentMocks.runEmbeddedAgent).not.toHaveBeenCalled();
expect(
projectSessionDeliveryFields(loadSessionEntry({ storePath, sessionKey })?.delivery),
).toMatchObject({
lastChannel: "discord",
lastTo: "UserCase123",
lastAccountId: "default",
});
});
});
it("routes structured native command turns through the target session before legacy sync", async () => {
await withTempHome(async (home) => {
agentMocks.runEmbeddedAgent.mockResolvedValue(makeEmbeddedTextResult("ok"));
@@ -8,12 +8,14 @@ import { testing as cliBackendsTesting } from "../../agents/cli-backends.test-su
import type { OpenClawConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/session-accessor.js";
import { setActivePluginRegistry } from "../../plugins/runtime.js";
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 { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { getReplyPayloadMetadata } from "../reply-payload.js";
import { handleGoalCommand } from "./commands-goal.js";
import { buildFastReplyCommandContext, initFastReplySessionState } from "./get-reply-fast-path.js";
@@ -210,6 +212,7 @@ describe("getReplyFromConfig fast test bootstrap", () => {
afterEach(() => {
closeOpenClawStateDatabaseForTest();
setActivePluginRegistry(createTestRegistry([]));
cliBackendsTesting.resetDepsForTest();
vi.unstubAllEnvs();
});
@@ -121,6 +121,7 @@ const CURRENT_TRAIN_METHODS = [
"tools.github.authorize.start",
"tools.github.authorize.poll",
"tools.github.authorize.cancel",
"session.members.listEvidence",
] as const;
describe("core gateway method release trains", () => {
+3
View File
@@ -629,6 +629,9 @@ const CORE_GATEWAY_METHOD_SPECS = [
{ controlPlaneWrite: true },
],
["diagnostics.lanes", "diagnostics", "operator.read", "2026.8"],
// Evidence-aware member projection is additive so legacy method indices and
// its required `addedBy` response contract remain unchanged.
["session.members.listEvidence", "sessions-sharing", "operator.read", "2026.8"],
] as const satisfies readonly CoreGatewayMethodSpecRow[];
export type CoreGatewayHandlerFamily = Exclude<(typeof CORE_GATEWAY_METHOD_SPECS)[number][1], null>;
+1
View File
@@ -90,6 +90,7 @@ const EVENT_SCOPE_GUARDS: Record<string, string[]> = {
"session.observer": [READ_SCOPE],
"session.operation": [READ_SCOPE],
"session.sharing": [READ_SCOPE],
"session.sharing.evidence": [READ_SCOPE],
"session.suggestion": [READ_SCOPE],
"session.typing": [READ_SCOPE],
"session.tool": [READ_SCOPE],
+7 -2
View File
@@ -73,7 +73,7 @@ describe("listGatewayMethods", () => {
});
it("appends new methods after model probing without shifting older method indices", () => {
expect(listGatewayMethods().slice(-64)).toEqual([
expect(listGatewayMethods().slice(-65)).toEqual([
"models.probe",
"migrations.memory.plan",
"migrations.memory.apply",
@@ -138,6 +138,7 @@ describe("listGatewayMethods", () => {
"tools.github.authorize.cancel",
"sessions.github.publish",
"diagnostics.lanes",
"session.members.listEvidence",
]);
const methods = listGatewayMethods();
expect(methods.indexOf("node.pluginSurface.refresh")).toBe(
@@ -260,7 +261,7 @@ describe("listGatewayMethods", () => {
"exec.approval.get",
]);
expect(methods).toContain("tts.speak");
expect(coreMethods.slice(-71)).toEqual([
expect(coreMethods.slice(-72)).toEqual([
"sessions.catalog.continue",
"sessions.catalog.archive",
"approval.get",
@@ -332,6 +333,7 @@ describe("listGatewayMethods", () => {
"tools.github.authorize.cancel",
"sessions.github.publish",
"diagnostics.lanes",
"session.members.listEvidence",
]);
expect(methods.indexOf("approval.get")).toBeGreaterThan(methods.indexOf("tts.speak"));
expect(methods.indexOf("approval.resolve")).toBe(methods.indexOf("approval.get") + 1);
@@ -372,6 +374,9 @@ describe("listGatewayMethods", () => {
expect(methods.indexOf("sessions.assignOwner")).toBe(methods.indexOf("sessions.move") + 1);
expect(methods.indexOf("progressCard.get")).toBe(methods.indexOf("sessions.assignOwner") + 1);
expect(methods.indexOf("progressCard.put")).toBe(methods.indexOf("progressCard.get") + 1);
expect(methods.indexOf("session.members.listEvidence")).toBe(
methods.indexOf("diagnostics.lanes") + 1,
);
});
it("advertises the versioned Talk session RPCs", () => {
+1
View File
@@ -50,6 +50,7 @@ export const GATEWAY_EVENTS = [
"session.observer",
"session.operation",
"session.sharing",
"session.sharing.evidence",
"session.suggestion",
"session.typing",
"session.tool",
@@ -1,4 +1,13 @@
import { Value } from "typebox/value";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
SessionMembersListEvidenceResultSchema,
SessionSharingEvidenceEventSchema,
SessionSharingEventSchema,
type SessionMembersListEvidenceResult,
type SessionSharingEvidenceEvent,
type SessionSharingEvent,
} from "../../../packages/gateway-protocol/src/index.js";
import {
loadSessionEntry,
loadTranscriptEvents,
@@ -16,6 +25,11 @@ import {
import { ensureProfileForEmail, listProfiles, setDisplayName } from "../../state/user-profiles.js";
import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js";
import { createBoardViewTicket } from "../board-view-ticket.js";
import {
attachGatewayLocalUserIngress,
getGatewayLocalUserIngress,
prepareGatewayLocalUserIngress,
} from "../local-user-ingress.js";
import {
authorizeResolvedSessionMutation,
resolveSessionMutationAuthorization,
@@ -25,6 +39,7 @@ import {
invalidateSessionSharingSnapshot,
} from "../session-sharing.js";
import { createControlUiHandlers } from "./control-ui.js";
import { flushPendingSessionsChangedEvents } from "./session-change-event.js";
import { sessionReadHandlers } from "./sessions-read.js";
import { sessionSharingHandlers } from "./sessions-sharing.js";
import type { GatewayClient, GatewayRequestContext, RespondFn } from "./types.js";
@@ -108,6 +123,7 @@ async function call(
method:
| "session.visibility.set"
| "session.members.list"
| "session.members.listEvidence"
| "session.members.add"
| "session.members.remove",
params: Record<string, unknown>,
@@ -124,7 +140,202 @@ async function call(
return responses;
}
function sessionMembersListEvidenceResult(
responses: Parameters<RespondFn>[],
): SessionMembersListEvidenceResult {
const response = responses[0];
if (response?.[0] !== true || response[1] === undefined) {
throw new Error("expected one successful Gateway response");
}
return Value.Decode(SessionMembersListEvidenceResultSchema, response[1]);
}
function sharingEvents(broadcast: ReturnType<typeof vi.fn>): SessionSharingEvent[] {
return broadcast.mock.calls.flatMap(([name, event]) =>
name === "session.sharing" ? [Value.Decode(SessionSharingEventSchema, event)] : [],
);
}
function sharingEvidenceEvents(broadcast: ReturnType<typeof vi.fn>): SessionSharingEvidenceEvent[] {
return broadcast.mock.calls.flatMap(([name, event]) =>
name === "session.sharing.evidence"
? [Value.Decode(SessionSharingEvidenceEventSchema, event)]
: [],
);
}
describe("session sharing handlers", () => {
it("preserves profile actors and distinguishes unknown from absent profileless evidence", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
const member = ensureProfileForEmail("sharing-evidence-member@example.com");
const profiled = identifiedClient("profile-ada", "Ada");
const unknown = soloClient();
attachGatewayLocalUserIngress(
unknown,
prepareGatewayLocalUserIngress({
authMethod: "trusted-proxy",
authenticatedUserExpected: true,
isLocalClient: false,
}),
);
const absent = soloClient();
const cases = [
{ name: "present", client: profiled },
{ name: "unknown", client: unknown },
{ name: "absent", client: absent },
] as const;
const listings = new Map<string, SessionMembersListEvidenceResult>();
for (const item of cases) {
const sessionKey = `agent:main:sharing-actor-${item.name}`;
await upsertSessionEntryCore(
{ agentId: "main", sessionKey },
{
sessionId: `session-sharing-actor-${item.name}`,
updatedAt: 1,
visibility: "shared",
...(item.name === "present"
? { createdActor: { type: "human" as const, id: "profile-ada" } }
: {}),
},
);
const broadcast = vi.fn();
const requestContext = context(broadcast);
requestContext.getSessionEventSubscriberConnIds = () => new Set(["legacy-client"]);
expect(
await call(
"session.visibility.set",
{ sessionKey, visibility: "draft" },
requestContext,
item.client,
),
).toMatchObject([[true, { ok: true, sessionKey }, undefined]]);
expect(
await call(
"session.members.add",
{ sessionKey, identityId: member.id },
requestContext,
item.client,
),
).toEqual([[true, { ok: true, sessionKey, identityId: member.id }, undefined]]);
const listed = await call(
"session.members.listEvidence",
{ sessionKey },
requestContext,
item.client,
);
listings.set(item.name, sessionMembersListEvidenceResult(listed));
const legacyListed = await call(
"session.members.list",
{ sessionKey },
requestContext,
item.client,
);
expect(legacyListed[0]?.[0]).toBe(item.name === "present");
if (item.name === "present") {
expect(legacyListed[0]?.[1]).toMatchObject({
members: [{ identityId: member.id, addedBy: "profile-ada" }],
});
} else {
expect(legacyListed[0]?.[2]?.details).toEqual({
code: "SESSION_MEMBER_ACTOR_EVIDENCE_UNSUPPORTED",
recommendedMethod: "session.members.listEvidence",
});
}
expect(
await call(
"session.members.remove",
{ sessionKey, identityId: member.id },
requestContext,
item.client,
),
).toEqual([[true, { ok: true, sessionKey, identityId: member.id }, undefined]]);
flushPendingSessionsChangedEvents(requestContext);
expect(requestContext.broadcastToConnIds).toHaveBeenCalledWith(
"sessions.changed",
expect.objectContaining({ reason: "sharing", sessionKey }),
new Set(["legacy-client"]),
expect.objectContaining({ sessionKeys: [sessionKey] }),
);
const publishedEvents = sharingEvents(broadcast);
expect(publishedEvents.map((event) => event.action)).toEqual(
item.name === "present" ? ["visibility", "member-added", "member-removed"] : [],
);
const publishedEvidenceEvents = sharingEvidenceEvents(broadcast);
expect(publishedEvidenceEvents.map((event) => event.action)).toEqual(
item.name === "present" ? [] : ["visibility", "member-added", "member-removed"],
);
for (const event of publishedEvents) {
expect(event.actor).toMatchObject({ type: "human", id: "profile-ada", label: "Ada" });
}
for (const event of publishedEvidenceEvents) {
expect(event).not.toHaveProperty("actor");
if (item.name === "unknown") {
expect(event).toMatchObject({ actorState: "unknown" });
} else {
expect(event).not.toHaveProperty("actorState");
}
}
expect(JSON.stringify([publishedEvidenceEvents, listings.get(item.name)])).not.toMatch(
/local-operator|operator\.admin|actor-evidence:/,
);
}
const listedMember = (name: "present" | "unknown" | "absent") =>
listings.get(name)?.members[0];
expect(listings.get("present")).toMatchObject({
members: [{ identityId: member.id, addedBy: "profile-ada" }],
});
expect(listings.get("unknown")).toMatchObject({
members: [{ identityId: member.id, addedByState: "unknown" }],
});
expect(listedMember("unknown")).not.toHaveProperty("addedBy");
expect(listings.get("absent")).toMatchObject({
members: [{ identityId: member.id }],
});
expect(listedMember("absent")).not.toHaveProperty("addedBy");
expect(listedMember("absent")).not.toHaveProperty("addedByState");
expect(getGatewayLocalUserIngress(unknown)?.facts.invoker).toEqual({ state: "unknown" });
expect(getGatewayLocalUserIngress(absent)).toBeUndefined();
});
});
it("keeps real actor-evidence profile ids while discarding beta synthetic actors", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async () => {
const sessionKey = "agent:main:sharing-storage-projection";
const sessionId = "session-sharing-storage-projection";
await upsertSessionEntryCore({ agentId: "main", sessionKey }, { sessionId, updatedAt: 1 });
for (const [identityId, addedBy, addedAt] of [
["legacy-admin-member", "operator.admin", 1],
["legacy-local-member", "local-operator", 2],
["real-prefix-member", "actor-evidence:profile-ada", 3],
] as const) {
expect(
addSessionMember(
{ agentId: "main", sessionKey },
{ identityId, addedBy, addedAt, expectedSessionId: sessionId },
).inserted,
).toBe(true);
}
const response = await call("session.members.listEvidence", { sessionKey }, context(vi.fn()));
const result = sessionMembersListEvidenceResult(response);
const member = (identityId: string) =>
result.members.find((candidate) => candidate.identityId === identityId);
expect(member("real-prefix-member")).toMatchObject({
addedBy: "actor-evidence:profile-ada",
});
for (const identityId of ["legacy-admin-member", "legacy-local-member"]) {
expect(member(identityId)).not.toHaveProperty("addedBy");
expect(member(identityId)).not.toHaveProperty("addedByState");
}
const serialized = JSON.stringify(result);
expect(serialized).not.toContain("actor-evidence:unknown");
expect(serialized).not.toContain("actor-evidence:unattributed");
expect(serialized).not.toContain("operator.admin");
expect(serialized).not.toContain("local-operator");
});
});
it("admits bare fixed-store keys only through their persisted owner", async () => {
await withOpenClawTestState({ scenario: "minimal" }, async (state) => {
const storePath = state.path("shared-sessions.sqlite");
@@ -720,6 +931,7 @@ describe("session sharing handlers", () => {
);
const broadcast = vi.fn();
const requestContext = context(broadcast);
const member = ensureProfileForEmail("member-change@example.com");
const transcriptBefore = await loadTranscriptEvents({
agentId: "main",
sessionId: "session-main",
@@ -736,23 +948,18 @@ describe("session sharing handlers", () => {
expect(loadSessionEntry({ agentId: "main", sessionKey })?.visibility).toBe("read-only");
expect(
await call(
"session.members.add",
{ sessionKey, identityId: "local-operator" },
requestContext,
),
).toEqual([[true, { ok: true, sessionKey, identityId: "local-operator" }, undefined]]);
await call("session.members.add", { sessionKey, identityId: member.id }, requestContext),
).toEqual([[true, { ok: true, sessionKey, identityId: member.id }, undefined]]);
expect(listSessionMembers({ agentId: "main", sessionKey })).toEqual([
expect.objectContaining({ identityId: "local-operator", addedBy: "local-operator" }),
expect.objectContaining({
identityId: member.id,
addedBy: "actor-evidence:unattributed",
}),
]);
expect(
await call(
"session.members.remove",
{ sessionKey, identityId: "local-operator" },
requestContext,
),
).toEqual([[true, { ok: true, sessionKey, identityId: "local-operator" }, undefined]]);
await call("session.members.remove", { sessionKey, identityId: member.id }, requestContext),
).toEqual([[true, { ok: true, sessionKey, identityId: member.id }, undefined]]);
expect(listSessionMembers({ agentId: "main", sessionKey })).toEqual([]);
expect(
@@ -762,10 +969,10 @@ describe("session sharing handlers", () => {
sessionKey,
}),
).toEqual(transcriptBefore);
const sharingEvents = broadcast.mock.calls
.filter(([event]) => event === "session.sharing")
const publishedEvents = broadcast.mock.calls
.filter(([event]) => event === "session.sharing.evidence")
.map(([, payload, options]) => ({ payload, options }));
expect(sharingEvents).toEqual([
expect(publishedEvents).toEqual([
{
payload: expect.objectContaining({
action: "visibility",
@@ -778,7 +985,7 @@ describe("session sharing handlers", () => {
payload: expect.objectContaining({
action: "member-added",
sessionKey,
identityId: "local-operator",
identityId: member.id,
}),
options: { sessionKeys: [sessionKey] },
},
@@ -786,7 +993,7 @@ describe("session sharing handlers", () => {
payload: expect.objectContaining({
action: "member-removed",
sessionKey,
identityId: "local-operator",
identityId: member.id,
}),
options: { sessionKeys: [sessionKey] },
},
+160 -70
View File
@@ -5,9 +5,12 @@ import {
validateSessionMemberRemoveParams,
validateSessionMembersListParams,
validateSessionVisibilitySetParams,
type SessionSharingEvent,
type SessionSharingIdentity,
type SessionMember,
type SessionMemberEvidence,
type SessionCreatedActor,
type SessionSharingEvent,
type SessionSharingEvidenceEvent,
type SessionSharingIdentity,
type SessionVisibility,
} from "../../../packages/gateway-protocol/src/index.js";
import {
@@ -19,6 +22,7 @@ import {
import { patchSessionEntryCore } from "../../config/sessions/session-accessor.js";
import { runExclusiveSessionLifecycleMutation } from "../../sessions/session-lifecycle-admission.js";
import { listProfiles } from "../../state/user-profiles.js";
import { getGatewayLocalUserIngress } from "../local-user-ingress.js";
import { resolveRequestedSessionAgentId } from "../session-request-agent.js";
import {
allowedSessionVisibilities,
@@ -47,13 +51,62 @@ function runExclusiveSharingMutation<T>(
});
}
function actorIdentity(client: GatewayClient | null): SessionSharingIdentity {
return (
gatewayClientSessionCreator(client) ??
(client?.connect.scopes?.includes("operator.admin")
? { type: "system", id: "operator.admin", label: "Administrator" }
: { type: "system", id: "local-operator", label: "Local operator" })
);
const UNKNOWN_SHARING_ACTOR_STORAGE_REF = "actor-evidence:unknown";
const UNATTRIBUTED_SHARING_ACTOR_STORAGE_REF = "actor-evidence:unattributed";
const LEGACY_SYNTHETIC_SHARING_ACTOR_STORAGE_REFS = new Set(["local-operator", "operator.admin"]);
type SharingActorFacts =
| { state: "present"; actor: SessionSharingIdentity }
| { state: "unknown" }
| { state: "absent" };
function actorIdentity(client: GatewayClient | null): SharingActorFacts {
const principal = gatewayClientSessionCreator(client);
if (principal) {
return { state: "present", actor: principal };
}
return getGatewayLocalUserIngress(client)?.facts.invoker?.state === "unknown"
? { state: "unknown" }
: { state: "absent" };
}
function sharingActorStorageRef(facts: SharingActorFacts): string {
return facts.state === "present"
? facts.actor.id
: facts.state === "unknown"
? UNKNOWN_SHARING_ACTOR_STORAGE_REF
: UNATTRIBUTED_SHARING_ACTOR_STORAGE_REF;
}
function projectSessionMemberEvidence(
member: ReturnType<typeof listSessionMembers>[number],
): SessionMemberEvidence {
// Sentinel ids satisfy the existing non-null storage contract only. Project
// actor evidence here so persistence markers never become protocol identities.
const common = { identityId: member.identityId, addedAt: member.addedAt };
if (member.addedBy === UNKNOWN_SHARING_ACTOR_STORAGE_REF) {
return { ...common, addedByState: "unknown" };
}
if (
member.addedBy === UNATTRIBUTED_SHARING_ACTOR_STORAGE_REF ||
LEGACY_SYNTHETIC_SHARING_ACTOR_STORAGE_REFS.has(member.addedBy)
) {
// Beta builds stored fabricated operator ids before actor evidence became
// tri-state. Discard those unshipped values instead of presenting principals.
return common;
}
return { ...common, addedBy: member.addedBy };
}
function projectLegacySessionMember(member: SessionMemberEvidence): SessionMember | null {
if (!member.addedBy) {
return null;
}
return {
identityId: member.identityId,
addedBy: member.addedBy,
addedAt: member.addedAt,
};
}
function requireManageableTarget(params: {
@@ -129,7 +182,7 @@ function requireCurrentManagedTarget(params: {
function knownSessionIdentities(params: {
cfg: ReturnType<GatewayRequestContext["getRuntimeConfig"]>;
actor: SessionSharingIdentity;
actor: SharingActorFacts;
}): SessionSharingIdentity[] {
const identities = new Map<string, SessionSharingIdentity>();
const remember = (identity: SessionCreatedActor | null) => {
@@ -143,7 +196,9 @@ function knownSessionIdentities(params: {
...((identity.label ?? current?.label) ? { label: identity.label ?? current?.label } : {}),
});
};
remember(params.actor);
if (params.actor.state === "present") {
remember(params.actor.actor);
}
for (const entry of Object.values(loadCombinedSessionStoreForGatewayCore(params.cfg).store)) {
remember(entry.createdActor ?? null);
}
@@ -163,13 +218,24 @@ function knownSessionIdentities(params: {
function publishSharingChange(params: {
context: GatewayRequestContext;
event: SessionSharingEvent;
actor: SharingActorFacts;
event: Omit<SessionSharingEvidenceEvent, "actorState">;
agentId: string;
}): void {
invalidateSessionSharingSnapshot(params.event.sessionKey);
params.context.broadcast("session.sharing", params.event, {
const eventOptions = {
sessionKeys: [params.event.sessionKey],
});
};
if (params.actor.state === "present") {
const event: SessionSharingEvent = { ...params.event, actor: params.actor.actor };
params.context.broadcast("session.sharing", event, eventOptions);
} else {
const event: SessionSharingEvidenceEvent = {
...params.event,
...(params.actor.state === "unknown" ? { actorState: "unknown" } : {}),
};
params.context.broadcast("session.sharing.evidence", event, eventOptions);
}
emitSessionsChanged(params.context, {
reason: "sharing",
sessionKey: params.event.sessionKey,
@@ -180,6 +246,80 @@ function publishSharingChange(params: {
emitSessionsChanged(params.context, { reason: "sharing" });
}
function createSessionMembersListHandler(
method: "session.members.list" | "session.members.listEvidence",
): GatewayRequestHandlers[string] {
const evidenceAware = method === "session.members.listEvidence";
return async ({ params, respond, client, context }) => {
if (!assertValidParams(params, validateSessionMembersListParams, method, respond)) {
return;
}
const cfg = context.getRuntimeConfig();
const managed = requireManageableTarget({
cfg,
client,
sessionKey: params.sessionKey,
agentId: params.agentId,
respond,
});
if (!managed) {
return;
}
const target = managed.target;
const actor = actorIdentity(client);
const evidenceMembers = listSessionMembers({
agentId: target.agentId,
sessionKey: target.storeKey,
storePath: target.storePath,
}).map(projectSessionMemberEvidence);
const members = evidenceAware
? evidenceMembers
: evidenceMembers.map(projectLegacySessionMember);
if (!evidenceAware && members.some((member) => member === null)) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
"session membership includes actor evidence this client cannot represent",
{
details: {
code: "SESSION_MEMBER_ACTOR_EVIDENCE_UNSUPPORTED",
recommendedMethod: "session.members.listEvidence",
},
},
),
);
return;
}
const projectedMembers = members.filter((member) => member !== null);
const identities = knownSessionIdentities({ cfg, actor });
for (const member of projectedMembers) {
if (!identities.some((identity) => identity.id === member.identityId)) {
identities.push({ type: "human", id: member.identityId });
}
}
identities.sort(
(left, right) =>
(left.label ?? left.id).localeCompare(right.label ?? right.id) ||
left.id.localeCompare(right.id),
);
const owner = target.entry.createdActor?.id ? target.entry.createdActor : undefined;
respond(
true,
{
sessionKey: target.canonicalKey,
...(owner ? { owner: { ...owner } } : {}),
members: projectedMembers,
identities,
role: managed.role,
allowedVisibilities: allowedSessionVisibilities(cfg),
},
undefined,
);
};
}
export const sessionSharingHandlers: GatewayRequestHandlers = {
"session.visibility.set": async ({ params, respond, client, context }) => {
if (
@@ -244,11 +384,11 @@ export const sessionSharingHandlers: GatewayRequestHandlers = {
publishSharingChange({
context,
agentId: current.agentId,
actor,
event: {
action: "visibility",
sessionKey: current.canonicalKey,
agentId: current.agentId,
actor,
visibility,
ts: now,
},
@@ -257,58 +397,8 @@ export const sessionSharingHandlers: GatewayRequestHandlers = {
respond(true, { ok: true, sessionKey: managed.target.canonicalKey, visibility }, undefined);
},
"session.members.list": async ({ params, respond, client, context }) => {
if (
!assertValidParams(params, validateSessionMembersListParams, "session.members.list", respond)
) {
return;
}
const cfg = context.getRuntimeConfig();
const managed = requireManageableTarget({
cfg,
client,
sessionKey: params.sessionKey,
agentId: params.agentId,
respond,
});
if (!managed) {
return;
}
const target = managed.target;
const actor = actorIdentity(client);
const members = listSessionMembers({
agentId: target.agentId,
sessionKey: target.storeKey,
storePath: target.storePath,
});
const identities = knownSessionIdentities({
cfg,
actor,
});
for (const member of members) {
if (!identities.some((identity) => identity.id === member.identityId)) {
identities.push({ type: "human", id: member.identityId });
}
}
identities.sort(
(left, right) =>
(left.label ?? left.id).localeCompare(right.label ?? right.id) ||
left.id.localeCompare(right.id),
);
const owner = target.entry.createdActor?.id ? target.entry.createdActor : undefined;
respond(
true,
{
sessionKey: target.canonicalKey,
...(owner ? { owner: { ...owner } } : {}),
members,
identities,
role: managed.role,
allowedVisibilities: allowedSessionVisibilities(cfg),
},
undefined,
);
},
"session.members.list": createSessionMembersListHandler("session.members.list"),
"session.members.listEvidence": createSessionMembersListHandler("session.members.listEvidence"),
"session.members.add": async ({ params, respond, client, context }) => {
if (
@@ -346,7 +436,7 @@ export const sessionSharingHandlers: GatewayRequestHandlers = {
const now = Date.now();
const added = addSessionMember(scope, {
identityId: params.identityId,
addedBy: actor.id,
addedBy: sharingActorStorageRef(actor),
addedAt: now,
expectedSessionId: current.entry.sessionId,
});
@@ -356,11 +446,11 @@ export const sessionSharingHandlers: GatewayRequestHandlers = {
publishSharingChange({
context,
agentId: current.agentId,
actor,
event: {
action: "member-added",
sessionKey: current.canonicalKey,
agentId: current.agentId,
actor,
identityId: params.identityId,
ts: now,
},
@@ -416,11 +506,11 @@ export const sessionSharingHandlers: GatewayRequestHandlers = {
publishSharingChange({
context,
agentId: current.agentId,
actor,
event: {
action: "member-removed",
sessionKey: current.canonicalKey,
agentId: current.agentId,
actor,
identityId: params.identityId,
ts: now,
},
@@ -1,5 +1,6 @@
/** Core-private spawned-session ownership lookup; not a published plugin SDK subpath. */
import { err, ok, type Result } from "@openclaw/normalization-core/result";
import { normalizeLowercaseStringOrEmpty } from "../../packages/normalization-core/src/string-coerce.js";
import { normalizeTrimmedStringList } from "../../packages/normalization-core/src/string-normalization.js";
import {
GatewayCredentialsRequiredError,
@@ -12,11 +13,259 @@ import { GatewaySecretRefUnavailableError } from "../gateway/credentials.js";
import { formatErrorMessage } from "../infra/errors.js";
import { logWarn } from "../logger.js";
import { redactIdentifier } from "../logging/redact-identifier.js";
import {
isAcpSessionKey,
isIncognitoSessionKey,
isSubagentSessionKey,
resolveAgentIdFromSessionKey,
} from "../routing/session-key.js";
type GatewayCaller = typeof defaultCallGateway;
export type LookupFailureKind = "transient" | "credentials" | "unknown";
export type SessionVisibilityDecisionAction = "history" | "send" | "list" | "status";
export type SessionVisibilityDecisionPresentationAction =
| SessionVisibilityDecisionAction
| "search";
export type SessionVisibilityDecisionMode = "self" | "tree" | "agent" | "all";
export type SessionVisibilityDecisionPolicy = {
enabled: boolean;
isAllowed: (requesterAgentId: string, targetAgentId: string) => boolean;
};
export type SessionVisibilityDecisionRow = {
key: string;
agentId?: string;
ownerSessionKey?: string;
spawnedBy?: string;
parentSessionKey?: string;
};
export type SessionVisibilityDenialReason =
| "agent_to_agent_disabled"
| "agent_to_agent_not_allowed"
| "cross_agent_visibility_restricted"
| "incognito_session"
| "session_ownership_lookup_failed_credentials"
| "session_ownership_lookup_failed_transient"
| "session_ownership_lookup_failed_unknown"
| "self_visibility_restricted"
| "target_agent_ownership_unavailable"
| "tree_visibility_restricted";
type SessionVisibilityDenied = {
allowed: false;
status: "forbidden";
reasonCode: SessionVisibilityDenialReason;
policyRefs: string[];
contextFieldsUsed: string[];
missingEvidence: string[];
};
export type SessionVisibilityDecision =
| { allowed: true; expectedSessionId?: string }
| SessionVisibilityDenied;
type SessionVisibilityDecisionParams = {
action: SessionVisibilityDecisionAction;
defaultAgentId?: string;
requesterAgentId?: string;
requesterSessionKey: string;
mainSessionKey?: string;
explicitTargetAgentOwnership?: boolean;
visibility: SessionVisibilityDecisionMode;
a2aPolicy: SessionVisibilityDecisionPolicy;
};
function denied(
reasonCode: SessionVisibilityDenialReason,
policyRefs: string[],
contextFieldsUsed: string[],
missingEvidence: string[] = [],
): SessionVisibilityDenied {
return {
allowed: false,
status: "forbidden",
reasonCode,
policyRefs,
contextFieldsUsed,
missingEvidence,
};
}
export function resolveIncognitoSessionAccessDecision(
targetSessionKey: string,
): SessionVisibilityDecision | undefined {
return isIncognitoSessionKey(targetSessionKey)
? denied("incognito_session", ["sessions.incognito"], ["targetSessionKey"])
: undefined;
}
function rowOwnedByRequester(
row: SessionVisibilityDecisionRow,
requesterSessionKey: string,
): boolean {
return (
row.ownerSessionKey === requesterSessionKey ||
row.spawnedBy === requesterSessionKey ||
row.parentSessionKey === requesterSessionKey
);
}
/** Core-private policy owner; public SDK wrappers only render this decision. */
export function createSessionVisibilityDecisionChecker(params: SessionVisibilityDecisionParams): {
check: (row: SessionVisibilityDecisionRow) => SessionVisibilityDecision;
} {
const requesterAgentId =
normalizeLowercaseStringOrEmpty(params.requesterAgentId) ||
resolveAgentIdFromSessionKey(params.requesterSessionKey, params.defaultAgentId);
return {
check: (row) => {
const targetSessionKey = row.key;
const incognito = resolveIncognitoSessionAccessDecision(targetSessionKey);
if (incognito) {
return incognito;
}
const isRequesterSession =
targetSessionKey === params.requesterSessionKey || targetSessionKey === "current";
let targetAgentId = normalizeLowercaseStringOrEmpty(row.agentId);
if (
!targetAgentId &&
(targetSessionKey === "current" ||
(targetSessionKey === params.requesterSessionKey && !params.defaultAgentId?.trim()))
) {
targetAgentId = requesterAgentId;
}
if (!targetAgentId) {
try {
targetAgentId = resolveAgentIdFromSessionKey(targetSessionKey, params.defaultAgentId);
} catch {
return denied(
"target_agent_ownership_unavailable",
["session.owner"],
["requesterSessionKey", "targetSessionKey"],
["session.owner"],
);
}
}
const isRequesterOwned =
rowOwnedByRequester(row, params.requesterSessionKey) ||
(params.visibility === "tree" &&
targetAgentId === requesterAgentId &&
params.requesterSessionKey === params.mainSessionKey);
const isCrossAgent = targetAgentId !== requesterAgentId;
// Native child lineage can authorize a cross-agent backend without
// weakening ordinary cross-agent session policy.
if (
!isRequesterSession &&
isRequesterOwned &&
(!isCrossAgent ||
isAcpSessionKey(targetSessionKey) ||
isSubagentSessionKey(targetSessionKey)) &&
(params.visibility === "tree" || params.visibility === "all")
) {
return { allowed: true };
}
if (isCrossAgent) {
const a2aDenial = !params.a2aPolicy.enabled
? denied(
"agent_to_agent_disabled",
["tools.agentToAgent.enabled"],
["requesterAgentId", "targetAgentId"],
)
: !params.a2aPolicy.isAllowed(requesterAgentId, targetAgentId)
? denied(
"agent_to_agent_not_allowed",
["tools.agentToAgent.allow"],
["requesterAgentId", "targetAgentId"],
)
: undefined;
// Status historically reports the explicit fixed-store owner's A2A
// gate before generic visibility; retain that operator contract here.
if (params.action === "status" && params.explicitTargetAgentOwnership && a2aDenial) {
return a2aDenial;
}
if (params.visibility !== "all") {
return denied(
"cross_agent_visibility_restricted",
["tools.sessions.visibility"],
["requesterAgentId", "targetAgentId", "visibility"],
);
}
if (a2aDenial) {
return a2aDenial;
}
return { allowed: true };
}
if (params.visibility === "self" && !isRequesterSession) {
return denied(
"self_visibility_restricted",
["tools.sessions.visibility"],
["requesterSessionKey", "targetSessionKey", "visibility"],
);
}
if (params.visibility === "tree" && !isRequesterSession && !isRequesterOwned) {
return denied(
"tree_visibility_restricted",
["tools.sessions.visibility"],
["requesterSessionKey", "targetSessionKey", "requesterOwned", "visibility"],
);
}
return { allowed: true };
},
};
}
export function sessionOwnershipLookupDenied(kind: LookupFailureKind): SessionVisibilityDenied {
return denied(
`session_ownership_lookup_failed_${kind}`,
["tools.sessions.visibility"],
["requesterSessionKey", "targetSessionKey"],
["session.owner"],
);
}
function actionPrefix(action: SessionVisibilityDecisionPresentationAction): string {
return action === "list" ? "Session list" : `Session ${action}`;
}
/** Preserve the established public/tool prose without making prose the policy fact. */
export function renderSessionVisibilityDenial(
denial: Extract<SessionVisibilityDecision, { allowed: false }>,
params: {
action: SessionVisibilityDecisionPresentationAction;
targetSessionKey?: string;
},
): string {
switch (denial.reasonCode) {
case "incognito_session":
return `Session not visible from session tools${params.targetSessionKey ? `: ${params.targetSessionKey}` : ""}`;
case "target_agent_ownership_unavailable":
return `${actionPrefix(params.action)} denied because target agent ownership is unavailable.`;
case "cross_agent_visibility_restricted":
return `${actionPrefix(params.action)} visibility is restricted. Set tools.sessions.visibility=all and tools.agentToAgent.enabled=true to allow cross-agent access; use tools.agentToAgent.allow to restrict permitted agent pairs.`;
case "agent_to_agent_disabled":
if (params.action === "send") {
return "Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.";
}
if (params.action === "list") {
return "Agent-to-agent listing is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent visibility.";
}
return `Agent-to-agent ${params.action} is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.`;
case "agent_to_agent_not_allowed":
return `Agent-to-agent ${params.action === "send" ? "messaging" : params.action === "list" ? "listing" : params.action} denied by tools.agentToAgent.allow.`;
case "self_visibility_restricted":
return `${actionPrefix(params.action)} visibility is restricted to the current session (tools.sessions.visibility=self).`;
case "tree_visibility_restricted":
return `${actionPrefix(params.action)} visibility is restricted to the current session tree (tools.sessions.visibility=tree).`;
case "session_ownership_lookup_failed_transient":
return lookupFailedDenialMessage(params.action, "transient");
case "session_ownership_lookup_failed_credentials":
return lookupFailedDenialMessage(params.action, "credentials");
case "session_ownership_lookup_failed_unknown":
return lookupFailedDenialMessage(params.action, "unknown");
default:
throw new Error("unsupported session visibility denial");
}
}
export function classifyLookupFailure(error: unknown): LookupFailureKind {
if (error instanceof GatewayClientRequestError && error.retryable) {
return "transient";
+31 -191
View File
@@ -7,15 +7,13 @@ import {
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { callGateway as defaultCallGateway } from "../gateway/call.js";
import {
isAcpSessionKey,
isIncognitoSessionKey,
isSubagentSessionKey,
resolveAgentIdFromSessionKey,
} from "../routing/session-key.js";
import {
createSessionVisibilityDecisionChecker,
listSpawnedSessionKeysWithResult,
logSessionOwnershipLookupFailure,
lookupFailedDenialMessage,
renderSessionVisibilityDenial,
resolveIncognitoSessionAccessDecision,
sessionOwnershipLookupDenied,
type SessionVisibilityDecision,
type SessionOwnershipLookupFailure,
} from "./session-visibility-internal.js";
@@ -63,7 +61,7 @@ function resolveScopedSessionAccess(
): ScopedSessionAccessGrant | undefined {
// Incognito transcripts must never be re-persisted through another session,
// including host-scoped access paths that bypass normal visibility policy.
if (resolveIncognitoSessionAccessDenial(request.targetSessionKey)) {
if (resolveIncognitoSessionAccessDecision(request.targetSessionKey)) {
return undefined;
}
for (const provider of scopedSessionAccessProviders) {
@@ -239,77 +237,18 @@ export function createAgentToAgentPolicy(cfg: OpenClawConfig): AgentToAgentPolic
return { enabled, matchesAllow, isAllowed };
}
function actionPrefix(action: SessionAccessAction): string {
if (action === "history") {
return "Session history";
}
if (action === "send") {
return "Session send";
}
if (action === "status") {
return "Session status";
}
return "Session list";
}
function a2aDisabledMessage(action: SessionAccessAction): string {
if (action === "history") {
return "Agent-to-agent history is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.";
}
if (action === "send") {
return "Agent-to-agent messaging is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent sends.";
}
if (action === "status") {
return "Agent-to-agent status is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent access.";
}
return "Agent-to-agent listing is disabled. Set tools.agentToAgent.enabled=true to allow cross-agent visibility.";
}
function a2aDeniedMessage(action: SessionAccessAction): string {
if (action === "history") {
return "Agent-to-agent history denied by tools.agentToAgent.allow.";
}
if (action === "send") {
return "Agent-to-agent messaging denied by tools.agentToAgent.allow.";
}
if (action === "status") {
return "Agent-to-agent status denied by tools.agentToAgent.allow.";
}
return "Agent-to-agent listing denied by tools.agentToAgent.allow.";
}
function crossVisibilityMessage(action: SessionAccessAction): string {
const suffix =
"Set tools.sessions.visibility=all and tools.agentToAgent.enabled=true to allow cross-agent access; use tools.agentToAgent.allow to restrict permitted agent pairs.";
if (action === "history") {
return `Session history visibility is restricted. ${suffix}`;
}
if (action === "send") {
return `Session send visibility is restricted. ${suffix}`;
}
if (action === "status") {
return `Session status visibility is restricted. ${suffix}`;
}
return `Session list visibility is restricted. ${suffix}`;
}
function selfVisibilityMessage(action: SessionAccessAction): string {
return `${actionPrefix(action)} visibility is restricted to the current session (tools.sessions.visibility=self).`;
}
function resolveIncognitoSessionAccessDenial(
function toSessionAccessResult(
decision: SessionVisibilityDecision,
action: SessionAccessAction,
targetSessionKey: string,
): SessionAccessResult | undefined {
// Session-tool output is persisted into the caller transcript. Process-only
// incognito sessions must stay hidden even from owners and scoped grants.
if (!isIncognitoSessionKey(targetSessionKey)) {
return undefined;
}
return {
allowed: false,
status: "forbidden",
error: `Session not visible from session tools: ${targetSessionKey}`,
};
): SessionAccessResult {
return decision.allowed
? decision
: {
allowed: false,
status: "forbidden",
error: renderSessionVisibilityDenial(decision, { action, targetSessionKey }),
};
}
type SessionVisibilityCheckerParams = {
@@ -329,12 +268,12 @@ function createSessionVisibilityCheckerWithResult(
): { check: (targetSessionKey: string) => SessionAccessResult } {
const spawnedKeys = params.spawnedKeys;
let lookupFailureLogged = false;
const rowChecker = createSessionVisibilityRowChecker(params);
const decisionChecker = createSessionVisibilityDecisionChecker(params);
const check = (targetSessionKey: string): SessionAccessResult => {
const incognitoDenial = resolveIncognitoSessionAccessDenial(targetSessionKey);
const incognitoDenial = resolveIncognitoSessionAccessDecision(targetSessionKey);
if (incognitoDenial) {
return incognitoDenial;
return toSessionAccessResult(incognitoDenial, params.action, targetSessionKey);
}
if (params.action !== "list") {
const scoped = resolveScopedSessionAccess({
@@ -348,12 +287,12 @@ function createSessionVisibilityCheckerWithResult(
}
const spawnedKeySet = spawnedKeys?.ok ? spawnedKeys.value : undefined;
const isSpawnedSession = spawnedKeySet?.has(targetSessionKey) === true;
const result = rowChecker.check({
const result = decisionChecker.check({
key: targetSessionKey,
spawnedBy: isSpawnedSession ? params.requesterSessionKey : undefined,
});
if (!result.allowed) {
const ownedResult = rowChecker.check({
const ownedResult = decisionChecker.check({
key: targetSessionKey,
spawnedBy: params.requesterSessionKey,
});
@@ -373,14 +312,14 @@ function createSessionVisibilityCheckerWithResult(
failure: spawnedKeys.error,
});
}
return {
allowed: false,
status: "forbidden",
error: lookupFailedDenialMessage(params.action, spawnedKeys.error.kind),
};
return toSessionAccessResult(
sessionOwnershipLookupDenied(spawnedKeys.error.kind),
params.action,
targetSessionKey,
);
}
}
return result;
return toSessionAccessResult(result, params.action, targetSessionKey);
};
return { check };
@@ -402,113 +341,14 @@ export const createSessionVisibilityChecker = Object.assign(createSessionVisibil
resolveScopedAccess: resolveScopedSessionAccess,
});
function rowOwnedByRequester(row: SessionVisibilityRow, requesterSessionKey: string): boolean {
return (
row.ownerSessionKey === requesterSessionKey ||
row.spawnedBy === requesterSessionKey ||
row.parentSessionKey === requesterSessionKey
);
}
/** Create a row-aware visibility checker that can use owner/spawn metadata. */
export function createSessionVisibilityRowChecker(params: SessionVisibilityCheckerParams): {
check: (row: SessionVisibilityRow) => SessionAccessResult;
} {
const requesterAgentId =
normalizeLowercaseStringOrEmpty(params.requesterAgentId) ||
resolveAgentIdFromSessionKey(params.requesterSessionKey, params.defaultAgentId);
const check = (row: SessionVisibilityRow): SessionAccessResult => {
const targetSessionKey = row.key;
const incognitoDenial = resolveIncognitoSessionAccessDenial(targetSessionKey);
if (incognitoDenial) {
return incognitoDenial;
}
const isRequesterSession =
targetSessionKey === params.requesterSessionKey || targetSessionKey === "current";
let targetAgentId = normalizeLowercaseStringOrEmpty(row.agentId);
if (
!targetAgentId &&
(targetSessionKey === "current" ||
(targetSessionKey === params.requesterSessionKey && !params.defaultAgentId?.trim()))
) {
targetAgentId = requesterAgentId;
}
if (!targetAgentId) {
try {
targetAgentId = resolveAgentIdFromSessionKey(targetSessionKey, params.defaultAgentId);
} catch {
return {
allowed: false,
status: "forbidden",
error: `${actionPrefix(params.action)} denied because target agent ownership is unavailable.`,
};
}
}
const isRequesterOwned =
rowOwnedByRequester(row, params.requesterSessionKey) ||
(params.visibility === "tree" &&
targetAgentId === requesterAgentId &&
params.requesterSessionKey === params.mainSessionKey);
const isCrossAgent = targetAgentId !== requesterAgentId;
// Row ownership is stronger than agent ids: ACP children may use a backend
// agent id while still belonging to the requester that spawned them. Only
// native child namespaces can cross that agent boundary; ordinary sessions
// remain subject to A2A policy even if malformed lineage claims otherwise.
if (
!isRequesterSession &&
isRequesterOwned &&
(!isCrossAgent ||
isAcpSessionKey(targetSessionKey) ||
isSubagentSessionKey(targetSessionKey)) &&
(params.visibility === "tree" || params.visibility === "all")
) {
return { allowed: true };
}
if (isCrossAgent) {
if (params.visibility !== "all") {
return {
allowed: false,
status: "forbidden",
error: crossVisibilityMessage(params.action),
};
}
if (!params.a2aPolicy.enabled) {
return {
allowed: false,
status: "forbidden",
error: a2aDisabledMessage(params.action),
};
}
if (!params.a2aPolicy.isAllowed(requesterAgentId, targetAgentId)) {
return {
allowed: false,
status: "forbidden",
error: a2aDeniedMessage(params.action),
};
}
return { allowed: true };
}
if (params.visibility === "self" && !isRequesterSession) {
return {
allowed: false,
status: "forbidden",
error: selfVisibilityMessage(params.action),
};
}
if (params.visibility === "tree" && !isRequesterSession && !isRequesterOwned) {
return {
allowed: false,
status: "forbidden",
error: `${actionPrefix(params.action)} visibility is restricted to the current session tree (tools.sessions.visibility=tree).`,
};
}
return { allowed: true };
const checker = createSessionVisibilityDecisionChecker(params);
return {
check: (row) => toSessionAccessResult(checker.check(row), params.action, row.key),
};
return { check };
}
/** Create a visibility guard, loading spawned-session ownership when direct keys need it. */
+2 -2
View File
@@ -42,8 +42,8 @@ export type ChannelsPairingRequest =
import("../../../packages/gateway-protocol/src/index.js").ChannelsPairingRequest;
export type SessionVisibility =
import("../../../packages/gateway-protocol/src/index.js").SessionVisibility;
export type SessionMembersListResult =
import("../../../packages/gateway-protocol/src/index.js").SessionMembersListResult;
export type SessionMembersListEvidenceResult =
import("../../../packages/gateway-protocol/src/index.js").SessionMembersListEvidenceResult;
export type { SessionRunStatus } from "../../../packages/gateway-protocol/src/schema/sessions-row.js";
export type ChannelsStatusSnapshot = ChannelsStatusResult;
export type ChannelUiMetaEntry = NonNullable<ChannelsStatusResult["channelMeta"]>[number];
+9 -9
View File
@@ -641,7 +641,7 @@ suite.define(() => {
"chat.metadata",
"chat.startup",
"session.visibility.set",
"session.members.list",
"session.members.listEvidence",
"session.members.add",
"session.members.remove",
],
@@ -649,7 +649,7 @@ suite.define(() => {
historyMessages: [{ role: "assistant", content: [{ type: "text", text: "Ready." }] }],
methodResponses: {
"sessions.list": sessions,
"session.members.list": {
"session.members.listEvidence": {
sessionKey: "agent:main:ada",
members: [],
identities: [],
@@ -709,7 +709,7 @@ suite.define(() => {
await currentPage.getByText("Publish draft", { exact: true }).click();
await gateway.waitForRequest("session.visibility.set");
await expect.poll(() => dropdown.getAttribute("open")).toBeNull();
expect(await gateway.getRequests("session.members.list")).toHaveLength(0);
expect(await gateway.getRequests("session.members.listEvidence")).toHaveLength(0);
const message = "visibility change rejected";
await gateway.rejectDeferred("session.visibility.set", {
@@ -739,7 +739,7 @@ suite.define(() => {
"chat.metadata",
"chat.startup",
"session.visibility.set",
"session.members.list",
"session.members.listEvidence",
"session.members.add",
"session.members.remove",
],
@@ -747,7 +747,7 @@ suite.define(() => {
historyMessages: [{ role: "assistant", content: [{ type: "text", text: "Ready." }] }],
methodResponses: {
"sessions.list": sessions,
"session.members.list": {
"session.members.listEvidence": {
sessionKey: "agent:main:ada",
members: [],
identities: [{ type: "human", id: "profile-bob", label: "Bob" }],
@@ -760,7 +760,7 @@ suite.define(() => {
await currentPage.goto(`${suite.server?.baseUrl ?? ""}chat`);
await currentPage.getByText("Ready.", { exact: true }).waitFor();
await currentPage.getByLabel("Session sharing").click();
await gateway.waitForRequest("session.members.list");
await gateway.waitForRequest("session.members.listEvidence");
const dropdown = currentPage.locator(".chat-pane__sharing-menu");
const publish = dropdown.locator('wa-dropdown-item[value="visibility:shared"]');
await publish.waitFor();
@@ -818,7 +818,7 @@ suite.define(() => {
featureMethods: [
"chat.metadata",
"chat.startup",
"session.members.list",
"session.members.listEvidence",
"session.members.add",
"session.members.remove",
"session.visibility.set",
@@ -856,7 +856,7 @@ suite.define(() => {
],
methodResponses: {
"sessions.list": sessions,
"session.members.list": {
"session.members.listEvidence": {
sessionKey: "agent:main:ada",
members: [{ identityId: longMemberId, addedBy: "profile-ada", addedAt: 1 }],
identities: [...humanIdentities, ...nonHumanIdentities],
@@ -869,7 +869,7 @@ suite.define(() => {
await currentPage.goto(`${suite.server?.baseUrl ?? ""}chat`);
await currentPage.getByText("Ready.", { exact: true }).waitFor();
await currentPage.locator(".chat-pane__sharing-trigger").click();
await gateway.waitForRequest("session.members.list");
await gateway.waitForRequest("session.members.listEvidence");
const dropdown = currentPage.locator(".chat-pane__sharing-menu");
await dropdown.locator('wa-dropdown-item[value="member:profile-member-0"]').waitFor();
await dropdown.evaluate(async (element) => {
+11 -9
View File
@@ -115,21 +115,23 @@ describe("readSessionMethodAccess", () => {
});
it("allows read, write, and admin scopes to satisfy read-scoped actions", () => {
for (const scope of ["operator.read", "operator.write", "operator.admin"]) {
expect(
readSessionMethodAccess(snapshot({ methods: ["session.members.list"], scopes: [scope] }), {
method: "session.members.list",
requiredScope: "operator.read",
}).allowed,
).toBe(true);
for (const method of ["session.members.list", "session.members.listEvidence"]) {
for (const scope of ["operator.read", "operator.write", "operator.admin"]) {
expect(
readSessionMethodAccess(snapshot({ methods: [method], scopes: [scope] }), {
method,
requiredScope: "operator.read",
}).allowed,
).toBe(true);
}
}
});
it("rejects a read-scoped action without a compatible operator scope", () => {
expect(
readSessionMethodAccess(
snapshot({ methods: ["session.members.list"], scopes: ["operator.approvals"] }),
{ method: "session.members.list", requiredScope: "operator.read" },
snapshot({ methods: ["session.members.listEvidence"], scopes: ["operator.approvals"] }),
{ method: "session.members.listEvidence", requiredScope: "operator.read" },
),
).toMatchObject({
allowed: false,
+1 -1
View File
@@ -156,7 +156,7 @@ export abstract class ChatPaneHeader extends ChatPaneDiscussion {
const sharingMethodsSupported =
isGatewayMethodAdvertised(sharingSnapshot, "session.visibility.set") === true;
const sharingReadAccess = readSessionMethodAccess(sharingSnapshot, {
method: "session.members.list",
method: "session.members.listEvidence",
requiredScope: "operator.read",
});
const sharingVisibilityAccess = readSessionMethodAccess(sharingSnapshot, {
+19 -19
View File
@@ -6,7 +6,7 @@ import { createDeferred } from "../../../../test/helpers/promise.js";
import type { GatewayBrowserClient } from "../../api/gateway.ts";
import type {
GatewaySessionRow,
SessionMembersListResult,
SessionMembersListEvidenceResult,
SessionVisibility,
SessionsListResult,
} from "../../api/types.ts";
@@ -31,7 +31,7 @@ type SharingPane = TestChatPane & {
const SHARING_METHODS = [
"session.visibility.set",
"session.members.list",
"session.members.listEvidence",
"session.members.add",
"session.members.remove",
];
@@ -92,7 +92,7 @@ function sharingSessionsResult(row: GatewaySessionRow): SessionsListResult {
};
}
function sharingResult(row: GatewaySessionRow): SessionMembersListResult {
function sharingResult(row: GatewaySessionRow): SessionMembersListEvidenceResult {
return {
sessionKey: row.key,
members: [],
@@ -155,7 +155,7 @@ describe("chat pane sharing authorization", () => {
it("allows read-scoped owners to load sharing data but not mutate it", async () => {
const row = sessionRow();
const request = vi.fn(async (method: string) => {
if (method === "session.members.list") {
if (method === "session.members.listEvidence") {
return sharingResult(row);
}
throw new Error(`unexpected request: ${method}`);
@@ -176,7 +176,7 @@ describe("chat pane sharing authorization", () => {
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith(
"session.members.list",
"session.members.listEvidence",
expect.objectContaining({ sessionKey: row.key }),
);
expect(sessions.refreshReplacement).not.toHaveBeenCalled();
@@ -186,7 +186,7 @@ describe("chat pane sharing authorization", () => {
for (const scope of ["operator.write", "operator.admin"]) {
const row = sessionRow();
const request = vi.fn(async (method: string) => {
if (method === "session.members.list") {
if (method === "session.members.listEvidence") {
return sharingResult(row);
}
return {};
@@ -255,7 +255,7 @@ describe("chat pane sharing authorization", () => {
});
const pane = testPane as SharingPane;
setSharingAuthorization(pane, {
methods: ["session.members.list"],
methods: ["session.members.listEvidence"],
scopes: ["operator.admin"],
});
@@ -265,7 +265,7 @@ describe("chat pane sharing authorization", () => {
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith(
"session.members.list",
"session.members.listEvidence",
expect.objectContaining({ sessionKey: row.key }),
);
});
@@ -303,9 +303,9 @@ describe("chat pane sharing authorization", () => {
it("releases a stale same-key sharing load for the replacement session", async () => {
const stale = sessionRow();
const replacement = { ...sessionRow(), sessionId: "session-replacement" };
const listed = createDeferred<SessionMembersListResult>();
const listed = createDeferred<SessionMembersListEvidenceResult>();
const request = vi.fn((method: string) => {
if (method !== "session.members.list") {
if (method !== "session.members.listEvidence") {
throw new Error(`unexpected request: ${method}`);
}
return request.mock.calls.length === 1
@@ -338,7 +338,7 @@ describe("chat pane sharing authorization", () => {
it("drops a sharing load failure after leaving and returning", async () => {
const row = sessionRow();
const listed = createDeferred<SessionMembersListResult>();
const listed = createDeferred<SessionMembersListEvidenceResult>();
const request = vi.fn(() => listed.promise);
const { pane: testPane } = createSharingTestChatPane({
client: createGatewayBrowserClientFixture({ request }),
@@ -592,12 +592,12 @@ describe("chat pane sharing mutation phase ownership", () => {
it.each(["resolve", "reject"] as const)(
"drops a stale sharing reload when it later %s",
async (completion) => {
const listed = createDeferred<SessionMembersListResult>();
const listed = createDeferred<SessionMembersListEvidenceResult>();
const request = vi.fn((requestMethod: string) => {
if (requestMethod === method) {
return Promise.resolve({});
}
if (requestMethod === "session.members.list") {
if (requestMethod === "session.members.listEvidence") {
return listed.promise;
}
throw new Error(`unexpected old-connection request: ${requestMethod}`);
@@ -614,7 +614,7 @@ describe("chat pane sharing mutation phase ownership", () => {
const pending = invoke(pane, row);
await vi.waitFor(() => {
expect(request).toHaveBeenCalledWith(
"session.members.list",
"session.members.listEvidence",
expect.objectContaining({ sessionKey: row.key }),
);
});
@@ -639,7 +639,7 @@ describe("chat pane sharing mutation phase ownership", () => {
const refreshed = createDeferred();
const row = sessionRow();
const request = vi.fn(async (method: string) => {
if (method === "session.members.list") {
if (method === "session.members.listEvidence") {
return sharingResult(row);
}
if (method === "session.members.add") {
@@ -676,7 +676,7 @@ describe("chat pane current sharing mutation refresh order", () => {
const calls: string[] = [];
const request = vi.fn(async (method: string) => {
calls.push(method);
if (method === "session.members.list") {
if (method === "session.members.listEvidence") {
return sharingResult(row);
}
return {};
@@ -697,7 +697,7 @@ describe("chat pane current sharing mutation refresh order", () => {
expect(calls).toEqual([
"session.visibility.set",
"sessions.refreshReplacement",
"session.members.list",
"session.members.listEvidence",
]);
expect(pane.sessionSharingStates.get(pane.sessionSharingCacheKey(row.key))?.result).toEqual(
sharingResult(row),
@@ -709,7 +709,7 @@ describe("chat pane current sharing mutation refresh order", () => {
const calls: string[] = [];
const request = vi.fn(async (method: string) => {
calls.push(method);
if (method === "session.members.list") {
if (method === "session.members.listEvidence") {
return sharingResult(row);
}
return {};
@@ -729,7 +729,7 @@ describe("chat pane current sharing mutation refresh order", () => {
expect(calls).toEqual([
"session.members.add",
"session.members.list",
"session.members.listEvidence",
"sessions.refreshReplacement",
]);
expect(pane.sessionSharingStates.get(pane.sessionSharingCacheKey(row.key))?.result).toEqual(
+4 -3
View File
@@ -9,7 +9,7 @@ import type {
import { GatewayRequestError } from "../../api/gateway.ts";
import type {
GatewaySessionRow,
SessionMembersListResult,
SessionMembersListEvidenceResult as SessionSharingResult,
SessionVisibility,
} from "../../api/types.ts";
import { t } from "../../i18n/index.ts";
@@ -38,6 +38,7 @@ import {
} from "./components/chat-session-sharing.ts";
type HeaderScope = ChatPaneConnectionScope;
const SESSION_MEMBERS_LIST_METHOD = "session.members.listEvidence";
export abstract class ChatPaneSharing extends ChatPaneBase {
protected readonly clearSessionCompanion = async () => {
@@ -100,7 +101,7 @@ export abstract class ChatPaneSharing extends ChatPaneBase {
!scope ||
!currentRow ||
!readSessionMethodAccess(scope.context.gateway.snapshot, {
method: "session.members.list",
method: SESSION_MEMBERS_LIST_METHOD,
requiredScope: "operator.read",
}).allowed
) {
@@ -129,7 +130,7 @@ export abstract class ChatPaneSharing extends ChatPaneBase {
};
this.setSessionSharingState(cacheKey, loadingState);
try {
const result = await scope.client.request<SessionMembersListResult>("session.members.list", {
const result = await scope.client.request<SessionSharingResult>(SESSION_MEMBERS_LIST_METHOD, {
sessionKey: currentRow.key,
...(this.sessionSharingAgentId(currentRow.key)
? { agentId: this.sessionSharingAgentId(currentRow.key) }
@@ -1,7 +1,7 @@
import { html, nothing, type TemplateResult } from "lit";
import type {
GatewaySessionRow,
SessionMembersListResult,
SessionMembersListEvidenceResult,
SessionVisibility,
} from "../../../api/types.ts";
import { icons } from "../../../components/icons.ts";
@@ -11,7 +11,7 @@ import { t } from "../../../i18n/index.ts";
export type ChatSessionSharingState = {
loading: boolean;
result?: SessionMembersListResult;
result?: SessionMembersListEvidenceResult;
error?: string;
};
+1
View File
@@ -209,6 +209,7 @@ export const defaultControlUiFeatureMethods = [
"device.scopes.waitUpgrade",
"session.members.add",
"session.members.list",
"session.members.listEvidence",
"session.members.remove",
"session.visibility.set",
"sessions.abort",
+1
View File
@@ -7,6 +7,7 @@ export const SESSION_MUTATION_TEST_METHODS = [
"projects.add",
"session.members.add",
"session.members.list",
"session.members.listEvidence",
"session.members.remove",
"session.visibility.set",
"sessions.assignOwner",