@bubblebrain-ai/bubble 0.0.58 → 0.0.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/README.md +56 -6
  2. package/dist/agent/child-runner.js +5 -1
  3. package/dist/agent/internal-reminder-sanitizer.d.ts +1 -1
  4. package/dist/agent/internal-reminder-sanitizer.js +7 -1
  5. package/dist/agent/profiles.js +4 -4
  6. package/dist/agent/routing-catalog.d.ts +19 -1
  7. package/dist/agent/routing-catalog.js +32 -13
  8. package/dist/agent/subagent/runtime.d.ts +28 -0
  9. package/dist/agent/subagent/runtime.js +34 -30
  10. package/dist/agent/subagent-control.d.ts +7 -0
  11. package/dist/agent/subagent-control.js +2 -0
  12. package/dist/agent/subagent-lifecycle-reminder.js +2 -1
  13. package/dist/agent/subagent-wait-guidance.d.ts +2 -0
  14. package/dist/agent/subagent-wait-guidance.js +8 -0
  15. package/dist/agent/subtask-policy.js +4 -4
  16. package/dist/agent.d.ts +20 -11
  17. package/dist/agent.js +200 -117
  18. package/dist/anthropic-thinking.d.ts +4 -0
  19. package/dist/anthropic-thinking.js +11 -0
  20. package/dist/approval/controller.d.ts +20 -6
  21. package/dist/approval/controller.js +67 -8
  22. package/dist/approval/protected-paths.d.ts +1 -0
  23. package/dist/approval/protected-paths.js +35 -0
  24. package/dist/approval/session-cache.d.ts +12 -26
  25. package/dist/approval/session-cache.js +20 -50
  26. package/dist/approval/types.d.ts +27 -1
  27. package/dist/cli.d.ts +5 -0
  28. package/dist/cli.js +25 -2
  29. package/dist/config.d.ts +3 -0
  30. package/dist/config.js +7 -0
  31. package/dist/context/checkpoint.d.ts +18 -0
  32. package/dist/context/checkpoint.js +117 -0
  33. package/dist/context/compact-llm.js +7 -0
  34. package/dist/context/compact.d.ts +8 -0
  35. package/dist/context/compact.js +40 -7
  36. package/dist/context/llm-compactor.d.ts +36 -2
  37. package/dist/context/llm-compactor.js +227 -44
  38. package/dist/context/session-write-lock.d.ts +20 -0
  39. package/dist/context/session-write-lock.js +162 -0
  40. package/dist/context/tool-images.d.ts +3 -0
  41. package/dist/context/tool-images.js +35 -0
  42. package/dist/context/usage.js +22 -5
  43. package/dist/feishu/agent-host/run-driver.js +56 -9
  44. package/dist/lsp/service.d.ts +13 -1
  45. package/dist/lsp/service.js +163 -43
  46. package/dist/main.js +82 -16
  47. package/dist/mcp/config.d.ts +4 -0
  48. package/dist/mcp/config.js +17 -0
  49. package/dist/mcp/manager.d.ts +13 -1
  50. package/dist/mcp/manager.js +32 -1
  51. package/dist/mcp/transports.js +11 -0
  52. package/dist/memory/db.d.ts +1 -0
  53. package/dist/memory/db.js +20 -4
  54. package/dist/memory/phase1.js +122 -16
  55. package/dist/memory/prompts.js +3 -0
  56. package/dist/model-catalog.js +19 -3
  57. package/dist/model-config.d.ts +1 -0
  58. package/dist/model-config.js +5 -5
  59. package/dist/model-pricing.js +26 -9
  60. package/dist/network/provider-transport-log.d.ts +12 -0
  61. package/dist/network/provider-transport-log.js +32 -0
  62. package/dist/network/provider-transport.js +6 -1
  63. package/dist/network/retry.d.ts +1 -1
  64. package/dist/network/retry.js +1 -1
  65. package/dist/oauth/grok.d.ts +1 -0
  66. package/dist/oauth/grok.js +20 -16
  67. package/dist/oauth/openai-codex.d.ts +1 -0
  68. package/dist/oauth/openai-codex.js +29 -25
  69. package/dist/oauth/refresh-control.d.ts +12 -0
  70. package/dist/oauth/refresh-control.js +42 -0
  71. package/dist/oauth/storage.d.ts +55 -1
  72. package/dist/oauth/storage.js +281 -16
  73. package/dist/permissions/rule.d.ts +6 -3
  74. package/dist/permissions/rule.js +71 -12
  75. package/dist/permissions/settings.d.ts +28 -1
  76. package/dist/permissions/settings.js +93 -3
  77. package/dist/permissions/shell-command.d.ts +36 -0
  78. package/dist/permissions/shell-command.js +226 -0
  79. package/dist/permissions/trust-prompt.d.ts +10 -0
  80. package/dist/permissions/trust-prompt.js +34 -0
  81. package/dist/permissions/trust.d.ts +26 -0
  82. package/dist/permissions/trust.js +113 -0
  83. package/dist/prompt/compose.d.ts +3 -0
  84. package/dist/prompt/compose.js +24 -7
  85. package/dist/prompt/environment.js +2 -2
  86. package/dist/prompt/reminders.js +1 -1
  87. package/dist/prompt/runtime.js +1 -1
  88. package/dist/provider-anthropic.d.ts +6 -2
  89. package/dist/provider-anthropic.js +98 -18
  90. package/dist/provider-error-record.d.ts +9 -0
  91. package/dist/provider-error-record.js +42 -14
  92. package/dist/provider-grok.js +5 -1
  93. package/dist/provider-openai-codex.d.ts +8 -0
  94. package/dist/provider-openai-codex.js +107 -24
  95. package/dist/provider-registry.d.ts +45 -2
  96. package/dist/provider-registry.js +300 -88
  97. package/dist/provider-transform.js +8 -0
  98. package/dist/provider-zen-errors.d.ts +9 -0
  99. package/dist/provider-zen-errors.js +36 -0
  100. package/dist/provider.js +36 -5
  101. package/dist/sdk/index.d.ts +69 -8
  102. package/dist/sdk/index.js +252 -39
  103. package/dist/sdk/replay-event-log.d.ts +7 -0
  104. package/dist/sdk/replay-event-log.js +66 -16
  105. package/dist/session-context-fence.d.ts +16 -0
  106. package/dist/session-context-fence.js +26 -0
  107. package/dist/session-log.d.ts +11 -2
  108. package/dist/session-log.js +187 -39
  109. package/dist/session-types.d.ts +5 -1
  110. package/dist/session.d.ts +39 -7
  111. package/dist/session.js +341 -93
  112. package/dist/skills/format.d.ts +31 -1
  113. package/dist/skills/format.js +45 -24
  114. package/dist/slash-commands/commands.d.ts +3 -0
  115. package/dist/slash-commands/commands.js +29 -14
  116. package/dist/system-prompt.d.ts +3 -1
  117. package/dist/tasks/manager.d.ts +7 -0
  118. package/dist/tasks/manager.js +97 -18
  119. package/dist/tools/agent-lifecycle.js +11 -7
  120. package/dist/tools/child-tools.d.ts +1 -1
  121. package/dist/tools/child-tools.js +4 -4
  122. package/dist/tools/edit-apply.d.ts +0 -3
  123. package/dist/tools/edit-apply.js +45 -249
  124. package/dist/tools/edit.js +3 -3
  125. package/dist/tools/exit-plan-mode.d.ts +5 -0
  126. package/dist/tools/exit-plan-mode.js +3 -2
  127. package/dist/tools/file-state.d.ts +4 -0
  128. package/dist/tools/file-state.js +12 -3
  129. package/dist/tools/grep.js +20 -1
  130. package/dist/tools/index.d.ts +1 -1
  131. package/dist/tools/index.js +3 -3
  132. package/dist/tools/ls.d.ts +3 -0
  133. package/dist/tools/ls.js +85 -0
  134. package/dist/tools/path-utils.d.ts +6 -0
  135. package/dist/tools/path-utils.js +38 -1
  136. package/dist/tools/sensitive-paths.d.ts +8 -0
  137. package/dist/tools/sensitive-paths.js +85 -3
  138. package/dist/tools/skill.d.ts +2 -0
  139. package/dist/tools/skill.js +34 -2
  140. package/dist/tools/task-tools.js +11 -7
  141. package/dist/tui/app.js +22 -12
  142. package/dist/tui/components/approval-dialog.d.ts +5 -5
  143. package/dist/tui/components/approval-dialog.js +16 -51
  144. package/dist/tui/components/skills-panel.d.ts +4 -0
  145. package/dist/tui/components/skills-panel.js +15 -1
  146. package/dist/tui/components/streaming-message.js +7 -2
  147. package/dist/tui/components/tasks-pane.d.ts +38 -0
  148. package/dist/tui/components/tasks-pane.js +182 -32
  149. package/dist/tui/components/transcript.d.ts +11 -4
  150. package/dist/tui/components/transcript.js +158 -71
  151. package/dist/tui/composer-autocomplete.d.ts +17 -2
  152. package/dist/tui/composer-autocomplete.js +79 -2
  153. package/dist/tui/controller/controller.d.ts +9 -4
  154. package/dist/tui/controller/controller.js +87 -95
  155. package/dist/tui/controller/goal-runtime-controller.js +1 -1
  156. package/dist/tui/controller/session-transition.js +1 -1
  157. package/dist/tui/model/subagent-view.d.ts +11 -19
  158. package/dist/tui/model/subagent-view.js +56 -85
  159. package/dist/tui/model/task-lifecycle.d.ts +57 -0
  160. package/dist/tui/model/task-lifecycle.js +124 -0
  161. package/dist/tui/model/trace-groups.js +10 -9
  162. package/dist/tui/trace-groups.js +10 -9
  163. package/dist/types.d.ts +20 -0
  164. package/dist/variant/variant-resolver.js +2 -0
  165. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts +1 -0
  166. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts.map +1 -1
  167. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js +6 -0
  168. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js.map +1 -1
  169. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts +2 -0
  170. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts.map +1 -1
  171. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js +8 -4
  172. package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js.map +1 -1
  173. package/package.json +11 -3
  174. package/dist/prompt/skills.d.ts +0 -2
  175. package/dist/prompt/skills.js +0 -4
  176. package/dist/tools/glob.d.ts +0 -5
  177. package/dist/tools/glob.js +0 -199
