Skip to content

Commit 1e791bc

Browse files
committed
Merge branch 'main' into office-re-layout
2 parents 4334013 + 5e2153c commit 1e791bc

4 files changed

Lines changed: 401 additions & 1 deletion

File tree

lat.md/remote-dashboard-oauth.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@ Remote session history uses the same selected authentication transport as manage
3636

3737
[[src/main/remote-sessions.ts#remoteRequestJson]] routes direct OAuth session lists, search, messages, media, titles, and deletion through the persistent cookie partition. Token and SSH session requests retain the session-token header.
3838

39+
## Authenticated management request boundary
40+
41+
Direct Remote management features share one main-process request client that selects cookie or token authentication without exposing reusable credentials through IPC.
42+
43+
[[src/main/remote-api.ts#remoteDashboardRequestJson]] resolves `auto` through the public status probe, routes OAuth through the persistent Electron partition, and routes token mode through `X-Hermes-Session-Token`. Probe failures never guess another transport or fall back to local state.
44+
45+
[[src/main/remote-api.ts#RemoteDashboardApiError]] normalizes HTTP status for feature adapters. A `404` marks only that feature unsupported; OAuth login-required errors retain their original reauthentication signal.
46+
3947
## Failure behavior
4048

4149
Missing or expired OAuth sessions stop Remote chat and request browser sign-in without falling back to local state or legacy `/v1`.
@@ -77,3 +85,11 @@ Insecure remote WebSockets use a one-shot loopback relay with an unguessable pat
7785
### OAuth bearer suppression
7886

7987
Once a Remote connection resolves to OAuth, shared request headers omit any stored token while token and SSH authentication remain unchanged.
88+
89+
### Management authentication routing
90+
91+
Management requests resolve `auto` before selecting token or OAuth transport, preserve profile scoping, skip probing for explicit modes, reject non-Remote callers, and retain OAuth login-required errors for reauthentication.
92+
93+
### Management failure classification
94+
95+
Token and OAuth HTTP failures expose the same structured status, with `404` identified as an unsupported feature instead of an application-wide connection failure.

src/main/remote-api.test.ts

Lines changed: 294 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,294 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const {
4+
remoteRequestJson,
5+
requestRemoteOAuthJson,
6+
probeRemoteAuthMode,
7+
RemoteOAuthError,
8+
} = vi.hoisted(() => {
9+
class TestRemoteOAuthError extends Error {
10+
readonly needsOAuthLogin: boolean;
11+
12+
constructor(
13+
message: string,
14+
readonly code:
15+
| "oauth_cancelled"
16+
| "oauth_connection_changed"
17+
| "oauth_login_required"
18+
| "oauth_request_failed",
19+
readonly statusCode?: number,
20+
) {
21+
super(message);
22+
this.name = "RemoteOAuthError";
23+
this.needsOAuthLogin = code === "oauth_login_required";
24+
}
25+
}
26+
27+
return {
28+
remoteRequestJson: vi.fn(),
29+
requestRemoteOAuthJson: vi.fn(),
30+
probeRemoteAuthMode: vi.fn(),
31+
RemoteOAuthError: TestRemoteOAuthError,
32+
};
33+
});
34+
35+
vi.mock("./remote-sessions", () => ({
36+
dashboardApiUrl: (
37+
config: { remoteUrl: string; profile?: string },
38+
path: string,
39+
) => {
40+
const url = new URL(path, `${config.remoteUrl.replace(/\/+$/, "")}/`);
41+
if (config.profile && config.profile !== "default") {
42+
url.searchParams.set("profile", config.profile);
43+
}
44+
return url.toString();
45+
},
46+
remoteRequestJson,
47+
}));
48+
49+
vi.mock("./remote-oauth", () => ({
50+
probeRemoteAuthMode,
51+
requestRemoteOAuthJson,
52+
RemoteOAuthError,
53+
}));
54+
55+
import { remoteDashboardRequestJson } from "./remote-api";
56+
import type { ConnectionConfig } from "./config";
57+
58+
function remoteConnection(
59+
remoteAuthMode: ConnectionConfig["remoteAuthMode"],
60+
): ConnectionConfig {
61+
return {
62+
mode: "remote",
63+
remoteUrl: "https://remote.example:9119",
64+
apiKey: "secret",
65+
remoteAuthMode,
66+
remoteChatTransport: "auto",
67+
sshChatTransport: "auto",
68+
ssh: {
69+
host: "",
70+
port: 22,
71+
username: "",
72+
keyPath: "",
73+
remotePort: 9119,
74+
localPort: 9119,
75+
},
76+
};
77+
}
78+
79+
beforeEach(() => {
80+
remoteRequestJson.mockReset();
81+
requestRemoteOAuthJson.mockReset();
82+
probeRemoteAuthMode.mockReset();
83+
});
84+
85+
describe("remote dashboard API client", () => {
86+
it("uses token dashboard transport with profile scoping", async () => {
87+
remoteRequestJson.mockResolvedValue({ ok: true });
88+
89+
await expect(
90+
remoteDashboardRequestJson(
91+
remoteConnection("token"),
92+
"/api/tools/toolsets",
93+
{ method: "PUT", body: { enabled: true } },
94+
"research",
95+
),
96+
).resolves.toEqual({ ok: true });
97+
98+
expect(remoteRequestJson).toHaveBeenCalledWith(
99+
{
100+
remoteUrl: "https://remote.example:9119",
101+
apiKey: "secret",
102+
profile: "research",
103+
},
104+
"/api/tools/toolsets",
105+
{ method: "PUT", body: { enabled: true } },
106+
);
107+
expect(requestRemoteOAuthJson).not.toHaveBeenCalled();
108+
expect(probeRemoteAuthMode).not.toHaveBeenCalled();
109+
});
110+
111+
it("uses cookie-authenticated OAuth transport with scoped URL", async () => {
112+
requestRemoteOAuthJson.mockResolvedValue({ profiles: [] });
113+
114+
await expect(
115+
remoteDashboardRequestJson(
116+
remoteConnection("oauth"),
117+
"/api/profiles",
118+
{},
119+
"research",
120+
),
121+
).resolves.toEqual({ profiles: [] });
122+
123+
expect(requestRemoteOAuthJson).toHaveBeenCalledWith(
124+
"https://remote.example:9119/api/profiles?profile=research",
125+
{},
126+
);
127+
expect(remoteRequestJson).not.toHaveBeenCalled();
128+
expect(probeRemoteAuthMode).not.toHaveBeenCalled();
129+
});
130+
131+
it("resolves auto authentication to OAuth before selecting transport", async () => {
132+
// @lat: [[remote-dashboard-oauth#Test specifications#Management authentication routing]]
133+
probeRemoteAuthMode.mockResolvedValue({
134+
authMode: "oauth",
135+
version: "1.2.3",
136+
});
137+
requestRemoteOAuthJson.mockResolvedValue({ profiles: [] });
138+
139+
await expect(
140+
remoteDashboardRequestJson(
141+
remoteConnection("auto"),
142+
"/api/profiles",
143+
{},
144+
"research",
145+
),
146+
).resolves.toEqual({ profiles: [] });
147+
148+
expect(probeRemoteAuthMode).toHaveBeenCalledOnce();
149+
expect(probeRemoteAuthMode).toHaveBeenCalledWith(
150+
"https://remote.example:9119",
151+
);
152+
expect(requestRemoteOAuthJson).toHaveBeenCalledWith(
153+
"https://remote.example:9119/api/profiles?profile=research",
154+
{},
155+
);
156+
expect(remoteRequestJson).not.toHaveBeenCalled();
157+
});
158+
159+
it("resolves auto authentication to token before selecting transport", async () => {
160+
probeRemoteAuthMode.mockResolvedValue({ authMode: "token", version: null });
161+
remoteRequestJson.mockResolvedValue({ ok: true });
162+
163+
await expect(
164+
remoteDashboardRequestJson(
165+
remoteConnection("auto"),
166+
"/api/tools/toolsets",
167+
{ method: "PUT", body: { enabled: true } },
168+
"research",
169+
),
170+
).resolves.toEqual({ ok: true });
171+
172+
expect(probeRemoteAuthMode).toHaveBeenCalledOnce();
173+
expect(probeRemoteAuthMode).toHaveBeenCalledWith(
174+
"https://remote.example:9119",
175+
);
176+
expect(remoteRequestJson).toHaveBeenCalledWith(
177+
{
178+
remoteUrl: "https://remote.example:9119",
179+
apiKey: "secret",
180+
profile: "research",
181+
},
182+
"/api/tools/toolsets",
183+
{ method: "PUT", body: { enabled: true } },
184+
);
185+
expect(requestRemoteOAuthJson).not.toHaveBeenCalled();
186+
});
187+
188+
it("normalizes auto authentication probe failures before selecting transport", async () => {
189+
probeRemoteAuthMode.mockRejectedValue(
190+
new RemoteOAuthError(
191+
"Remote OAuth probe failed (404).",
192+
"oauth_request_failed",
193+
404,
194+
),
195+
);
196+
197+
await expect(
198+
remoteDashboardRequestJson(
199+
remoteConnection("auto"),
200+
"/api/tools/toolsets",
201+
),
202+
).rejects.toMatchObject({
203+
name: "RemoteDashboardApiError",
204+
statusCode: 404,
205+
unsupported: true,
206+
});
207+
208+
expect(probeRemoteAuthMode).toHaveBeenCalledOnce();
209+
expect(remoteRequestJson).not.toHaveBeenCalled();
210+
expect(requestRemoteOAuthJson).not.toHaveBeenCalled();
211+
});
212+
213+
it("omits default profile query parameter", async () => {
214+
requestRemoteOAuthJson.mockResolvedValue([]);
215+
await remoteDashboardRequestJson(
216+
remoteConnection("oauth"),
217+
"/api/tools/toolsets",
218+
{},
219+
"default",
220+
);
221+
expect(requestRemoteOAuthJson).toHaveBeenCalledWith(
222+
"https://remote.example:9119/api/tools/toolsets",
223+
{},
224+
);
225+
});
226+
227+
it("rejects non-Remote connections instead of touching local state", async () => {
228+
const local = {
229+
...remoteConnection("token"),
230+
mode: "local",
231+
} as ConnectionConfig;
232+
await expect(
233+
remoteDashboardRequestJson(local, "/api/status"),
234+
).rejects.toThrow("direct Remote mode");
235+
expect(remoteRequestJson).not.toHaveBeenCalled();
236+
expect(requestRemoteOAuthJson).not.toHaveBeenCalled();
237+
expect(probeRemoteAuthMode).not.toHaveBeenCalled();
238+
});
239+
240+
it("normalizes token HTTP failures with a structured status", async () => {
241+
// @lat: [[remote-dashboard-oauth#Test specifications#Management failure classification]]
242+
remoteRequestJson.mockRejectedValue(
243+
new Error('404: {"detail":"Not Found"}'),
244+
);
245+
246+
await expect(
247+
remoteDashboardRequestJson(
248+
remoteConnection("token"),
249+
"/api/tools/toolsets",
250+
),
251+
).rejects.toMatchObject({
252+
name: "RemoteDashboardApiError",
253+
statusCode: 404,
254+
unsupported: true,
255+
});
256+
});
257+
258+
it("normalizes OAuth feature failures without losing HTTP status", async () => {
259+
requestRemoteOAuthJson.mockRejectedValue(
260+
new RemoteOAuthError(
261+
"Remote OAuth request failed (404).",
262+
"oauth_request_failed",
263+
404,
264+
),
265+
);
266+
267+
await expect(
268+
remoteDashboardRequestJson(
269+
remoteConnection("oauth"),
270+
"/api/tools/toolsets",
271+
),
272+
).rejects.toMatchObject({
273+
name: "RemoteDashboardApiError",
274+
statusCode: 404,
275+
unsupported: true,
276+
});
277+
});
278+
279+
it("preserves OAuth login-required errors for reauthentication", async () => {
280+
const loginRequired = new RemoteOAuthError(
281+
"Remote OAuth session expired. Sign in again.",
282+
"oauth_login_required",
283+
401,
284+
);
285+
requestRemoteOAuthJson.mockRejectedValue(loginRequired);
286+
287+
await expect(
288+
remoteDashboardRequestJson(
289+
remoteConnection("oauth"),
290+
"/api/tools/toolsets",
291+
),
292+
).rejects.toBe(loginRequired);
293+
});
294+
});

0 commit comments

Comments
 (0)