diff --git a/CHANGELOG.md b/CHANGELOG.md index 0861b062f5d..698d118e078 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -184,6 +184,7 @@ Docs: https://docs.openclaw.ai - CLI/install: refuse state-mutating OpenClaw CLI runs as root by default, keep an explicit `OPENCLAW_ALLOW_ROOT=1` escape hatch for intentional root/container use, and update DigitalOcean setup guidance to run OpenClaw as a non-root user. Fixes #67478. Thanks @Jerry-Xin and @natechicago. - Auto-reply/media: resolve `scp` from `PATH` when staging sandbox media so nonstandard OpenSSH installs can copy remote attachments. - Agents/PI: route PI-native OpenAI-compatible default streams through OpenClaw boundary-aware transports so local-compatible model runs keep API-key injection and transport policy. +- Gateway/media: require authenticated owner or admin context for managed outgoing image bytes instead of trusting requester-session headers. - Gateway/watch: leave `OPENCLAW_TRACE_SYNC_IO` disabled by default in `pnpm gateway:watch:raw` so watch mode avoids noisy Node sync-I/O stack traces unless explicitly requested. - Codex app-server: close stdio stdin before force-killing the managed app-server, matching Codex single-client shutdown behavior and avoiding unsettled CLI exits after successful runs. - CLI/Codex: dispose registered agent harnesses during short-lived CLI shutdown so successful Codex-backed `agent --local` runs do not leave app-server child processes alive. diff --git a/src/gateway/managed-image-attachments.test.ts b/src/gateway/managed-image-attachments.test.ts index fbb1d6623c1..c5967b6fa21 100644 --- a/src/gateway/managed-image-attachments.test.ts +++ b/src/gateway/managed-image-attachments.test.ts @@ -9,13 +9,14 @@ import { setMediaStoreNetworkDepsForTest } from "../media/store.js"; const authorizeGatewayHttpRequestOrReplyMock = vi.fn(); const resolveOpenAiCompatibleHttpOperatorScopesMock = vi.fn(); -const getLatestSubagentRunByChildSessionKeyMock = vi.fn(); +const resolveOpenAiCompatibleHttpSenderIsOwnerMock = vi.fn(); const loadSessionEntryMock = vi.fn(); const readSessionMessagesMock = vi.fn(); vi.mock("./http-utils.js", () => ({ authorizeGatewayHttpRequestOrReply: authorizeGatewayHttpRequestOrReplyMock, resolveOpenAiCompatibleHttpOperatorScopes: resolveOpenAiCompatibleHttpOperatorScopesMock, + resolveOpenAiCompatibleHttpSenderIsOwner: resolveOpenAiCompatibleHttpSenderIsOwnerMock, })); vi.mock("./session-utils.js", () => ({ @@ -23,10 +24,6 @@ vi.mock("./session-utils.js", () => ({ readSessionMessagesAsync: readSessionMessagesMock, })); -vi.mock("../agents/subagent-registry.js", () => ({ - getLatestSubagentRunByChildSessionKey: getLatestSubagentRunByChildSessionKeyMock, -})); - const { DEFAULT_MANAGED_IMAGE_ATTACHMENT_LIMITS, attachManagedOutgoingImagesToMessage, @@ -128,7 +125,6 @@ async function requestManagedImage(params: { authResponse?: Record; headers?: Record; transcriptMessages?: Record[]; - subagentRun?: Record | null; sessionEntry?: { sessionId: string; sessionFile?: string }; }) { authorizeGatewayHttpRequestOrReplyMock.mockImplementation(async ({ res }) => { @@ -140,7 +136,15 @@ async function requestManagedImage(params: { return { ok: true, ...params.authResponse }; }); resolveOpenAiCompatibleHttpOperatorScopesMock.mockReturnValue(params.scopes ?? ["operator.read"]); - getLatestSubagentRunByChildSessionKeyMock.mockReturnValue(params.subagentRun ?? null); + resolveOpenAiCompatibleHttpSenderIsOwnerMock.mockImplementation((_req, requestAuth) => { + if (requestAuth.authMethod === "token" || requestAuth.authMethod === "password") { + return true; + } + return ( + requestAuth.trustDeclaredOperatorScopes === true && + (params.scopes ?? ["operator.read"]).includes("operator.admin") + ); + }); loadSessionEntryMock.mockReturnValue({ storePath: path.join(params.stateDir, "gateway-sessions.json"), entry: params.sessionEntry ?? { sessionId: "sess-1", sessionFile: "session.jsonl" }, @@ -237,7 +241,7 @@ describe("handleManagedOutgoingImageHttpRequest", () => { const { result } = await requestManagedImage({ stateDir, pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, - headers: { "x-openclaw-requester-session-key": sessionKey }, + authResponse: { authMethod: "token" }, }); expect(result.statusCode).toBe(200); @@ -259,25 +263,40 @@ describe("handleManagedOutgoingImageHttpRequest", () => { expect(result.body.byteLength).toBe(0); }); - it("rejects requests from unrelated sessions", async () => { + it("rejects non-owner trusted-proxy requests with self-declared session ownership", async () => { const { attachmentId, sessionKey } = await createFixture(stateDir); const { result } = await requestManagedImage({ stateDir, pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, - headers: { "x-openclaw-requester-session-key": "agent:main:other" }, + authResponse: { authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true }, + headers: { "x-openclaw-requester-session-key": sessionKey }, }); expect(result.statusCode).toBe(403); }); - it("allows device-token access without requester session ownership", async () => { + it("rejects device-token access with self-declared session ownership", async () => { const { attachmentId, sessionKey } = await createFixture(stateDir); const { result } = await requestManagedImage({ stateDir, pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, authResponse: { authMethod: "device-token" }, + headers: { "x-openclaw-requester-session-key": sessionKey }, + }); + + expect(result.statusCode).toBe(403); + }); + + it("serves owner trusted-proxy requests with admin scope", async () => { + const { attachmentId, sessionKey } = await createFixture(stateDir); + + const { result } = await requestManagedImage({ + stateDir, + pathName: `/api/chat/media/outgoing/${encodeURIComponent(sessionKey)}/${attachmentId}/full`, + authResponse: { authMethod: "trusted-proxy", trustDeclaredOperatorScopes: true }, + scopes: ["operator.admin"], }); expect(result.statusCode).toBe(200); @@ -332,14 +351,14 @@ describe("handleManagedOutgoingImageHttpRequest", () => { const first = await requestManagedImage({ stateDir, pathName, - headers: { "x-openclaw-requester-session-key": sessionKey }, + authResponse: { authMethod: "token" }, sessionEntry: { sessionId: "sess-main", sessionFile }, transcriptMessages, }); const second = await requestManagedImage({ stateDir, pathName, - headers: { "x-openclaw-requester-session-key": sessionKey }, + authResponse: { authMethod: "token" }, sessionEntry: { sessionId: "sess-main", sessionFile }, transcriptMessages, }); @@ -354,7 +373,7 @@ describe("handleManagedOutgoingImageHttpRequest", () => { const third = await requestManagedImage({ stateDir, pathName, - headers: { "x-openclaw-requester-session-key": sessionKey }, + authResponse: { authMethod: "token" }, sessionEntry: { sessionId: "sess-main", sessionFile }, transcriptMessages, }); diff --git a/src/gateway/managed-image-attachments.ts b/src/gateway/managed-image-attachments.ts index 0ee8807ff8f..3cb5b63c0eb 100644 --- a/src/gateway/managed-image-attachments.ts +++ b/src/gateway/managed-image-attachments.ts @@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs/promises"; import type { IncomingMessage, ServerResponse } from "node:http"; import path from "node:path"; -import { getLatestSubagentRunByChildSessionKey } from "../agents/subagent-registry.js"; import { resolveStateDir } from "../config/paths.js"; import { readLocalFileSafely } from "../infra/fs-safe.js"; import { tryReadJson, writeJson } from "../infra/json-files.js"; @@ -23,6 +22,7 @@ import { sendJson, sendMethodNotAllowed } from "./http-common.js"; import { authorizeGatewayHttpRequestOrReply, resolveOpenAiCompatibleHttpOperatorScopes, + resolveOpenAiCompatibleHttpSenderIsOwner, } from "./http-utils.js"; import { authorizeOperatorScopesForMethod } from "./method-scopes.js"; import { loadSessionEntry, readSessionMessagesAsync } from "./session-utils.js"; @@ -277,31 +277,6 @@ function buildOutgoingVariantUrl(sessionKey: string, attachmentId: string, varia return `${OUTGOING_IMAGE_ROUTE_PREFIX}/${encodeURIComponent(sessionKey)}/${attachmentId}/${variant}`; } -function resolveRequesterSessionKey(req: IncomingMessage) { - const raw = req.headers["x-openclaw-requester-session-key"]; - if (Array.isArray(raw)) { - return raw[0]?.trim() || null; - } - return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : null; -} - -async function requesterOwnsManagedImageSession(params: { - requesterSessionKey: string; - targetSessionKey: string; -}) { - if (params.requesterSessionKey === params.targetSessionKey) { - return true; - } - const subagentRun = getLatestSubagentRunByChildSessionKey(params.targetSessionKey); - if (!subagentRun) { - return false; - } - return ( - subagentRun.requesterSessionKey === params.requesterSessionKey || - subagentRun.controllerSessionKey === params.requesterSessionKey - ); -} - function deriveAltText(source: string, index: number) { const fallback = `Generated image ${index + 1}`; try { @@ -1009,9 +984,6 @@ export async function handleManagedOutgoingImageHttpRequest( return true; } - const privilegedAccess = - requestAuth.trustDeclaredOperatorScopes || requestAuth.authMethod === "device-token"; - const requestedScopes = resolveOpenAiCompatibleHttpOperatorScopes(req, requestAuth); const scopeAuth = authorizeOperatorScopesForMethod("chat.history", requestedScopes); if (!scopeAuth.allowed) { @@ -1046,32 +1018,17 @@ export async function handleManagedOutgoingImageHttpRequest( sendStatus(res, 404, "not found"); return true; } - if (!privilegedAccess) { - const requesterSessionKey = resolveRequesterSessionKey(req); - if (!requesterSessionKey) { - sendJson(res, 403, { - ok: false, - error: { - type: "forbidden", - message: "requester session ownership required", - }, - }); - return true; - } - const ownsSession = await requesterOwnsManagedImageSession({ - requesterSessionKey, - targetSessionKey: record.sessionKey, + // Requester-session headers are client-declared, so media bytes require + // authenticated owner/admin context rather than trusting a URL-scoped header. + if (!resolveOpenAiCompatibleHttpSenderIsOwner(req, requestAuth)) { + sendJson(res, 403, { + ok: false, + error: { + type: "forbidden", + message: "owner access required", + }, }); - if (!ownsSession) { - sendJson(res, 403, { - ok: false, - error: { - type: "forbidden", - message: "requester session does not own attachment session", - }, - }); - return true; - } + return true; } if (!(await recordMatchesTranscriptMessage(record))) { sendStatus(res, 404, "not found");