Skip to content

Commit e410b5d

Browse files
authored
fix(ui): guard shared form control sizing
1 parent dd3a4a8 commit e410b5d

40 files changed

Lines changed: 1783 additions & 595 deletions

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ Docs: https://docs.openclaw.ai
2222
- **Source build portability:** keep tsdown configuration self-contained so builds do not depend on resolving the tsdown package from unrun's temporary module directory.
2323
- **Diffs rendering:** render viewer and image output from one SSR preload, preserve language-pack highlighting through hydration, normalize language hints case-insensitively, skip identical before/after inputs with an explicit `changed` result, report truthful file-render and input errors, cache hash-pinned viewer runtimes, and prefer canonical file settings over stale aliases. (#100487)
2424
- **Remote browser reliability:** bound persistent Playwright tab enumeration by the existing remote CDP timeout budget and retire timed-out connection attempts so late completions cannot restore a stuck connection. (#80147, #58968) Thanks @HemantSudarshan and @KeaneYan.
25+
- **Managed browser cookie persistence:** initialize new isolated macOS headless profiles with a non-interactive encryption key while preserving existing profile keys, and close Chromium through CDP before bounded signal fallback so persistent logins survive graceful browser and Gateway restarts. (#96704, #98284) Thanks @TurboTheTurtle.
2526
- **MCP OAuth response bounds:** reject body-less foreign error bodies without calling their inherently unbounded `text()` fallback, while preserving HTTP status and headers for safe SDK diagnostics. (#98143) Thanks @Pick-cat.
2627
- **Control UI approval prompts:** keep stale resolve failures and busy-state cleanup from leaking across newer approvals or Gateway reconnects. (#98394) Thanks @haruaiclone-droid.
27-
- **Agent empty replies:** surface a visible failure when a completed interactive turn has no deliverable reply, including queued follow-ups, while preserving explicit silence, pending continuations, and committed side effects. (#100456) Thanks @mushuiyu886.
28+
- **Agent empty replies:** surface a visible failure when a completed interactive turn has no deliverable reply, including queued follow-ups, while preserving explicit silence, pending continuations, and committed side effects, honoring queued send policies, and treating compaction notices as progress. (#100456) Thanks @mushuiyu886.
2829
- **Child process output safety:** prevent stdout/stderr pipe failures from crashing agent exec sessions, local TUI shell commands, and bounded process execution. (#100407, #100406, #100410) Thanks @cxbAsDev.
2930
- **Background refresh isolation:** keep remote skill-bin refreshes running when one node fails, and contain periodic subagent-sweeper failures without hiding errors from direct callers. (#100393, #100390) Thanks @cxbAsDev.
3031
- **Skill scan diagnostics:** report directory enumeration failures through the existing resource diagnostics instead of silently dropping affected skills. (#100380) Thanks @wendy-chsy.

docs/channels/ambient-room-events.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,8 @@ With `messages.groupChat.unmentionedInbound: "room_event"`:
4646

4747
Room events use strict visible delivery. Final assistant text is private. The agent must call `message(action=send)` to post in the room.
4848

49+
Typing and lifecycle status reactions stay suppressed for room events. The one explicit receipt exception is `messages.ackReactionScope: "all"`, which sends the configured ack reaction; use any narrower scope or `"off"` when the room must remain completely silent.
50+
4951
## Discord example
5052

5153
```json5

docs/channels/discord.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -879,6 +879,14 @@ See [Slash commands](/tools/slash-commands) for the command catalog and behavior
879879
- Discord accepts unicode emoji or custom emoji names.
880880
- Use `""` to disable the reaction for a channel or account.
881881

882+
**Scope (`messages.ackReactionScope`):**
883+
884+
Values: `"all"` (DMs + groups, including ambient room events), `"direct"` (DMs only), `"group-all"` (every group message except ambient room events, no DMs), `"group-mentions"` (groups when the bot is mentioned; **no DMs**, default), `"off"` / `"none"` (disabled).
885+
886+
<Note>
887+
The default scope (`"group-mentions"`) does not fire ack reactions in direct messages or ambient room events. To get an ack reaction on inbound Discord DMs and quiet room events, set `messages.ackReactionScope` to `"all"`.
888+
</Note>
889+
882890
</Accordion>
883891

884892
<Accordion title="Config writes">

docs/channels/slack.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1102,14 +1102,14 @@ The Slack provider reads scope from `messages.ackReactionScope` (default `"group
11021102

11031103
Values:
11041104

1105-
- `"all"`: react in DMs and groups.
1105+
- `"all"`: react in DMs and groups, including ambient room events.
11061106
- `"direct"`: react in DMs only.
1107-
- `"group-all"`: react on every group message (no DMs).
1107+
- `"group-all"`: react on every group message except ambient room events (no DMs).
11081108
- `"group-mentions"` (default): react in groups, but only when the bot is mentioned (or in group mentionables that opted in). **DMs are excluded.**
11091109
- `"off"` / `"none"`: never react.
11101110

11111111
<Note>
1112-
The default scope (`"group-mentions"`) does not fire ack reactions in direct messages. To see the configured `ackReaction` (for example `"eyes"`) on inbound Slack DMs, set `messages.ackReactionScope` to `"direct"` or `"all"`. `messages.ackReactionScope` is read at Slack provider startup, so a gateway restart is needed for the change to take effect.
1112+
The default scope (`"group-mentions"`) does not fire ack reactions in direct messages or ambient room events. To see the configured `ackReaction` (for example `"eyes"`) on inbound Slack DMs and quiet room events, set `messages.ackReactionScope` to `"all"`. `messages.ackReactionScope` is read at Slack provider startup, so a gateway restart is needed for the change to take effect.
11131113
</Note>
11141114

11151115
```json5

docs/channels/telegram.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -667,10 +667,10 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
667667

668668
**Scope (`messages.ackReactionScope`, default `"group-mentions"`; no Telegram-account or Telegram-channel override today):**
669669

670-
`all` (DMs + groups), `direct` (DMs only), `group-all` (every group message, no DMs), `group-mentions` (groups when the bot is mentioned; **no DMs** — default), `off` / `none` (disabled).
670+
`all` (DMs + groups, including ambient room events), `direct` (DMs only), `group-all` (every group message except ambient room events, no DMs), `group-mentions` (groups when the bot is mentioned; **no DMs** — default), `off` / `none` (disabled).
671671

672672
<Note>
673-
The default scope (`group-mentions`) does not fire ack reactions in DMs. Set `messages.ackReactionScope` to `direct` or `all` for that. This value is read at Telegram provider startup, so a gateway restart is needed for the change to take effect.
673+
The default scope (`group-mentions`) does not fire ack reactions in DMs or ambient room events. Use `direct` or `all` for DMs; only `all` acknowledges ambient room events. This value is read at Telegram provider startup, so a gateway restart is needed for the change to take effect.
674674
</Note>
675675

676676
</Accordion>

extensions/browser/src/browser/chrome.internal.test.ts

Lines changed: 36 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -418,18 +418,36 @@ describe("chrome.ts internal", () => {
418418
});
419419

420420
it("adds --disable-dev-shm-usage on linux", () => {
421-
const originalPlatform = process.platform;
422-
Object.defineProperty(process, "platform", { value: "linux" });
423-
try {
424-
const args = buildOpenClawChromeLaunchArgs({
425-
resolved: baseResolved(),
426-
profile: baseProfile,
427-
userDataDir: "/tmp/foo",
428-
});
429-
expect(args).toContain("--disable-dev-shm-usage");
430-
} finally {
431-
Object.defineProperty(process, "platform", { value: originalPlatform });
432-
}
421+
const args = buildOpenClawChromeLaunchArgs({
422+
resolved: baseResolved(),
423+
profile: baseProfile,
424+
userDataDir: "/tmp/foo",
425+
platform: "linux",
426+
});
427+
expect(args).toContain("--disable-dev-shm-usage");
428+
expect(args).not.toContain("--use-mock-keychain");
429+
});
430+
431+
it("uses a non-interactive keychain for isolated managed profiles on macOS", () => {
432+
const args = buildOpenClawChromeLaunchArgs({
433+
resolved: baseResolved(),
434+
profile: baseProfile,
435+
userDataDir: "/tmp/foo",
436+
platform: "darwin",
437+
useMockKeychain: true,
438+
});
439+
expect(args).toContain("--use-mock-keychain");
440+
expect(args).not.toContain("--disable-dev-shm-usage");
441+
});
442+
443+
it("keeps existing macOS profiles on their original keychain", () => {
444+
const args = buildOpenClawChromeLaunchArgs({
445+
resolved: baseResolved(),
446+
profile: baseProfile,
447+
userDataDir: "/tmp/foo",
448+
platform: "darwin",
449+
});
450+
expect(args).not.toContain("--use-mock-keychain");
433451
});
434452

435453
it("propagates extraArgs", () => {
@@ -1138,10 +1156,12 @@ describe("chrome.ts internal", () => {
11381156
it("escalates to SIGKILL when CDP keeps reporting reachable past the deadline", async () => {
11391157
vi.stubGlobal(
11401158
"fetch",
1141-
vi.fn().mockResolvedValue({
1142-
ok: true,
1143-
json: async () => ({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }),
1144-
} as unknown as Response),
1159+
vi.fn(
1160+
async () =>
1161+
new Response(JSON.stringify({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" }), {
1162+
headers: { "content-type": "application/json" },
1163+
}),
1164+
),
11451165
);
11461166
const proc = makeFakeProc();
11471167
await stopOpenClawChrome(

extensions/browser/src/browser/chrome.profile-decoration.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,10 @@ function readNestedRecord(root: unknown, key: string): Record<string, unknown> |
3737
return asRecord(asRecord(root)?.[key]);
3838
}
3939

40+
function readDefaultProfileInfo(localState: unknown): Record<string, unknown> | null {
41+
return readNestedRecord(readNestedRecord(asRecord(localState)?.profile, "info_cache"), "Default");
42+
}
43+
4044
function setDeep(obj: Record<string, unknown>, keys: string[], value: unknown) {
4145
if (keys.length === 0) {
4246
return;
@@ -76,8 +80,7 @@ export function isProfileDecorated(
7680
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
7781

7882
const localState = safeReadJson(localStatePath);
79-
const profile = localState?.profile;
80-
const info = readNestedRecord(readNestedRecord(profile, "info_cache"), "Default");
83+
const info = readDefaultProfileInfo(localState);
8184

8285
const prefs = safeReadJson(preferencesPath);
8386
const browserTheme = readNestedRecord(prefs?.browser, "theme");
@@ -111,13 +114,19 @@ export function isProfileDecorated(
111114
return nameOk && localSeedOk && prefOk && downloadOk;
112115
}
113116

117+
/** Return whether this profile was initialized with Chromium's automation keychain. */
118+
export function usesOpenClawMockKeychain(userDataDir: string): boolean {
119+
const localState = safeReadJson(path.join(userDataDir, "Local State"));
120+
return readDefaultProfileInfo(localState)?.openclaw_mock_keychain === true;
121+
}
122+
114123
/**
115124
* Best-effort profile decoration (name + lobster-orange). Chrome preference keys
116125
* vary by version; we keep this conservative and idempotent.
117126
*/
118127
export function decorateOpenClawProfile(
119128
userDataDir: string,
120-
opts?: { name?: string; color?: string; downloadDir?: string },
129+
opts?: { name?: string; color?: string; downloadDir?: string; mockKeychain?: boolean },
121130
) {
122131
const desiredName = opts?.name ?? DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME;
123132
const desiredColor = (opts?.color ?? DEFAULT_OPENCLAW_BROWSER_COLOR).toUpperCase();
@@ -131,6 +140,11 @@ export function decorateOpenClawProfile(
131140
setDeep(localState, ["profile", "info_cache", "Default", "name"], desiredName);
132141
setDeep(localState, ["profile", "info_cache", "Default", "shortcut_name"], desiredName);
133142
setDeep(localState, ["profile", "info_cache", "Default", "user_name"], desiredName);
143+
if (opts?.mockKeychain) {
144+
// Chrome preserves extra fields in this per-profile dictionary. Recording
145+
// the key source here keeps later launches on the same encryption backend.
146+
setDeep(localState, ["profile", "info_cache", "Default", "openclaw_mock_keychain"], true);
147+
}
134148
// Color keys are best-effort (Chrome changes these frequently).
135149
setDeep(localState, ["profile", "info_cache", "Default", "profile_color"], desiredColor);
136150
setDeep(localState, ["profile", "info_cache", "Default", "user_color"], desiredColor);

extensions/browser/src/browser/chrome.test.ts

Lines changed: 97 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
resolveBrowserExecutableForPlatform,
3030
stopOpenClawChrome,
3131
} from "./chrome.js";
32+
import { usesOpenClawMockKeychain } from "./chrome.profile-decoration.js";
3233
import {
3334
DEFAULT_OPENCLAW_BROWSER_COLOR,
3435
DEFAULT_OPENCLAW_BROWSER_PROFILE_NAME,
@@ -137,10 +138,17 @@ async function stopChromeWithProc(proc: ReturnType<typeof makeChromeTestProc>, t
137138
);
138139
}
139140

140-
function makeChromeTestProc(overrides?: Partial<{ killed: boolean; exitCode: number | null }>) {
141+
function makeChromeTestProc(
142+
overrides?: Partial<{
143+
killed: boolean;
144+
exitCode: number | null;
145+
signalCode: NodeJS.Signals | null;
146+
}>,
147+
) {
141148
return {
142149
killed: overrides?.killed ?? false,
143150
exitCode: overrides?.exitCode ?? null,
151+
signalCode: overrides?.signalCode ?? null,
144152
kill: vi.fn(),
145153
};
146154
}
@@ -232,6 +240,22 @@ describe("browser chrome profile decoration", () => {
232240
).toBe(true);
233241
});
234242

243+
it("records the managed profile keychain backend without changing existing profiles", async () => {
244+
const userDataDir = await createUserDataDir();
245+
decorateOpenClawProfile(userDataDir, {
246+
color: DEFAULT_OPENCLAW_BROWSER_COLOR,
247+
mockKeychain: true,
248+
});
249+
250+
expect(usesOpenClawMockKeychain(userDataDir)).toBe(true);
251+
decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR });
252+
expect(usesOpenClawMockKeychain(userDataDir)).toBe(true);
253+
254+
const existingUserDataDir = await createUserDataDir();
255+
decorateOpenClawProfile(existingUserDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR });
256+
expect(usesOpenClawMockKeychain(existingUserDataDir)).toBe(false);
257+
});
258+
235259
it("treats missing managed download prefs as undecorated when required", async () => {
236260
const userDataDir = await createUserDataDir();
237261
decorateOpenClawProfile(userDataDir, { color: DEFAULT_OPENCLAW_BROWSER_COLOR });
@@ -867,22 +891,84 @@ describe("browser chrome helpers", () => {
867891
expect(proc.kill).not.toHaveBeenCalled();
868892
});
869893

894+
it.each([
895+
{ label: "exited", proc: makeChromeTestProc({ exitCode: 0 }) },
896+
{ label: "signaled", proc: makeChromeTestProc({ signalCode: "SIGTERM" }) },
897+
])("does not close a reused CDP port after the tracked process has $label", async ({ proc }) => {
898+
const fetchMock = vi.fn();
899+
vi.stubGlobal("fetch", fetchMock);
900+
901+
await stopChromeWithProc(proc, 10);
902+
903+
expect(fetchMock).not.toHaveBeenCalled();
904+
expect(proc.kill).not.toHaveBeenCalled();
905+
});
906+
870907
it("stopOpenClawChrome sends SIGTERM and returns once CDP is down", async () => {
871908
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("down")));
872909
const proc = makeChromeTestProc();
873910
await stopChromeWithProc(proc, 10);
874911
expect(proc.kill).toHaveBeenCalledWith("SIGTERM");
875912
});
876913

877-
it("stopOpenClawChrome escalates to SIGKILL when CDP stays reachable", async () => {
878-
vi.stubGlobal(
879-
"fetch",
880-
vi.fn().mockResolvedValue(jsonResponse({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" })),
881-
);
882-
const proc = makeChromeTestProc();
883-
await stopChromeWithProc(proc, 1);
884-
expect(proc.kill).toHaveBeenNthCalledWith(1, "SIGTERM");
885-
expect(proc.kill).toHaveBeenNthCalledWith(2, "SIGKILL");
914+
it("stopOpenClawChrome asks Chrome to close gracefully before sending a signal", async () => {
915+
let closeRequested = false;
916+
await withMockChromeCdpServer({
917+
wsPath: "/devtools/browser/graceful-stop",
918+
onConnection: (wss) => {
919+
wss.on("connection", (ws) => {
920+
ws.on("message", (data) => {
921+
const req = JSON.parse(rawDataToString(data)) as { id: number; method: string };
922+
expect(req.method).toBe("Browser.close");
923+
closeRequested = true;
924+
ws.send(JSON.stringify({ id: req.id, result: {} }));
925+
});
926+
});
927+
},
928+
run: async (baseUrl) => {
929+
const browserWsUrl = `${baseUrl.replace("http://", "ws://")}/devtools/browser/graceful-stop`;
930+
vi.stubGlobal(
931+
"fetch",
932+
vi.fn(async () => {
933+
if (closeRequested) {
934+
throw new Error("down");
935+
}
936+
return jsonResponse({ webSocketDebuggerUrl: browserWsUrl });
937+
}),
938+
);
939+
const proc = makeChromeTestProc();
940+
await stopChromeWithProc(proc, 20);
941+
942+
expect(closeRequested).toBe(true);
943+
expect(proc.kill).not.toHaveBeenCalled();
944+
},
945+
});
946+
});
947+
948+
it("stopOpenClawChrome escalates when graceful close leaves CDP reachable", async () => {
949+
await withMockChromeCdpServer({
950+
wsPath: "/devtools/browser/stuck-stop",
951+
onConnection: (wss) => {
952+
wss.on("connection", (ws) => {
953+
ws.on("message", (data) => {
954+
const req = JSON.parse(rawDataToString(data)) as { id: number; method: string };
955+
expect(req.method).toBe("Browser.close");
956+
ws.send(JSON.stringify({ id: req.id, result: {} }));
957+
});
958+
});
959+
},
960+
run: async (baseUrl) => {
961+
const browserWsUrl = `${baseUrl.replace("http://", "ws://")}/devtools/browser/stuck-stop`;
962+
vi.stubGlobal(
963+
"fetch",
964+
vi.fn(async () => jsonResponse({ webSocketDebuggerUrl: browserWsUrl })),
965+
);
966+
const proc = makeChromeTestProc();
967+
await stopChromeWithProc(proc, 1);
968+
expect(proc.kill).toHaveBeenNthCalledWith(1, "SIGTERM");
969+
expect(proc.kill).toHaveBeenNthCalledWith(2, "SIGKILL");
970+
},
971+
});
886972
});
887973

888974
it("stopOpenClawChrome releases the managed-proxy CDP bypass exactly once on a double stop", async () => {
@@ -902,7 +988,7 @@ describe("browser chrome helpers", () => {
902988
it("stopOpenClawChrome still releases the bypass when the SIGKILL fallback fires", async () => {
903989
vi.stubGlobal(
904990
"fetch",
905-
vi.fn().mockResolvedValue(jsonResponse({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" })),
991+
vi.fn(async () => jsonResponse({ webSocketDebuggerUrl: "ws://127.0.0.1/devtools" })),
906992
);
907993
const proc = makeChromeTestProc();
908994
const release = vi.fn();

0 commit comments

Comments
 (0)