@@ -12,6 +12,7 @@ import { randomBytes, createHash } from "node:crypto";
12
12
  import { readFileSync } from "node:fs";
13
13
  import { homedir } from "node:os";
14
14
  import { join } from "node:path";
15
+ import { withOAuthRefreshTimeout } from "./refresh-control.js";
15
16
  import { chatGptFetch } from "../network/chatgpt-transport.js";
16
17
  // Public OAuth client registered for the official Grok CLI ("Grok Build").
17
18
  const CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828";
@@ -73,7 +74,7 @@ async function startCallbackServer(timeoutMs, onStatus) {
73
74
  }
74
75
  if (code) {
75
76
  res.writeHead(200, { "Content-Type": "text/html" });
76
- res.end("<html><body><h1>Authorization successful</h1><p>You can close this window and return to the terminal.</p></body></html>");
77
+ res.end("<html><body><h1>Authorization received</h1><p>Return to Bubble to check whether sign-in completed. Bubble still needs to exchange and save your credentials.</p></body></html>");
77
78
  finish(() => settle({ code, state: state || "" }));
78
79
  return;
79
80
  }
@@ -186,21 +187,24 @@ export async function loginGrok(callbacks, options = {}) {
186
187
  return tokens;
187
188
  }
188
189
  export async function refreshGrok(refreshToken, options = {}) {
189
- const fetchImpl = options.fetch ?? chatGptFetch;
190
- const response = await fetchImpl(TOKEN_URL, {
191
- method: "POST",
192
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
193
- body: new URLSearchParams({
194
- grant_type: "refresh_token",
195
- refresh_token: refreshToken,
196
- client_id: CLIENT_ID,
197
- }),
198
- });
199
- if (!response.ok) {
200
- const text = await response.text().catch(() => "Unknown error");
201
- throw new Error(`Token refresh failed: ${response.status} ${response.statusText} - ${text}`);
202
- }
203
- return parseTokenResponse((await response.json()), refreshToken);
190
+ return withOAuthRefreshTimeout(async (signal) => {
191
+ const fetchImpl = options.fetch ?? chatGptFetch;
192
+ const response = await fetchImpl(TOKEN_URL, {
193
+ method: "POST",
194
+ signal,
195
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
196
+ body: new URLSearchParams({
197
+ grant_type: "refresh_token",
198
+ refresh_token: refreshToken,
199
+ client_id: CLIENT_ID,
200
+ }),
201
+ });
202
+ if (!response.ok) {
203
+ const text = await response.text().catch(() => "Unknown error");
204
+ throw new Error(`Token refresh failed: ${response.status} ${response.statusText} - ${text}`);
205
+ }
206
+ return parseTokenResponse((await response.json()), refreshToken);
207
+ }, options.timeoutMs);
204
208
  }
