Skip to content

Commit 01d95b9

Browse files
mednsodysseus0
andauthored
fix(gateway): allow bearer-auth session history reads (#81815)
Merged via squash. Prepared head SHA: eb49667 Co-authored-by: medns <1575008+medns@users.noreply.github.com> Co-authored-by: odysseus0 <8635094+odysseus0@users.noreply.github.com> Reviewed-by: @odysseus0
1 parent 504f0df commit 01d95b9

6 files changed

Lines changed: 73 additions & 25 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ Docs: https://docs.openclaw.ai
5555
- Auth/Codex: load legacy OAuth sidecar credentials in the embedded runner's secrets-runtime auth loaders so Telegram replies, cron-triggered turns, and other isolated sub-agent lanes can reach the existing #83312 refresh-and-rewrite migration instead of failing with `No API key found for provider "openai-codex"` until the user runs `openclaw doctor`. Thanks @Totalsolutionsync and @romneyda.
5656
- Codex/failover: classify `deactivated_workspace` as a permanent auth failure so configured fallback models can advance when a Codex workspace is deactivated. (#55893) Thanks @litang9.
5757
- Exec: keep configured `tools.exec.pathPrepend` entries ahead of user shell startup PATH changes on POSIX gateway runs. (#81403) Thanks @medns.
58+
- Gateway/sessions: allow shared-secret bearer callers to read and stream session history without an explicit scope header. (#81815) Thanks @medns.
5859

5960
## 2026.5.20
6061

docs/gateway/operator-scopes.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,9 @@ own `system.run` exec approval policy.
102102
## Shared-secret auth
103103

104104
Shared gateway token/password auth is treated as trusted operator access for
105-
that Gateway. OpenAI-compatible HTTP surfaces and `/tools/invoke` restore the
106-
normal full operator default scope set for shared-secret bearer auth, even if a
107-
caller sends narrower declared scopes.
105+
that Gateway. OpenAI-compatible HTTP surfaces, `/tools/invoke`, and HTTP session
106+
history endpoints restore the normal full operator default scope set for
107+
shared-secret bearer auth, even if a caller sends narrower declared scopes.
108108

109109
Identity-bearing modes, such as trusted proxy auth or private-ingress `none`,
110110
can still honor explicit declared scopes. Use separate Gateways for real trust

docs/gateway/security/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -935,7 +935,7 @@ Important boundary note:
935935
- On the OpenAI-compatible HTTP surface, shared-secret bearer auth restores the full default operator scopes (`operator.admin`, `operator.approvals`, `operator.pairing`, `operator.read`, `operator.talk.secrets`, `operator.write`) and owner semantics for agent turns; narrower `x-openclaw-scopes` values do not reduce that shared-secret path.
936936
- Per-request scope semantics on HTTP only apply when the request comes from an identity-bearing mode such as trusted proxy auth, or from an explicitly no-auth private ingress.
937937
- In those identity-bearing modes, omitting `x-openclaw-scopes` falls back to the normal operator default scope set; send the header explicitly when you want a narrower scope set.
938-
- `/tools/invoke` follows the same shared-secret rule: token/password bearer auth is treated as full operator access there too, while identity-bearing modes still honor declared scopes.
938+
- `/tools/invoke` and HTTP session history endpoints follow the same shared-secret rule: token/password bearer auth is treated as full operator access there too, while identity-bearing modes still honor declared scopes.
939939
- Do not share these credentials with untrusted callers; prefer separate gateways per trust boundary.
940940

941941
**Trust assumption:** tokenless Serve auth assumes the gateway host is trusted.

src/gateway/sessions-history-http.revocation.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ vi.mock("./http-utils.js", () => ({
4343
const value = req.headers[name.toLowerCase()];
4444
return Array.isArray(value) ? value[0] : value;
4545
},
46-
resolveTrustedHttpOperatorScopes: () => ["operator.read"],
46+
resolveSharedSecretHttpOperatorScopes: () => ["operator.read"],
4747
authorizeScopedGatewayHttpRequestOrReply: async () => ({
4848
cfg: { gateway: { webchat: { chatHistoryMaxChars: 2000 } } },
4949
requestAuth: { trustDeclaredOperatorScopes: true },

src/gateway/sessions-history-http.test.ts

Lines changed: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -789,33 +789,76 @@ describe("session history HTTP endpoints", () => {
789789
});
790790
expect(wsHistory.ok).toBe(false);
791791
expect(wsHistory.error?.message).toBe("missing scope: operator.read");
792+
} finally {
793+
ws.close();
794+
await server.close();
795+
envSnapshot.restore();
796+
}
797+
});
792798

799+
test("allows HTTP session history reads with shared-secret bearer auth and default scopes", async () => {
800+
await seedSession({ text: "bearer allowed history" });
801+
802+
const started = await startServerWithClient("test-gateway-token-1234567890");
803+
const { server, ws, port, envSnapshot } = started;
804+
try {
793805
const httpHistory = await fetch(
794806
`http://127.0.0.1:${port}/sessions/${encodeURIComponent("agent:main:main")}/history?limit=1`,
807+
{
808+
headers: AUTH_HEADER,
809+
},
810+
);
811+
expect(httpHistory.status).toBe(200);
812+
const body = await httpHistory.json();
813+
expect(body.sessionKey).toBe("agent:main:main");
814+
expect(body.messages?.[0]?.content?.[0]?.text).toBe("bearer allowed history");
815+
} finally {
816+
ws.close();
817+
await server.close();
818+
envSnapshot.restore();
819+
}
820+
});
821+
822+
test("maintains HTTP SSE streams with shared-secret bearer auth across transcript updates", async () => {
823+
const { storePath } = await seedSession({ text: "bearer allowed history" });
824+
825+
const started = await startServerWithClient("test-gateway-token-1234567890");
826+
const { server, ws, port, envSnapshot } = started;
827+
try {
828+
const res = await fetch(
829+
`http://127.0.0.1:${port}/sessions/${encodeURIComponent("agent:main:main")}/history`,
795830
{
796831
headers: {
797832
...AUTH_HEADER,
798-
"x-openclaw-scopes": "operator.approvals",
833+
Accept: "text/event-stream",
799834
},
800835
},
801836
);
802-
expect(httpHistory.status).toBe(403);
803-
expectErrorResponse(await httpHistory.json(), {
804-
type: "forbidden",
805-
message: "missing scope: operator.read",
837+
expect(res.status).toBe(200);
838+
const reader = res.body?.getReader();
839+
expect(reader).toBeDefined();
840+
const stream = { reader: reader!, streamState: { buffer: "" } };
841+
842+
await expectHistoryEventTexts(stream, ["bearer allowed history"]);
843+
844+
const appended = await appendAssistantMessageToSessionTranscript({
845+
sessionKey: "agent:main:main",
846+
text: "bearer sse update",
847+
storePath,
806848
});
849+
expect(appended.ok).toBe(true);
807850

808-
const httpHistoryWithoutScopes = await fetch(
809-
`http://127.0.0.1:${port}/sessions/${encodeURIComponent("agent:main:main")}/history?limit=1`,
810-
{
811-
headers: AUTH_HEADER,
812-
},
813-
);
814-
expect(httpHistoryWithoutScopes.status).toBe(403);
815-
expectErrorResponse(await httpHistoryWithoutScopes.json(), {
816-
type: "forbidden",
817-
message: "missing scope: operator.read",
851+
if (!appended.ok) {
852+
throw new Error(`append failed: ${appended.reason}`);
853+
}
854+
855+
await expectMessageEventMatch(stream, {
856+
text: "bearer sse update",
857+
seq: 2,
858+
id: appended.messageId,
818859
});
860+
861+
await stream.reader.cancel();
819862
} finally {
820863
ws.close();
821864
await server.close();

src/gateway/sessions-history-http.ts

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {
2121
authorizeScopedGatewayHttpRequestOrReply,
2222
checkGatewayHttpRequestAuth,
2323
getHeader,
24-
resolveTrustedHttpOperatorScopes,
24+
resolveSharedSecretHttpOperatorScopes,
2525
} from "./http-utils.js";
2626
import { authorizeOperatorScopesForMethod } from "./method-scopes.js";
2727
import { DEFAULT_CHAT_HISTORY_TEXT_MAX_CHARS } from "./server-methods/chat.js";
@@ -118,8 +118,9 @@ export async function handleSessionHistoryHttpRequest(
118118
return true;
119119
}
120120

121-
// HTTP callers must declare the same least-privilege operator scopes they
122-
// intend to use over WS so both transport surfaces enforce the same gate.
121+
// Session history intentionally uses the shared-secret HTTP trust model:
122+
// token/password bearer auth grants default operator scopes so simple API key
123+
// callers can read their own history without a scope header.
123124
const authResult = await authorizeScopedGatewayHttpRequestOrReply({
124125
req,
125126
res,
@@ -128,7 +129,7 @@ export async function handleSessionHistoryHttpRequest(
128129
allowRealIpFallback: opts.allowRealIpFallback,
129130
rateLimiter: opts.rateLimiter,
130131
operatorMethod: "chat.history",
131-
resolveOperatorScopes: resolveTrustedHttpOperatorScopes,
132+
resolveOperatorScopes: resolveSharedSecretHttpOperatorScopes,
132133
});
133134
if (!authResult) {
134135
return true;
@@ -285,7 +286,10 @@ export async function handleSessionHistoryHttpRequest(
285286
if (!currentRequestAuth.ok) {
286287
return false;
287288
}
288-
const requestedScopes = resolveTrustedHttpOperatorScopes(req, currentRequestAuth.requestAuth);
289+
const requestedScopes = resolveSharedSecretHttpOperatorScopes(
290+
req,
291+
currentRequestAuth.requestAuth,
292+
);
289293
return authorizeOperatorScopesForMethod("chat.history", requestedScopes).allowed;
290294
};
291295

0 commit comments

Comments
 (0)