gateway: pass Talk session scope to resolver [AI] (#81379)

* fix: pass talk session visibility scope

* addressing review-skill

* addressing review-skill

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* addressing codex review

* addressing claude review

* addressing ci

* docs: add changelog entry for PR merge
This commit is contained in:
Pavan Kumar Gondhi
2026-05-13 20:09:03 +05:30
committed by GitHub
parent 4d8aec8210
commit 418d7afb33
9 changed files with 126 additions and 22 deletions

View File

@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- gateway: pass Talk session scope to resolver [AI]. (#81379) Thanks @pgondhi987.
- GitHub Copilot: exchange OAuth tokens for Copilot API tokens on image understanding requests and route Gemini image payloads through Chat Completions, fixing Copilot Gemini image descriptions. (#80393, #80442) Thanks @afunnyhy.
- Gateway: hide pending Node pairing commands, capabilities, and permissions until approval, and refresh the live approved surface when pairings change. (#80741) Thanks @samzong.
- SGLang: preserve replayed reasoning history for OpenAI-compatible chat completions, keeping thinking-capable local models from losing prior reasoning turns. (#81091) Thanks @akrimm702.

View File

@@ -3228,6 +3228,7 @@ public struct TalkSessionCancelTurnParams: Codable, Sendable {
public struct TalkSessionCreateParams: Codable, Sendable {
public let sessionkey: String?
public let spawnedby: String?
public let provider: String?
public let model: String?
public let voice: String?
@@ -3242,6 +3243,7 @@ public struct TalkSessionCreateParams: Codable, Sendable {
public init(
sessionkey: String?,
spawnedby: String?,
provider: String?,
model: String?,
voice: String?,
@@ -3255,6 +3257,7 @@ public struct TalkSessionCreateParams: Codable, Sendable {
ttlms: Int?)
{
self.sessionkey = sessionkey
self.spawnedby = spawnedby
self.provider = provider
self.model = model
self.voice = voice
@@ -3270,6 +3273,7 @@ public struct TalkSessionCreateParams: Codable, Sendable {
private enum CodingKeys: String, CodingKey {
case sessionkey = "sessionKey"
case spawnedby = "spawnedBy"
case provider
case model
case voice

View File

@@ -364,7 +364,7 @@ enumeration of `src/gateway/server-methods/*.ts`.
<Accordion title="Talk and TTS">
- `talk.catalog` returns the read-only Talk provider catalog for speech, streaming transcription, and realtime voice. It includes provider ids, labels, configured state, exposed model/voice ids, canonical modes, transports, brain strategies, and realtime audio/capability flags without returning provider secrets or mutating global config.
- `talk.config` returns the effective Talk config payload; `includeSecrets` requires `operator.talk.secrets` (or `operator.admin`).
- `talk.session.create` creates a Gateway-owned Talk session for `realtime/gateway-relay`, `transcription/gateway-relay`, or `stt-tts/managed-room`. `brain: "direct-tools"` requires `operator.admin`.
- `talk.session.create` creates a Gateway-owned Talk session for `realtime/gateway-relay`, `transcription/gateway-relay`, or `stt-tts/managed-room`. For `stt-tts/managed-room`, `operator.write` callers that pass `sessionKey` must also pass `spawnedBy` for scoped session-key visibility; unscoped `sessionKey` creation and `brain: "direct-tools"` require `operator.admin`.
- `talk.session.join` validates a managed-room session token, emits `session.ready` or `session.replaced` events as needed, and returns room/session metadata plus recent Talk events without the plaintext token or stored token hash.
- `talk.session.appendAudio` appends base64 PCM input audio to Gateway-owned realtime relay and transcription sessions.
- `talk.session.startTurn`, `talk.session.endTurn`, and `talk.session.cancelTurn` drive managed-room turn lifecycle with stale-turn rejection before state is cleared.

View File

@@ -273,6 +273,7 @@ describe("validateTalkSession", () => {
expect(
validateTalkSessionCreateParams({
sessionKey: "agent:main:main",
spawnedBy: "agent:main:parent",
provider: "openai",
model: "gpt-realtime-2",
voice: "alloy",

View File

@@ -204,6 +204,7 @@ export const TalkSessionJoinParamsSchema = Type.Object(
export const TalkSessionCreateParamsSchema = Type.Object(
{
sessionKey: Type.Optional(Type.String()),
spawnedBy: Type.Optional(NonEmptyString),
provider: Type.Optional(Type.String()),
model: Type.Optional(Type.String()),
voice: Type.Optional(Type.String()),

View File

@@ -110,6 +110,12 @@ function canCloseManagedRoomSession(
return !handoff?.room.activeClientId || handoff.room.activeClientId === connId;
}
function canCreateUnscopedManagedRoomSession(
client: { connect?: { scopes?: string[] } } | null,
): boolean {
return client?.connect?.scopes?.includes(ADMIN_SCOPE) === true;
}
function managedRoomOwnershipError(action: string) {
return errorShape(
ErrorCodes.INVALID_REQUEST,
@@ -160,10 +166,27 @@ export const talkSessionHandlers: GatewayRequestHandlers = {
);
return;
}
const spawnedBy = normalizeOptionalString(params.spawnedBy);
if (
normalizeOptionalString(params.sessionKey) &&
!spawnedBy &&
!canCreateUnscopedManagedRoomSession(client)
) {
respond(
false,
undefined,
errorShape(
ErrorCodes.INVALID_REQUEST,
`talk.session.create managed-room sessionKey requires spawnedBy or gateway scope: ${ADMIN_SCOPE}`,
),
);
return;
}
const resolvedSession = await resolveSessionKeyFromResolveParams({
cfg: context.getRuntimeConfig(),
p: {
key: params.sessionKey,
...(spawnedBy ? { spawnedBy } : {}),
includeGlobal: true,
includeUnknown: true,
},

View File

@@ -708,7 +708,7 @@ describe("talk.session unified handlers", () => {
sessionKey: "session:main",
ttlMs: 5000,
},
client: { connId: "conn-1" } as never,
client: { connId: "conn-1", connect: { scopes: ["operator.admin"] } } as never,
isWebchatConnect: () => false,
respond: createRespond as never,
context: {
@@ -807,6 +807,64 @@ describe("talk.session unified handlers", () => {
expect(mockCallArg(broadcastToConnIds, 2, 3)).toEqual({ dropIfSlow: true });
});
it("passes managed-room spawnedBy visibility scope to session resolution", async () => {
const createRespond = vi.fn();
await talkHandlers["talk.session.create"]({
req: { type: "req", id: "1", method: "talk.session.create" },
params: {
mode: "stt-tts",
transport: "managed-room",
sessionKey: "agent:worker:subagent:child",
spawnedBy: "agent:main:parent",
},
client: { connId: "conn-1", connect: { scopes: ["operator.write"] } } as never,
isWebchatConnect: () => false,
respond: createRespond as never,
context: {
getRuntimeConfig: () => ({}) as OpenClawConfig,
} as never,
});
expectRespondOk(createRespond, {
transport: "managed-room",
brain: "agent-consult",
});
expect(mocks.resolveSessionKeyFromResolveParams).toHaveBeenCalledWith({
cfg: {},
p: {
key: "agent:worker:subagent:child",
spawnedBy: "agent:main:parent",
includeGlobal: true,
includeUnknown: true,
},
});
});
it("rejects unscoped managed-room session keys without admin scope", async () => {
const createRespond = vi.fn();
await talkHandlers["talk.session.create"]({
req: { type: "req", id: "1", method: "talk.session.create" },
params: {
mode: "stt-tts",
transport: "managed-room",
sessionKey: "agent:worker:main",
},
client: { connId: "conn-1", connect: { scopes: ["operator.write"] } } as never,
isWebchatConnect: () => false,
respond: createRespond as never,
context: {
getRuntimeConfig: () => ({}) as OpenClawConfig,
} as never,
});
expectRespondError(createRespond, {
code: ErrorCodes.INVALID_REQUEST,
message:
"talk.session.create managed-room sessionKey requires spawnedBy or gateway scope: operator.admin",
});
expect(mocks.resolveSessionKeyFromResolveParams).not.toHaveBeenCalled();
});
it("requires managed-room ownership before turn control", async () => {
const broadcastToConnIds = vi.fn();
const createRespond = vi.fn();
@@ -817,7 +875,7 @@ describe("talk.session unified handlers", () => {
transport: "managed-room",
sessionKey: "session:main",
},
client: { connId: "creator" } as never,
client: { connId: "creator", connect: { scopes: ["operator.admin"] } } as never,
isWebchatConnect: () => false,
respond: createRespond as never,
context: {

View File

@@ -98,14 +98,41 @@ describe("resolveSessionKeyFromResolveParams", () => {
});
});
it("does not page-limit exact key spawnedBy visibility checks", async () => {
const now = Date.now();
const store: Record<string, SessionEntry> = {
[canonicalKey]: {
sessionId: "sess-target",
spawnedBy: "controller-1",
updatedAt: now - 10_000,
},
};
for (let i = 0; i < 120; i += 1) {
store[`agent:main:sibling-${i}`] = {
sessionId: `sess-sibling-${i}`,
spawnedBy: "controller-1",
updatedAt: now - i,
};
}
hoisted.loadSessionStoreMock.mockReturnValue(store);
await expect(
resolveSessionKeyFromResolveParams({
cfg: {},
p: { key: canonicalKey, spawnedBy: "controller-1" },
}),
).resolves.toEqual({
ok: true,
key: canonicalKey,
});
expect(hoisted.listSessionsFromStoreMock).not.toHaveBeenCalled();
});
it("re-checks migrated legacy keys through the same visibility filter", async () => {
const store = {
[legacyKey]: { sessionId: "sess-legacy", updatedAt: 1 },
[legacyKey]: { sessionId: "sess-legacy", spawnedBy: "controller-1", updatedAt: Date.now() },
} satisfies Record<string, SessionEntry>;
hoisted.loadSessionStoreMock.mockImplementation(() => store);
hoisted.listSessionsFromStoreMock.mockReturnValue({
sessions: [{ key: canonicalKey }],
});
await expect(
resolveSessionKeyFromResolveParams({
@@ -121,17 +148,7 @@ describe("resolveSessionKeyFromResolveParams", () => {
const updateSessionStoreCall = hoisted.updateSessionStoreMock.mock.calls[0];
expect(updateSessionStoreCall?.[0]).toBe(storePath);
expect(typeof updateSessionStoreCall?.[1]).toBe("function");
expect(hoisted.listSessionsFromStoreMock).toHaveBeenCalledWith({
cfg: {},
storePath,
store,
opts: {
includeGlobal: false,
includeUnknown: false,
spawnedBy: "controller-1",
agentId: undefined,
},
});
expect(hoisted.listSessionsFromStoreMock).not.toHaveBeenCalled();
});
it("rejects sessions belonging to a deleted agent (key-based lookup)", async () => {

View File

@@ -64,12 +64,11 @@ function isResolvedSessionKeyVisible(params: {
if (typeof params.p.spawnedBy !== "string" || params.p.spawnedBy.trim().length === 0) {
return true;
}
return listSessionsFromStore({
cfg: params.cfg,
storePath: params.storePath,
return filterAndSortSessionEntries({
store: params.store,
now: Date.now(),
opts: resolveSessionVisibilityFilterOptions(params.p),
}).sessions.some((session) => session.key === params.key);
}).some(([key]) => key === params.key);
}
function findVisibleSessionIdMatches(params: {