mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
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:
committed by
GitHub
parent
4d8aec8210
commit
418d7afb33
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -273,6 +273,7 @@ describe("validateTalkSession", () => {
|
||||
expect(
|
||||
validateTalkSessionCreateParams({
|
||||
sessionKey: "agent:main:main",
|
||||
spawnedBy: "agent:main:parent",
|
||||
provider: "openai",
|
||||
model: "gpt-realtime-2",
|
||||
voice: "alloy",
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user