205
209
  /**
206
210
  * One-time credential import from the official Grok CLI's auth store (either
@@ -11,4 +11,5 @@ export declare function loginOpenAICodex(callbacks?: OpenAICodexLoginCallbacks,
11
11
  }): Promise<OAuthTokens>;
12
12
  export declare function refreshOpenAICodex(refreshToken: string, options?: {
13
13
  fetch?: ChatGptFetch;
14
+ timeoutMs?: number;
14
15
  }): Promise<OAuthTokens>;
@@ -4,6 +4,7 @@
4
4
  import { createServer } from "node:http";
5
5
  import { exec } from "node:child_process";
6
6
  import { randomBytes, createHash } from "node:crypto";
7
+ import { withOAuthRefreshTimeout } from "./refresh-control.js";
7
8
  import { chatGptFetch } from "../network/chatgpt-transport.js";
8
9
  const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
9
10
  const AUTH_URL = "https://auth.openai.com/oauth/authorize";
@@ -47,7 +48,7 @@ async function startCallbackServer(port, timeoutMs, onStatus) {
47
48
  }
48
49
  if (code) {
49
50
  res.writeHead(200, { "Content-Type": "text/html" });
50
- res.end(`<html><body><h1>Authorization successful</h1><p>You can close this window and return to the terminal.</p></body></html>`);
51
+ res.end(`<html><body><h1>Authorization received</h1><p>Return to Bubble to check whether sign-in completed. Bubble still needs to exchange and save your credentials.</p></body></html>`);
51
52
  if (!resolved) {
52
53
  resolved = true;
53
54
  server.close(() => resolve({ code, state: state || "" }));
@@ -162,28 +163,31 @@ export async function loginOpenAICodex(callbacks, options = {}) {
162
163
  };
163
164
  }
164
165
  export async function refreshOpenAICodex(refreshToken, options = {}) {
165
- const fetchImpl = options.fetch ?? chatGptFetch;
166
- const response = await fetchImpl(TOKEN_URL, {
167
- method: "POST",
168
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
169
- body: new URLSearchParams({
170
- grant_type: "refresh_token",
171
- refresh_token: refreshToken,
172
- client_id: CLIENT_ID,
173
- }),
174
- });
175
- if (!response.ok) {
176
- const text = await response.text().catch(() => "Unknown error");
177
- throw new Error(`Token refresh failed: ${response.status} ${response.statusText} - ${text}`);
178
- }
179
- const data = (await response.json());
180
- const expiresAt = Date.now() + data.expires_in * 1000;
181
- const accountId = extractAccountId(data.id_token);
182
- return {
183
- accessToken: data.access_token,
184
- refreshToken: data.refresh_token || refreshToken,
185
- expiresAt,
186
- idToken: data.id_token,
187
- accountId,
188
- };
166
+ return withOAuthRefreshTimeout(async (signal) => {
167
+ const fetchImpl = options.fetch ?? chatGptFetch;
168
+ const response = await fetchImpl(TOKEN_URL, {
169
+ method: "POST",
170
+ signal,
171
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
172
+ body: new URLSearchParams({
173
+ grant_type: "refresh_token",
174
+ refresh_token: refreshToken,
175
+ client_id: CLIENT_ID,
176
+ }),
177
+ });
178
+ if (!response.ok) {
179
+ const text = await response.text().catch(() => "Unknown error");
180
+ throw new Error(`Token refresh failed: ${response.status} ${response.statusText} - ${text}`);
181
+ }
182
+ const data = (await response.json());
183
+ const expiresAt = Date.now() + data.expires_in * 1000;
184
+ const accountId = extractAccountId(data.id_token);
185
+ return {
186
+ accessToken: data.access_token,
187
+ refreshToken: data.refresh_token || refreshToken,
188
+ expiresAt,
189
+ idToken: data.id_token,
190
+ accountId,
191
+ };
192
+ }, options.timeoutMs);
189
193
  }
@@ -0,0 +1,12 @@
1
+ export declare const OAUTH_REFRESH_TIMEOUT_MS = 30000;
2
+ /** Cancel only this caller's wait, never the shared credential rotation.
3
+ * Keep both handlers attached after cancellation so a late refresh rejection
4
+ * is observed even when every caller has stopped waiting.
5
+ */
6
+ export declare function waitForOAuth<T>(promise: Promise<T>, signal?: AbortSignal | null): Promise<T>;
7
+ /** The refresh owner has its own deadline, independent of any chat's signal.
8
+ * Cover response-body reads as well as connection/headers, and release callers
9
+ * even if a custom transport fails to honor abort. Only the returned result may
10
+ * be persisted by the registry; a late response after timeout is discarded.
11
+ */
12
+ export declare function withOAuthRefreshTimeout<T>(request: (signal: AbortSignal) => Promise<T>, timeoutMs?: number): Promise<T>;
@@ -0,0 +1,42 @@
1
+ export const OAUTH_REFRESH_TIMEOUT_MS = 30_000;
2
+ /** Cancel only this caller's wait, never the shared credential rotation.
3
+ * Keep both handlers attached after cancellation so a late refresh rejection
4
+ * is observed even when every caller has stopped waiting.
5
+ */
6
+ export function waitForOAuth(promise, signal) {
7
+ if (!signal)
8
+ return promise;
9
+ return new Promise((resolve, reject) => {
10
+ const abort = () => {
11
+ signal.removeEventListener("abort", abort);
12
+ reject(signal.reason ?? new DOMException("Operation aborted", "AbortError"));
13
+ };
14
+ promise.then(value => { signal.removeEventListener("abort", abort); resolve(value); }, error => { signal.removeEventListener("abort", abort); reject(error); });
15
+ if (signal.aborted)
16
+ abort();
17
+ else
18
+ signal.addEventListener("abort", abort, { once: true });
19
+ });
20
+ }
21
+ /** The refresh owner has its own deadline, independent of any chat's signal.
22
+ * Cover response-body reads as well as connection/headers, and release callers
23
+ * even if a custom transport fails to honor abort. Only the returned result may
24
+ * be persisted by the registry; a late response after timeout is discarded.
25
+ */
26
+ export async function withOAuthRefreshTimeout(request, timeoutMs = OAUTH_REFRESH_TIMEOUT_MS) {
27
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0)
28
+ throw new Error("Invalid OAuth refresh timeout");
29
+ const controller = new AbortController();
30
+ const timer = setTimeout(() => {
31
+ const error = new Error("OAuth token refresh timed out. Please try again.");
32
+ error.name = "TimeoutError";
33
+ controller.abort(error);
34
+ }, timeoutMs);
35
+ timer.unref?.();
36
+ try {
37
+ return await waitForOAuth(request(controller.signal), controller.signal);
38
+ }
39
+ finally {
40
+ clearTimeout(timer);
41
+ }
42
+ }
@@ -6,10 +6,31 @@
6
6
  * which no test could isolate (known-defects #7) and which ignored
7
7
  * BUBBLE_HOME / dev mode while every other config file honored them
8
8
  * (config.json, sessions, memory all derive from getBubbleHome).
9
+ *
10
+ * The file is shared by every Bubble process on the machine (several TUIs, the
11
+ * desktop app, the Feishu host), and OAuth refresh tokens are single-use, so a
12
+ * stale copy is not harmless — presenting an already-rotated refresh token is
13
+ * rejected (`refresh_token_reused`) and forces a new login. Hence:
14
+ * - writes are read-merge-write of ONE key plus an atomic rename, never a
15
+ * dump of this process's whole in-memory map over the file;
16
+ * - reads follow the file (mtime/size check) so a token rotated by another
17
+ * process is picked up instead of the stale in-memory one;
18
+ * - withRefreshLock() serializes refreshes across processes.
19
+ *
20
+ * Node has no flock, so both locks are O_EXCL lock files carrying an owner
21
+ * token: a holder only ever deletes a lock whose content is its own token,
22
+ * and stale locks are broken one breaker at a time (see breakLockIfStale), so
23
+ * a breaker cannot remove a successor's live lock. What remains is a holder
24
+ * suspended past the stale threshold that resumes and releases in the same
25
+ * microseconds a breaker acts. The refresh path tolerates even that: results
26
+ * are stored by compare-and-set, and a rejected token triggers a second look
27
+ * at the file (ProviderRegistry).
9
28
  */
10
29
  import type { OAuthCredentials } from "./types.js";
11
30
  export declare class AuthStorage {
12
31
  private data;
32
+ /** Identity of the file as last read/written, to detect foreign writes. */
33
+ private diskStamp;
13
34
  private mutationListeners;
14
35
  private readonly authPath;
15
36
  constructor(authPath?: string);
@@ -21,11 +42,44 @@ export declare class AuthStorage {
21
42
  */
22
43
  onMutation(listener: (providerId: string) => void): () => void;
23
44
  private notifyMutation;
45
+ private stampOf;
46
+ private readDisk;
24
47
  private load;
25
- private save;
48
+ /**
49
+ * Adopt writes made by other processes; notifies for the keys that changed.
50
+ * `force` skips the stamp shortcut: a token rotation keeps the file size, so
51
+ * on a coarse-mtime filesystem the stamp alone can miss it. The refresh and
52
+ * write paths cannot afford that.
53
+ */
54
+ sync(force?: boolean): void;
55
+ /**
56
+ * Read-merge-write of a single key: entries owned by other processes survive.
57
+ * The whole sequence runs under the cross-process write lock — without it a
58
+ * process could read, lose the CPU while another persists a rotated token,
59
+ * then rename its older snapshot over it.
60
+ */
61
+ private commit;
62
+ /** Force a re-read of the file (see sync). */
63
+ reload(): void;
64
+ /**
65
+ * Serialize token refreshes across processes. Callers must re-read the
66
+ * credentials inside `fn`: whoever held the lock before may already have
67
+ * rotated them. `fn` never runs without the lock — presenting a single-use
68
+ * refresh token concurrently can void the login, while a timeout only costs
69
+ * one retryable request.
70
+ */
71
+ withRefreshLock<T>(fn: () => Promise<T>): Promise<T>;
26
72
  getPath(): string;
27
73
  get(providerId: string): OAuthCredentials | undefined;
28
74
  set(providerId: string, creds: OAuthCredentials): void;
75
+ /**
76
+ * Store the result of a refresh only if the entry is still the one that was
77
+ * refreshed (`fromRefreshToken`; null = the key must still be absent). A
78
+ * /login or /logout in another process only takes the write lock, so it can
79
+ * land while a refresh request is in flight; its outcome must win over a
80
+ * result derived from the credentials it replaced.
81
+ */
82
+ replaceIfUnchanged(providerId: string, creds: OAuthCredentials, fromRefreshToken: string | null): boolean;
29
83
  remove(providerId: string): void;
30
84
  has(providerId: string): boolean;
31
85
  isExpired(providerId: string, graceMs?: number): boolean;
@@ -6,12 +6,150 @@
6
6
  * which no test could isolate (known-defects #7) and which ignored
7
7
  * BUBBLE_HOME / dev mode while every other config file honored them
8
8
  * (config.json, sessions, memory all derive from getBubbleHome).
9
+ *
10
+ * The file is shared by every Bubble process on the machine (several TUIs, the
11
+ * desktop app, the Feishu host), and OAuth refresh tokens are single-use, so a
12
+ * stale copy is not harmless — presenting an already-rotated refresh token is
13
+ * rejected (`refresh_token_reused`) and forces a new login. Hence:
14
+ * - writes are read-merge-write of ONE key plus an atomic rename, never a
15
+ * dump of this process's whole in-memory map over the file;
16
+ * - reads follow the file (mtime/size check) so a token rotated by another
17
+ * process is picked up instead of the stale in-memory one;
18
+ * - withRefreshLock() serializes refreshes across processes.
19
+ *
20
+ * Node has no flock, so both locks are O_EXCL lock files carrying an owner
21
+ * token: a holder only ever deletes a lock whose content is its own token,
22
+ * and stale locks are broken one breaker at a time (see breakLockIfStale), so
23
+ * a breaker cannot remove a successor's live lock. What remains is a holder
24
+ * suspended past the stale threshold that resumes and releases in the same
25
+ * microseconds a breaker acts. The refresh path tolerates even that: results
26
+ * are stored by compare-and-set, and a rejected token triggers a second look
27
+ * at the file (ProviderRegistry).
9
28
  */
10
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
29
+ import { randomUUID } from "node:crypto";
30
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, statSync, unlinkSync, utimesSync, writeFileSync, writeSync, } from "node:fs";
11
31
  import { dirname, join } from "node:path";
12
32
  import { getBubbleHome } from "../bubble-home.js";
33
+ /**
34
+ * The refresh holder heartbeats its lock, so "stale" means the holder is gone,
35
+ * not merely slow. Waiters outlast the stale threshold: a crashed holder is
36
+ * always recovered from, and only a live, stuck one makes a waiter give up.
37
+ */
38
+ const REFRESH_LOCK_STALE_MS = 30_000;
39
+ const REFRESH_LOCK_HEARTBEAT_MS = 5_000;
40
+ const REFRESH_LOCK_WAIT_MS = 90_000;
41
+ const REFRESH_LOCK_POLL_MS = 50;
42
+ /** The write lock spans a few ms of synchronous file I/O. */
43
+ const WRITE_LOCK_STALE_MS = 5_000;
44
+ const WRITE_LOCK_WAIT_MS = 15_000;
45
+ const WRITE_LOCK_POLL_MS = 5;
46
+ const BREAKER_STALE_MS = 10_000;
47
+ const sleepCell = new Int32Array(new SharedArrayBuffer(4));
48
+ function sleepSync(ms) {
49
+ Atomics.wait(sleepCell, 0, 0, ms);
50
+ }
51
+ /**
52
+ * One acquisition attempt. Returns the owner token, or undefined on contention
53
+ * (after breaking the lock if it was stale, so the caller's retry can win).
54
+ * Anything but EEXIST is a real error — an unwritable home must surface, not
55
+ * be mistaken for contention and retried forever.
56
+ */
57
+ function tryAcquireLock(lockPath, staleMs) {
58
+ const token = `${process.pid}:${randomUUID()}`;
59
+ let fd;
60
+ try {
61
+ const dir = dirname(lockPath);
62
+ if (!existsSync(dir))
63
+ mkdirSync(dir, { recursive: true });
64
+ fd = openSync(lockPath, "wx", 0o600);
65
+ }
66
+ catch (error) {
67
+ if (error.code !== "EEXIST")
68
+ throw error;
69
+ breakLockIfStale(lockPath, staleMs);
70
+ return undefined;
71
+ }
72
+ try {
73
+ writeSync(fd, token);
74
+ }
75
+ finally {
76
+ closeSync(fd);
77
+ }
78
+ return token;
79
+ }
80
+ function observeLock(lockPath) {
81
+ try {
82
+ return { mtimeMs: statSync(lockPath).mtimeMs, token: readFileSync(lockPath, "utf-8") };
83
+ }
84
+ catch {
85
+ return undefined; // released meanwhile
86
+ }
87
+ }
88
+ function isStale(lockPath, staleMs) {
89
+ const observed = observeLock(lockPath);
90
+ return !!observed && Date.now() - observed.mtimeMs > staleMs;
91
+ }
92
+ /**
93
+ * Breakers are serialized by their own O_EXCL lock, held for the few
94
+ * microseconds of the re-check and unlink below. Without it, two waiters that
95
+ * both judged the lock stale could interleave: one unlinks it, a successor
96
+ * acquires the path, and the other then unlinks the successor's live lock.
97
+ * Under the breaker lock the second one re-checks and finds a fresh lock.
98
+ */
99
+ function breakLockIfStale(lockPath, staleMs) {
100
+ if (!isStale(lockPath, staleMs))
101
+ return;
102
+ const breakerPath = `${lockPath}.breaker`;
103
+ let fd;
104
+ try {
105
+ fd = openSync(breakerPath, "wx", 0o600);
106
+ }
107
+ catch (error) {
108
+ if (error.code !== "EEXIST")
109
+ throw error;
110
+ // Someone else is breaking it right now; only a crash inside that tiny
111
+ // section leaves the file behind, so clear an old one for the next retry.
112
+ if (isStale(breakerPath, BREAKER_STALE_MS)) {
113
+ try {
114
+ unlinkSync(breakerPath);
115
+ }
116
+ catch {
117
+ // Already gone.
118
+ }
119
+ }
120
+ return;
121
+ }
122
+ try {
123
+ closeSync(fd);
124
+ if (isStale(lockPath, staleMs))
125
+ unlinkSync(lockPath);
126
+ }
127
+ catch {
128
+ // Already gone.
129
+ }
130
+ finally {
131
+ try {
132
+ unlinkSync(breakerPath);
133
+ }
134
+ catch {
135
+ // Already gone.
136
+ }
137
+ }
138
+ }
139
+ /** Never delete a lock that is not ours (ours may have been broken as stale). */
140
+ function releaseLock(lockPath, token) {
141
+ try {
142
+ if (readFileSync(lockPath, "utf-8") === token)
143
+ unlinkSync(lockPath);
144
+ }
145
+ catch {
146
+ // Already gone.
147
+ }
148
+ }
13
149
  export class AuthStorage {
14
150
  data = {};
151
+ /** Identity of the file as last read/written, to detect foreign writes. */
152
+ diskStamp;
15
153
  mutationListeners = [];
16
154
  authPath;
17
155
  constructor(authPath = join(getBubbleHome(), "auth.json")) {
@@ -40,53 +178,180 @@ export class AuthStorage {
40
178
  }
41
179
  }
42
180
  }
181
+ stampOf() {
182
+ try {
183
+ const stat = statSync(this.authPath);
184
+ // ino + ctime: every write replaces the file by rename, so they change
185
+ // even when a same-size rotation lands within one coarse mtime tick.
186
+ return `${stat.ino}:${stat.mtimeMs}:${stat.ctimeMs}:${stat.size}`;
187
+ }
188
+ catch {
189
+ return undefined;
190
+ }
191
+ }
192
+ readDisk() {
193
+ try {
194
+ const parsed = JSON.parse(readFileSync(this.authPath, "utf-8"));
195
+ return parsed && typeof parsed === "object" ? parsed : {};
196
+ }
197
+ catch {
198
+ return {};
199
+ }
200
+ }
43
201
  load() {
44
- if (!existsSync(this.authPath))
202
+ this.diskStamp = this.stampOf();
203
+ this.data = this.diskStamp === undefined ? {} : this.readDisk();
204
+ }
205
+ /**
206
+ * Adopt writes made by other processes; notifies for the keys that changed.
207
+ * `force` skips the stamp shortcut: a token rotation keeps the file size, so
208
+ * on a coarse-mtime filesystem the stamp alone can miss it. The refresh and
209
+ * write paths cannot afford that.
210
+ */
211
+ sync(force = false) {
212
+ const stamp = this.stampOf();
213
+ if (!force && stamp === this.diskStamp)
45
214
  return;
215
+ const previous = this.data;
216
+ this.diskStamp = stamp;
217
+ this.data = stamp === undefined ? {} : this.readDisk();
218
+ for (const key of new Set([...Object.keys(previous), ...Object.keys(this.data)])) {
219
+ if (JSON.stringify(previous[key]) !== JSON.stringify(this.data[key]))
220
+ this.notifyMutation(key);
221
+ }
222
+ }
223
+ /**
224
+ * Read-merge-write of a single key: entries owned by other processes survive.
225
+ * The whole sequence runs under the cross-process write lock — without it a
226
+ * process could read, lose the CPU while another persists a rotated token,
227
+ * then rename its older snapshot over it.
228
+ */
229
+ commit(providerId, creds,
230
+ /** Compare-and-set guard, checked under the lock against the re-read file. */
231
+ guard) {
232
+ const lockPath = `${this.authPath}.wlock`;
233
+ const deadline = Date.now() + WRITE_LOCK_WAIT_MS;
234
+ let token = tryAcquireLock(lockPath, WRITE_LOCK_STALE_MS);
235
+ while (token === undefined) {
236
+ if (Date.now() >= deadline) {
237
+ throw new Error(`Timed out waiting for the credential write lock (${lockPath}).`);
238
+ }
239
+ sleepSync(WRITE_LOCK_POLL_MS);
240
+ token = tryAcquireLock(lockPath, WRITE_LOCK_STALE_MS);
241
+ }
46
242
  try {
47
- const raw = readFileSync(this.authPath, "utf-8");
48
- const parsed = JSON.parse(raw);
49
- this.data = parsed;
243
+ this.sync(true);
244
+ if (guard && !guard(this.data[providerId]))
245
+ return false;
246
+ const next = { ...this.data };
247
+ if (creds)
248
+ next[providerId] = creds;
249
+ else
250
+ delete next[providerId];
251
+ const tmpPath = `${this.authPath}.${process.pid}.tmp`;
252
+ writeFileSync(tmpPath, JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
253
+ // Fencing: if this process was suspended past the stale threshold, the
254
+ // lock was broken and someone else may have committed since the re-read
255
+ // above. Publishing the snapshot now would roll their write back.
256
+ if (observeLock(lockPath)?.token !== token) {
257
+ unlinkSync(tmpPath);
258
+ throw new Error("Lost the credential write lock before publishing; the write was not applied.");
259
+ }
260
+ renameSync(tmpPath, this.authPath);
261
+ this.data = next;
262
+ this.diskStamp = this.stampOf();
263
+ return true;
50
264
  }
51
- catch {
52
- this.data = {};
265
+ finally {
266
+ releaseLock(lockPath, token);
53
267
  }
54
268
  }
55
- save() {
56
- const dir = dirname(this.authPath);
57
- if (!existsSync(dir))
58
- mkdirSync(dir, { recursive: true });
59
- writeFileSync(this.authPath, JSON.stringify(this.data, null, 2) + "\n", { mode: 0o600 });
269
+ /** Force a re-read of the file (see sync). */
270
+ reload() {
271
+ this.sync(true);
272
+ }
273
+ /**
274
+ * Serialize token refreshes across processes. Callers must re-read the
275
+ * credentials inside `fn`: whoever held the lock before may already have
276
+ * rotated them. `fn` never runs without the lock — presenting a single-use
277
+ * refresh token concurrently can void the login, while a timeout only costs
278
+ * one retryable request.
279
+ */
280
+ async withRefreshLock(fn) {
281
+ const lockPath = `${this.authPath}.lock`;
282
+ const deadline = Date.now() + REFRESH_LOCK_WAIT_MS;
283
+ let token = tryAcquireLock(lockPath, REFRESH_LOCK_STALE_MS);
284
+ while (token === undefined) {
285
+ if (Date.now() >= deadline) {
286
+ throw new Error("Timed out waiting for another Bubble process to finish refreshing credentials. Try again.");
287
+ }
288
+ await new Promise((resolve) => setTimeout(resolve, REFRESH_LOCK_POLL_MS));
289
+ token = tryAcquireLock(lockPath, REFRESH_LOCK_STALE_MS);
290
+ }
291
+ const owned = token;
292
+ const heartbeat = setInterval(() => {
293
+ try {
294
+ if (readFileSync(lockPath, "utf-8") === owned)
295
+ utimesSync(lockPath, new Date(), new Date());
296
+ }
297
+ catch {
298
+ // Lock gone: nothing to keep alive.
299
+ }
300
+ }, REFRESH_LOCK_HEARTBEAT_MS);
301
+ heartbeat.unref?.();
302
+ try {
303
+ return await fn();
304
+ }
305
+ finally {
306
+ clearInterval(heartbeat);
307
+ releaseLock(lockPath, owned);
308
+ }
60
309
  }
61
310
  getPath() {
62
311
  return this.authPath;
63
312
  }
64
313
  get(providerId) {
314
+ this.sync();
65
315
  return this.data[providerId];
66
316
  }
67
317
  set(providerId, creds) {
68
- this.data[providerId] = creds;
69
- this.save();
318
+ this.commit(providerId, creds);
70
319
  this.notifyMutation(providerId);
71
320
  }
321
+ /**
322
+ * Store the result of a refresh only if the entry is still the one that was
323
+ * refreshed (`fromRefreshToken`; null = the key must still be absent). A
324
+ * /login or /logout in another process only takes the write lock, so it can
325
+ * land while a refresh request is in flight; its outcome must win over a
326
+ * result derived from the credentials it replaced.
327
+ */
328
+ replaceIfUnchanged(providerId, creds, fromRefreshToken) {
329
+ const written = this.commit(providerId, creds, (onDisk) => fromRefreshToken === null ? onDisk === undefined : onDisk?.refreshToken === fromRefreshToken);
330
+ if (written)
331
+ this.notifyMutation(providerId);
332
+ return written;
333
+ }
72
334
  remove(providerId) {
73
- delete this.data[providerId];
74
- this.save();
335
+ this.commit(providerId, undefined);
75
336
  this.notifyMutation(providerId);
76
337
  }
77
338
  has(providerId) {
339
+ this.sync();
78
340
  return !!this.data[providerId];
79
341
  }
80
342
  isExpired(providerId, graceMs = 5 * 60 * 1000) {
343
+ this.sync();
81
344
  const creds = this.data[providerId];
82
345
  if (!creds)
83
346
  return true;
84
347
  return Date.now() >= creds.expiresAt - graceMs;
85
348
  }
86
349
  getAccessToken(providerId) {
350
+ this.sync();
87
351
  return this.data[providerId]?.accessToken;
88
352
  }
89
353
  list() {
354
+ this.sync();
90
355
  return Object.keys(this.data);
91
356
  }
92
357
  }
@@ -21,10 +21,13 @@ export declare function parseRules(inputs: string[]): {
21
21
  /**
22
22
  * Does `rule` match `query`?
23
23
  *
24
- * Tool name must match (or rule.tool is "*"). If the rule has no pattern, it
25
- * matches any use of that tool. Otherwise the tool-specific matcher decides.
24
+ * Tool name must match (or rule.tool is "*"; `mcp__server` covers every
25
+ * `mcp__server__tool`). If the rule has no pattern, it matches any use of that
26
+ * tool. Otherwise the tool-specific matcher decides. `list` matters for Bash:
27
+ * an allow rule must cover every command a compound command runs, while a
28
+ * deny rule fires if it covers any of them.
26
29
  */
27
- export declare function matchRule(rule: PermissionRule, query: PermissionQuery): boolean;
30
+ export declare function matchRule(rule: PermissionRule, query: PermissionQuery, list?: "allow" | "deny"): boolean;
28
31
  /**
29
32
  * Evaluate a rule set against a query. Deny wins; otherwise first allow wins;
30
33
  * otherwise "ask".