@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.
- package/README.md +56 -6
- package/dist/agent/child-runner.js +5 -1
- package/dist/agent/internal-reminder-sanitizer.d.ts +1 -1
- package/dist/agent/internal-reminder-sanitizer.js +7 -1
- package/dist/agent/profiles.js +4 -4
- package/dist/agent/routing-catalog.d.ts +19 -1
- package/dist/agent/routing-catalog.js +32 -13
- package/dist/agent/subagent/runtime.d.ts +28 -0
- package/dist/agent/subagent/runtime.js +34 -30
- package/dist/agent/subagent-control.d.ts +7 -0
- package/dist/agent/subagent-control.js +2 -0
- package/dist/agent/subagent-lifecycle-reminder.js +2 -1
- package/dist/agent/subagent-wait-guidance.d.ts +2 -0
- package/dist/agent/subagent-wait-guidance.js +8 -0
- package/dist/agent/subtask-policy.js +4 -4
- package/dist/agent.d.ts +20 -11
- package/dist/agent.js +200 -117
- package/dist/anthropic-thinking.d.ts +4 -0
- package/dist/anthropic-thinking.js +11 -0
- package/dist/approval/controller.d.ts +20 -6
- package/dist/approval/controller.js +67 -8
- package/dist/approval/protected-paths.d.ts +1 -0
- package/dist/approval/protected-paths.js +35 -0
- package/dist/approval/session-cache.d.ts +12 -26
- package/dist/approval/session-cache.js +20 -50
- package/dist/approval/types.d.ts +27 -1
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +25 -2
- package/dist/config.d.ts +3 -0
- package/dist/config.js +7 -0
- package/dist/context/checkpoint.d.ts +18 -0
- package/dist/context/checkpoint.js +117 -0
- package/dist/context/compact-llm.js +7 -0
- package/dist/context/compact.d.ts +8 -0
- package/dist/context/compact.js +40 -7
- package/dist/context/llm-compactor.d.ts +36 -2
- package/dist/context/llm-compactor.js +227 -44
- package/dist/context/session-write-lock.d.ts +20 -0
- package/dist/context/session-write-lock.js +162 -0
- package/dist/context/tool-images.d.ts +3 -0
- package/dist/context/tool-images.js +35 -0
- package/dist/context/usage.js +22 -5
- package/dist/feishu/agent-host/run-driver.js +56 -9
- package/dist/lsp/service.d.ts +13 -1
- package/dist/lsp/service.js +163 -43
- package/dist/main.js +82 -16
- package/dist/mcp/config.d.ts +4 -0
- package/dist/mcp/config.js +17 -0
- package/dist/mcp/manager.d.ts +13 -1
- package/dist/mcp/manager.js +32 -1
- package/dist/mcp/transports.js +11 -0
- package/dist/memory/db.d.ts +1 -0
- package/dist/memory/db.js +20 -4
- package/dist/memory/phase1.js +122 -16
- package/dist/memory/prompts.js +3 -0
- package/dist/model-catalog.js +19 -3
- package/dist/model-config.d.ts +1 -0
- package/dist/model-config.js +5 -5
- package/dist/model-pricing.js +26 -9
- package/dist/network/provider-transport-log.d.ts +12 -0
- package/dist/network/provider-transport-log.js +32 -0
- package/dist/network/provider-transport.js +6 -1
- package/dist/network/retry.d.ts +1 -1
- package/dist/network/retry.js +1 -1
- package/dist/oauth/grok.d.ts +1 -0
- package/dist/oauth/grok.js +20 -16
- package/dist/oauth/openai-codex.d.ts +1 -0
- package/dist/oauth/openai-codex.js +29 -25
- package/dist/oauth/refresh-control.d.ts +12 -0
- package/dist/oauth/refresh-control.js +42 -0
- package/dist/oauth/storage.d.ts +55 -1
- package/dist/oauth/storage.js +281 -16
- package/dist/permissions/rule.d.ts +6 -3
- package/dist/permissions/rule.js +71 -12
- package/dist/permissions/settings.d.ts +28 -1
- package/dist/permissions/settings.js +93 -3
- package/dist/permissions/shell-command.d.ts +36 -0
- package/dist/permissions/shell-command.js +226 -0
- package/dist/permissions/trust-prompt.d.ts +10 -0
- package/dist/permissions/trust-prompt.js +34 -0
- package/dist/permissions/trust.d.ts +26 -0
- package/dist/permissions/trust.js +113 -0
- package/dist/prompt/compose.d.ts +3 -0
- package/dist/prompt/compose.js +24 -7
- package/dist/prompt/environment.js +2 -2
- package/dist/prompt/reminders.js +1 -1
- package/dist/prompt/runtime.js +1 -1
- package/dist/provider-anthropic.d.ts +6 -2
- package/dist/provider-anthropic.js +98 -18
- package/dist/provider-error-record.d.ts +9 -0
- package/dist/provider-error-record.js +42 -14
- package/dist/provider-grok.js +5 -1
- package/dist/provider-openai-codex.d.ts +8 -0
- package/dist/provider-openai-codex.js +107 -24
- package/dist/provider-registry.d.ts +45 -2
- package/dist/provider-registry.js +300 -88
- package/dist/provider-transform.js +8 -0
- package/dist/provider-zen-errors.d.ts +9 -0
- package/dist/provider-zen-errors.js +36 -0
- package/dist/provider.js +36 -5
- package/dist/sdk/index.d.ts +69 -8
- package/dist/sdk/index.js +252 -39
- package/dist/sdk/replay-event-log.d.ts +7 -0
- package/dist/sdk/replay-event-log.js +66 -16
- package/dist/session-context-fence.d.ts +16 -0
- package/dist/session-context-fence.js +26 -0
- package/dist/session-log.d.ts +11 -2
- package/dist/session-log.js +187 -39
- package/dist/session-types.d.ts +5 -1
- package/dist/session.d.ts +39 -7
- package/dist/session.js +341 -93
- package/dist/skills/format.d.ts +31 -1
- package/dist/skills/format.js +45 -24
- package/dist/slash-commands/commands.d.ts +3 -0
- package/dist/slash-commands/commands.js +29 -14
- package/dist/system-prompt.d.ts +3 -1
- package/dist/tasks/manager.d.ts +7 -0
- package/dist/tasks/manager.js +97 -18
- package/dist/tools/agent-lifecycle.js +11 -7
- package/dist/tools/child-tools.d.ts +1 -1
- package/dist/tools/child-tools.js +4 -4
- package/dist/tools/edit-apply.d.ts +0 -3
- package/dist/tools/edit-apply.js +45 -249
- package/dist/tools/edit.js +3 -3
- package/dist/tools/exit-plan-mode.d.ts +5 -0
- package/dist/tools/exit-plan-mode.js +3 -2
- package/dist/tools/file-state.d.ts +4 -0
- package/dist/tools/file-state.js +12 -3
- package/dist/tools/grep.js +20 -1
- package/dist/tools/index.d.ts +1 -1
- package/dist/tools/index.js +3 -3
- package/dist/tools/ls.d.ts +3 -0
- package/dist/tools/ls.js +85 -0
- package/dist/tools/path-utils.d.ts +6 -0
- package/dist/tools/path-utils.js +38 -1
- package/dist/tools/sensitive-paths.d.ts +8 -0
- package/dist/tools/sensitive-paths.js +85 -3
- package/dist/tools/skill.d.ts +2 -0
- package/dist/tools/skill.js +34 -2
- package/dist/tools/task-tools.js +11 -7
- package/dist/tui/app.js +22 -12
- package/dist/tui/components/approval-dialog.d.ts +5 -5
- package/dist/tui/components/approval-dialog.js +16 -51
- package/dist/tui/components/skills-panel.d.ts +4 -0
- package/dist/tui/components/skills-panel.js +15 -1
- package/dist/tui/components/streaming-message.js +7 -2
- package/dist/tui/components/tasks-pane.d.ts +38 -0
- package/dist/tui/components/tasks-pane.js +182 -32
- package/dist/tui/components/transcript.d.ts +11 -4
- package/dist/tui/components/transcript.js +158 -71
- package/dist/tui/composer-autocomplete.d.ts +17 -2
- package/dist/tui/composer-autocomplete.js +79 -2
- package/dist/tui/controller/controller.d.ts +9 -4
- package/dist/tui/controller/controller.js +87 -95
- package/dist/tui/controller/goal-runtime-controller.js +1 -1
- package/dist/tui/controller/session-transition.js +1 -1
- package/dist/tui/model/subagent-view.d.ts +11 -19
- package/dist/tui/model/subagent-view.js +56 -85
- package/dist/tui/model/task-lifecycle.d.ts +57 -0
- package/dist/tui/model/task-lifecycle.js +124 -0
- package/dist/tui/model/trace-groups.js +10 -9
- package/dist/tui/trace-groups.js +10 -9
- package/dist/types.d.ts +20 -0
- package/dist/variant/variant-resolver.js +2 -0
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts +1 -0
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.d.ts.map +1 -1
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js +6 -0
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui-alt-screen.js.map +1 -1
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts +2 -0
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.d.ts.map +1 -1
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js +8 -4
- package/node_modules/@bubblebrain-ai/pi-tui/dist/src/tui.js.map +1 -1
- package/package.json +11 -3
- package/dist/prompt/skills.d.ts +0 -2
- package/dist/prompt/skills.js +0 -4
- package/dist/tools/glob.d.ts +0 -5
- package/dist/tools/glob.js +0 -199
package/dist/oauth/grok.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
+
}
|
package/dist/oauth/storage.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/oauth/storage.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
52
|
-
|
|
265
|
+
finally {
|
|
266
|
+
releaseLock(lockPath, token);
|
|
53
267
|
}
|
|
54
268
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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.
|
|
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
|
-
|
|
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 "*"
|
|
25
|
-
*
|
|
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".
|