@chrysb/alphaclaw 0.3.5-beta.1 → 0.4.0

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 (37) hide show
  1. package/bin/alphaclaw.js +1 -31
  2. package/lib/public/assets/icons/google_icon.svg +8 -0
  3. package/lib/public/css/explorer.css +53 -0
  4. package/lib/public/js/app.js +126 -105
  5. package/lib/public/js/components/credentials-modal.js +36 -8
  6. package/lib/public/js/components/file-tree.js +212 -22
  7. package/lib/public/js/components/file-viewer/index.js +44 -6
  8. package/lib/public/js/components/file-viewer/status-banners.js +11 -6
  9. package/lib/public/js/components/file-viewer/toolbar.js +43 -1
  10. package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +6 -0
  11. package/lib/public/js/components/file-viewer/use-file-diff.js +11 -0
  12. package/lib/public/js/components/file-viewer/use-file-loader.js +12 -2
  13. package/lib/public/js/components/file-viewer/use-file-viewer.js +94 -2
  14. package/lib/public/js/components/google/account-row.js +98 -0
  15. package/lib/public/js/components/google/add-account-modal.js +93 -0
  16. package/lib/public/js/components/google/index.js +439 -0
  17. package/lib/public/js/components/google/use-google-accounts.js +41 -0
  18. package/lib/public/js/components/icons.js +26 -0
  19. package/lib/public/js/components/sidebar-git-panel.js +43 -14
  20. package/lib/public/js/components/sidebar.js +91 -75
  21. package/lib/public/js/lib/api.js +72 -8
  22. package/lib/public/js/lib/browse-file-policies.js +29 -11
  23. package/lib/public/js/lib/syntax-highlighters/index.js +6 -5
  24. package/lib/public/shared/browse-file-policies.json +13 -0
  25. package/lib/server/constants.js +19 -7
  26. package/lib/server/google-state.js +187 -0
  27. package/lib/server/helpers.js +12 -4
  28. package/lib/server/onboarding/github.js +21 -2
  29. package/lib/server/onboarding/index.js +1 -3
  30. package/lib/server/onboarding/openclaw.js +3 -0
  31. package/lib/server/onboarding/workspace.js +40 -0
  32. package/lib/server/routes/browse/index.js +90 -2
  33. package/lib/server/routes/google.js +414 -213
  34. package/lib/setup/gitignore +3 -0
  35. package/lib/setup/hourly-git-sync.sh +28 -1
  36. package/package.json +1 -1
  37. package/lib/public/js/components/google.js +0 -228
@@ -172,83 +172,99 @@ export const AppSidebar = ({
172
172
  <${FolderLineIcon} className="sidebar-tab-icon" />
173
173
  </button>
174
174
  </div>
175
- ${sidebarTab === "menu"
176
- ? navSections.map(
177
- (section) => html`
178
- <div class="sidebar-label">${section.label}</div>
179
- <nav class="sidebar-nav">
180
- ${section.items.map(
181
- (item) => html`
182
- <a
183
- class=${selectedNavId === item.id ? "active" : ""}
184
- onclick=${() => onSelectNavItem(item.id)}
185
- >
186
- ${item.label}
187
- </a>
188
- `,
189
- )}
190
- </nav>
191
- `,
192
- )
193
- : html`
194
- <div class="sidebar-browse-layout" ref=${browseLayoutRef}>
195
- <div
196
- class="sidebar-browse-panel"
197
- >
198
- <${FileTree}
199
- onSelectFile=${onSelectBrowseFile}
200
- selectedPath=${selectedBrowsePath}
201
- onPreviewFile=${onPreviewBrowseFile}
175
+ <div
176
+ style=${{
177
+ display: sidebarTab === "menu" ? "flex" : "none",
178
+ flexDirection: "column",
179
+ flex: "1 1 auto",
180
+ minHeight: 0,
181
+ }}
182
+ >
183
+ ${navSections.map(
184
+ (section) => html`
185
+ <div class="sidebar-label">${section.label}</div>
186
+ <nav class="sidebar-nav">
187
+ ${section.items.map(
188
+ (item) => html`
189
+ <a
190
+ class=${selectedNavId === item.id ? "active" : ""}
191
+ onclick=${() => onSelectNavItem(item.id)}
192
+ >
193
+ ${item.label}
194
+ </a>
195
+ `,
196
+ )}
197
+ </nav>
198
+ `,
199
+ )}
200
+ <div class="sidebar-footer">
201
+ ${acHasUpdate && acLatest && !acDismissed
202
+ ? html`
203
+ <${UpdateActionButton}
204
+ onClick=${onAcUpdate}
205
+ loading=${acUpdating}
206
+ warning=${true}
207
+ idleLabel=${`Update to v${acLatest}`}
208
+ loadingLabel="Updating..."
209
+ className="w-full justify-center"
202
210
  />
203
- </div>
204
- <div
205
- class=${`sidebar-browse-resizer ${isResizingBrowsePanels ? "is-resizing" : ""}`}
206
- onpointerdown=${onBrowsePanelResizerPointerDown}
207
- role="separator"
208
- aria-orientation="horizontal"
209
- aria-label="Resize browse and git panels"
210
- ></div>
211
- <div class="sidebar-browse-bottom">
212
- <div
213
- class="sidebar-browse-bottom-inner"
214
- ref=${browseBottomPanelRef}
215
- style=${{ height: `${browseBottomPanelHeightPx}px` }}
216
- >
217
- <${SidebarGitPanel} onSelectFile=${onSelectBrowseFile} />
218
- ${acHasUpdate && acLatest && !acDismissed
219
- ? html`
220
- <${UpdateActionButton}
221
- onClick=${onAcUpdate}
222
- loading=${acUpdating}
223
- warning=${true}
224
- idleLabel=${`Update to v${acLatest}`}
225
- loadingLabel="Updating..."
226
- className="w-full justify-center"
227
- />
228
- `
229
- : null}
230
- </div>
231
- </div>
232
- </div>
233
- `}
234
- ${sidebarTab === "menu"
235
- ? html`
236
- <div class="sidebar-footer">
237
- ${acHasUpdate && acLatest && !acDismissed
238
- ? html`
239
- <${UpdateActionButton}
240
- onClick=${onAcUpdate}
241
- loading=${acUpdating}
242
- warning=${true}
243
- idleLabel=${`Update to v${acLatest}`}
244
- loadingLabel="Updating..."
245
- className="w-full justify-center"
246
- />
247
- `
248
- : null}
211
+ `
212
+ : null}
213
+ </div>
214
+ </div>
215
+ <div
216
+ style=${{
217
+ display: sidebarTab === "browse" ? "flex" : "none",
218
+ flexDirection: "column",
219
+ flex: "1 1 auto",
220
+ minHeight: 0,
221
+ overflow: "hidden",
222
+ }}
223
+ >
224
+ <div class="sidebar-browse-layout" ref=${browseLayoutRef}>
225
+ <div
226
+ class="sidebar-browse-panel"
227
+ >
228
+ <${FileTree}
229
+ onSelectFile=${onSelectBrowseFile}
230
+ selectedPath=${selectedBrowsePath}
231
+ onPreviewFile=${onPreviewBrowseFile}
232
+ isActive=${sidebarTab === "browse"}
233
+ />
234
+ </div>
235
+ <div
236
+ class=${`sidebar-browse-resizer ${isResizingBrowsePanels ? "is-resizing" : ""}`}
237
+ onpointerdown=${onBrowsePanelResizerPointerDown}
238
+ role="separator"
239
+ aria-orientation="horizontal"
240
+ aria-label="Resize browse and git panels"
241
+ ></div>
242
+ <div class="sidebar-browse-bottom">
243
+ <div
244
+ class="sidebar-browse-bottom-inner"
245
+ ref=${browseBottomPanelRef}
246
+ style=${{ height: `${browseBottomPanelHeightPx}px` }}
247
+ >
248
+ <${SidebarGitPanel}
249
+ onSelectFile=${onSelectBrowseFile}
250
+ isActive=${sidebarTab === "browse"}
251
+ />
252
+ ${acHasUpdate && acLatest && !acDismissed
253
+ ? html`
254
+ <${UpdateActionButton}
255
+ onClick=${onAcUpdate}
256
+ loading=${acUpdating}
257
+ warning=${true}
258
+ idleLabel=${`Update to v${acLatest}`}
259
+ loadingLabel="Updating..."
260
+ className="w-full justify-center"
261
+ />
262
+ `
263
+ : null}
249
264
  </div>
250
- `
251
- : null}
265
+ </div>
266
+ </div>
267
+ </div>
252
268
  </div>
253
269
  `;
254
270
  };
@@ -38,27 +38,73 @@ export async function rejectPairing(id, channel) {
38
38
  return res.json();
39
39
  }
40
40
 
41
- export async function fetchGoogleStatus() {
42
- const res = await authFetch('/api/google/status');
41
+ export async function fetchGoogleAccounts() {
42
+ const res = await authFetch('/api/google/accounts');
43
43
  return res.json();
44
44
  }
45
45
 
46
- export async function checkGoogleApis() {
47
- const res = await authFetch('/api/google/check');
46
+ export async function fetchGoogleStatus(accountId = "") {
47
+ const params = new URLSearchParams();
48
+ if (accountId) params.set("accountId", String(accountId));
49
+ const suffix = params.toString() ? `?${params.toString()}` : "";
50
+ const res = await authFetch(`/api/google/status${suffix}`);
48
51
  return res.json();
49
52
  }
50
53
 
51
- export async function saveGoogleCredentials(clientId, clientSecret, email) {
54
+ export async function checkGoogleApis(accountId = "") {
55
+ const params = new URLSearchParams();
56
+ if (accountId) params.set("accountId", String(accountId));
57
+ const suffix = params.toString() ? `?${params.toString()}` : "";
58
+ const res = await authFetch(`/api/google/check${suffix}`);
59
+ return res.json();
60
+ }
61
+
62
+ export async function saveGoogleCredentials({
63
+ clientId,
64
+ clientSecret,
65
+ email,
66
+ services = [],
67
+ client = "default",
68
+ personal = false,
69
+ accountId = "",
70
+ }) {
52
71
  const res = await authFetch('/api/google/credentials', {
53
72
  method: 'POST',
54
73
  headers: { 'Content-Type': 'application/json' },
55
- body: JSON.stringify({ clientId, clientSecret, email }),
74
+ body: JSON.stringify({
75
+ clientId,
76
+ clientSecret,
77
+ email,
78
+ services,
79
+ client,
80
+ personal,
81
+ accountId,
82
+ }),
56
83
  });
57
84
  return res.json();
58
85
  }
59
86
 
60
- export async function disconnectGoogle() {
61
- const res = await authFetch('/api/google/disconnect', { method: 'POST' });
87
+ export async function saveGoogleAccount({
88
+ email,
89
+ services = [],
90
+ client = "default",
91
+ personal = false,
92
+ accountId = "",
93
+ }) {
94
+ const res = await authFetch('/api/google/accounts', {
95
+ method: 'POST',
96
+ headers: { 'Content-Type': 'application/json' },
97
+ body: JSON.stringify({ email, services, client, personal, accountId }),
98
+ });
99
+ return res.json();
100
+ }
101
+
102
+ export async function disconnectGoogle(accountId = "") {
103
+ const res = await authFetch('/api/google/disconnect', {
104
+ method: 'POST',
105
+ headers: { 'Content-Type': 'application/json' },
106
+ body: JSON.stringify({ accountId }),
107
+ });
62
108
  return res.json();
63
109
  }
64
110
 
@@ -389,6 +435,24 @@ export const saveFileContent = async (filePath, content) => {
389
435
  return parseJsonOrThrow(res, 'Could not save file');
390
436
  };
391
437
 
438
+ export const deleteBrowseFile = async (filePath) => {
439
+ const res = await authFetch('/api/browse/delete', {
440
+ method: 'DELETE',
441
+ headers: { 'Content-Type': 'application/json' },
442
+ body: JSON.stringify({ path: String(filePath || '') }),
443
+ });
444
+ return parseJsonOrThrow(res, 'Could not delete file');
445
+ };
446
+
447
+ export const restoreBrowseFile = async (filePath) => {
448
+ const res = await authFetch('/api/browse/restore', {
449
+ method: 'POST',
450
+ headers: { 'Content-Type': 'application/json' },
451
+ body: JSON.stringify({ path: String(filePath || '') }),
452
+ });
453
+ return parseJsonOrThrow(res, 'Could not restore file');
454
+ };
455
+
392
456
  export const fetchBrowseGitSummary = async () => {
393
457
  const res = await authFetch('/api/browse/git-summary');
394
458
  return parseJsonOrThrow(res, 'Could not load git summary');
@@ -1,15 +1,33 @@
1
- export const kProtectedBrowsePaths = new Set([
2
- "openclaw.json",
3
- "devices/paired.json",
4
- ]);
1
+ const kBrowseFilePoliciesUrl = new URL(
2
+ "../../shared/browse-file-policies.json",
3
+ import.meta.url,
4
+ );
5
5
 
6
- export const kLockedBrowsePaths = new Set([
7
- "hooks/bootstrap/agents.md",
8
- "hooks/bootstrap/tools.md",
9
- "skills/control-ui/skill.md",
10
- ".alphaclaw/hourly-git-sync.sh",
11
- ".alphaclaw/.cli-device-auto-approved",
12
- ]);
6
+ let kBrowseFilePolicies = {
7
+ protectedPaths: [],
8
+ lockedPaths: [],
9
+ };
10
+ try {
11
+ const policyResponse = await fetch(kBrowseFilePoliciesUrl);
12
+ if (policyResponse.ok) {
13
+ const policyJson = await policyResponse.json();
14
+ if (policyJson && typeof policyJson === "object") {
15
+ kBrowseFilePolicies = policyJson;
16
+ }
17
+ }
18
+ } catch {}
19
+
20
+ export const kProtectedBrowsePaths = new Set(
21
+ Array.isArray(kBrowseFilePolicies?.protectedPaths)
22
+ ? kBrowseFilePolicies.protectedPaths
23
+ : [],
24
+ );
25
+
26
+ export const kLockedBrowsePaths = new Set(
27
+ Array.isArray(kBrowseFilePolicies?.lockedPaths)
28
+ ? kBrowseFilePolicies.lockedPaths
29
+ : [],
30
+ );
13
31
 
14
32
  export const normalizeBrowsePolicyPath = (inputPath) =>
15
33
  String(inputPath || "")
@@ -8,11 +8,12 @@ import { escapeHtml, toLineObjects } from "./utils.js";
8
8
 
9
9
  export const getFileSyntaxKind = (filePath) => {
10
10
  const normalizedPath = String(filePath || "").toLowerCase();
11
- if (/\.(md|markdown|mdx)$/i.test(normalizedPath)) return "markdown";
12
- if (/\.(json|jsonl)$/i.test(normalizedPath)) return "json";
13
- if (/\.(html|htm)$/i.test(normalizedPath)) return "html";
14
- if (/\.(js|mjs|cjs)$/i.test(normalizedPath)) return "javascript";
15
- if (/\.(css|scss)$/i.test(normalizedPath)) return "css";
11
+ const pathWithoutBakSuffix = normalizedPath.replace(/(\.bak)+$/i, "");
12
+ if (/\.(md|markdown|mdx)$/i.test(pathWithoutBakSuffix)) return "markdown";
13
+ if (/\.(json|jsonl)$/i.test(pathWithoutBakSuffix)) return "json";
14
+ if (/\.(html|htm)$/i.test(pathWithoutBakSuffix)) return "html";
15
+ if (/\.(js|mjs|cjs)$/i.test(pathWithoutBakSuffix)) return "javascript";
16
+ if (/\.(css|scss)$/i.test(pathWithoutBakSuffix)) return "css";
16
17
  return "plain";
17
18
  };
18
19
 
@@ -0,0 +1,13 @@
1
+ {
2
+ "protectedPaths": [
3
+ "openclaw.json",
4
+ "devices/paired.json"
5
+ ],
6
+ "lockedPaths": [
7
+ "hooks/bootstrap/agents.md",
8
+ "hooks/bootstrap/tools.md",
9
+ "skills/control-ui/skill.md",
10
+ ".alphaclaw/hourly-git-sync.sh",
11
+ ".alphaclaw/.cli-device-auto-approved"
12
+ ]
13
+ }
@@ -1,5 +1,6 @@
1
1
  const os = require("os");
2
2
  const path = require("path");
3
+ const kBrowseFilePolicies = require("../public/shared/browse-file-policies.json");
3
4
 
4
5
  const parsePositiveIntEnv = (value, fallbackValue) => {
5
6
  const parsed = Number.parseInt(String(value || ""), 10);
@@ -258,6 +259,11 @@ const GOG_CONFIG_DIR = path.join(OPENCLAW_DIR, "gogcli");
258
259
  const GOG_CREDENTIALS_PATH = path.join(GOG_CONFIG_DIR, "credentials.json");
259
260
  const GOG_STATE_PATH = path.join(GOG_CONFIG_DIR, "state.json");
260
261
  const GOG_KEYRING_PASSWORD = process.env.GOG_KEYRING_PASSWORD || "alphaclaw";
262
+ const kMaxGoogleAccounts = 5;
263
+ const gogClientCredentialsPath = (clientName = "default") =>
264
+ clientName === "default"
265
+ ? GOG_CREDENTIALS_PATH
266
+ : path.join(GOG_CONFIG_DIR, `credentials-${clientName}.json`);
261
267
 
262
268
  const API_TEST_COMMANDS = {
263
269
  gmail: "gmail labels list",
@@ -274,13 +280,16 @@ const kChannelDefs = {
274
280
  telegram: { envKey: "TELEGRAM_BOT_TOKEN" },
275
281
  discord: { envKey: "DISCORD_BOT_TOKEN" },
276
282
  };
277
- const kLockedBrowsePaths = new Set([
278
- "hooks/bootstrap/agents.md",
279
- "hooks/bootstrap/tools.md",
280
- "skills/control-ui/skill.md",
281
- ".alphaclaw/hourly-git-sync.sh",
282
- ".alphaclaw/.cli-device-auto-approved",
283
- ]);
283
+ const kProtectedBrowsePaths = new Set(
284
+ Array.isArray(kBrowseFilePolicies?.protectedPaths)
285
+ ? kBrowseFilePolicies.protectedPaths
286
+ : [],
287
+ );
288
+ const kLockedBrowsePaths = new Set(
289
+ Array.isArray(kBrowseFilePolicies?.lockedPaths)
290
+ ? kBrowseFilePolicies.lockedPaths
291
+ : [],
292
+ );
284
293
 
285
294
  const SETUP_API_PREFIXES = [
286
295
  "/api/status",
@@ -350,6 +359,7 @@ module.exports = {
350
359
  kSystemVars,
351
360
  kKnownVars,
352
361
  kKnownKeys,
362
+ kProtectedBrowsePaths,
353
363
  kLockedBrowsePaths,
354
364
  SCOPE_MAP,
355
365
  REVERSE_SCOPE_MAP,
@@ -358,6 +368,8 @@ module.exports = {
358
368
  GOG_CREDENTIALS_PATH,
359
369
  GOG_STATE_PATH,
360
370
  GOG_KEYRING_PASSWORD,
371
+ kMaxGoogleAccounts,
372
+ gogClientCredentialsPath,
361
373
  API_TEST_COMMANDS,
362
374
  kChannelDefs,
363
375
  SETUP_API_PREFIXES,
@@ -0,0 +1,187 @@
1
+ const crypto = require("crypto");
2
+
3
+ const kGoogleStateVersion = 2;
4
+ const kDefaultGoogleClient = "default";
5
+ const kDefaultGoogleScopes = [
6
+ "gmail:read",
7
+ "calendar:read",
8
+ "calendar:write",
9
+ "drive:read",
10
+ "sheets:read",
11
+ "docs:read",
12
+ ];
13
+
14
+ const createEmptyGoogleState = () => ({
15
+ version: kGoogleStateVersion,
16
+ accounts: [],
17
+ });
18
+
19
+ const createGoogleAccountId = () => crypto.randomBytes(4).toString("hex");
20
+
21
+ const normalizeScopes = (services) => {
22
+ if (!Array.isArray(services)) return [...kDefaultGoogleScopes];
23
+ const deduped = Array.from(
24
+ new Set(
25
+ services
26
+ .map((scope) => String(scope || "").trim())
27
+ .filter(Boolean),
28
+ ),
29
+ );
30
+ return deduped.length ? deduped : [...kDefaultGoogleScopes];
31
+ };
32
+
33
+ const isLikelyPersonalEmail = (email = "") => {
34
+ const normalized = String(email || "").trim().toLowerCase();
35
+ return normalized.endsWith("@gmail.com") || normalized.endsWith("@googlemail.com");
36
+ };
37
+
38
+ const normalizePersonalFlag = ({ account = {}, client = kDefaultGoogleClient }) => {
39
+ if (typeof account.personal === "boolean") return account.personal;
40
+ if (client === "personal") return true;
41
+ return isLikelyPersonalEmail(account.email);
42
+ };
43
+
44
+ const normalizeGoogleAccount = (account = {}) => ({
45
+ // Backward-compatible migration path for older state entries that predate
46
+ // explicit personal flags or were saved before the personal marker existed.
47
+ ...(() => {
48
+ const client =
49
+ String(account.client || kDefaultGoogleClient).trim() || kDefaultGoogleClient;
50
+ return {
51
+ id: String(account.id || createGoogleAccountId()),
52
+ email: String(account.email || "").trim(),
53
+ client,
54
+ personal: normalizePersonalFlag({ account, client }),
55
+ services: normalizeScopes(account.services),
56
+ authenticated: Boolean(account.authenticated),
57
+ };
58
+ })(),
59
+ });
60
+
61
+ const normalizeGoogleStateV2 = (state = {}) => {
62
+ const accounts = Array.isArray(state.accounts)
63
+ ? state.accounts.map((account) => normalizeGoogleAccount(account))
64
+ : [];
65
+ return {
66
+ version: kGoogleStateVersion,
67
+ accounts,
68
+ };
69
+ };
70
+
71
+ const hasPersonalGoogleAccount = (state = {}) =>
72
+ (state.accounts || []).some((account) => account.personal);
73
+
74
+ const writeGoogleState = ({ fs, statePath, state }) => {
75
+ const normalized = normalizeGoogleStateV2(state);
76
+ fs.writeFileSync(statePath, JSON.stringify(normalized, null, 2));
77
+ return normalized;
78
+ };
79
+
80
+ const migrateGoogleStateV1 = ({ fs, statePath, rawState = {} }) => {
81
+ const email = String(rawState.email || "").trim();
82
+ const accounts = email
83
+ ? [
84
+ normalizeGoogleAccount({
85
+ id: createGoogleAccountId(),
86
+ email,
87
+ services: rawState.services,
88
+ authenticated: Boolean(rawState.authenticated),
89
+ client: kDefaultGoogleClient,
90
+ personal: false,
91
+ }),
92
+ ]
93
+ : [];
94
+ const migrated = {
95
+ version: kGoogleStateVersion,
96
+ accounts,
97
+ };
98
+ fs.writeFileSync(statePath, JSON.stringify(migrated, null, 2));
99
+ return migrated;
100
+ };
101
+
102
+ const readGoogleState = ({ fs, statePath }) => {
103
+ if (!fs.existsSync(statePath)) return createEmptyGoogleState();
104
+ try {
105
+ const raw = JSON.parse(fs.readFileSync(statePath, "utf8"));
106
+ if (raw && raw.version === kGoogleStateVersion && Array.isArray(raw.accounts)) {
107
+ const normalized = normalizeGoogleStateV2(raw);
108
+ if (JSON.stringify(raw) !== JSON.stringify(normalized)) {
109
+ fs.writeFileSync(statePath, JSON.stringify(normalized, null, 2));
110
+ }
111
+ return normalized;
112
+ }
113
+ return migrateGoogleStateV1({ fs, statePath, rawState: raw || {} });
114
+ } catch {
115
+ return createEmptyGoogleState();
116
+ }
117
+ };
118
+
119
+ const listGoogleAccounts = (state = {}) => [...(state.accounts || [])];
120
+
121
+ const getGoogleAccountById = (state = {}, accountId = "") =>
122
+ (state.accounts || []).find((account) => account.id === accountId) || null;
123
+
124
+ const getGoogleAccountByEmailAndClient = (
125
+ state = {},
126
+ email = "",
127
+ client = kDefaultGoogleClient,
128
+ ) =>
129
+ (state.accounts || []).find(
130
+ (account) => account.email === email && account.client === client,
131
+ ) || null;
132
+
133
+ const upsertGoogleAccount = ({
134
+ state,
135
+ account,
136
+ maxAccounts = 5,
137
+ }) => {
138
+ const nextState = normalizeGoogleStateV2(state);
139
+ const normalized = normalizeGoogleAccount(account);
140
+ if (!normalized.email) throw new Error("Account email is required");
141
+ const existingIdx = nextState.accounts.findIndex((item) => item.id === normalized.id);
142
+
143
+ if (normalized.personal) {
144
+ const personalExists = nextState.accounts.some(
145
+ (item, idx) => item.personal && idx !== existingIdx,
146
+ );
147
+ if (personalExists) {
148
+ throw new Error("Only one personal account is allowed");
149
+ }
150
+ }
151
+
152
+ if (existingIdx >= 0) {
153
+ nextState.accounts[existingIdx] = normalized;
154
+ return { state: nextState, account: normalized };
155
+ }
156
+
157
+ if (nextState.accounts.length >= maxAccounts) {
158
+ throw new Error(`Maximum ${maxAccounts} Google accounts allowed`);
159
+ }
160
+
161
+ nextState.accounts.push(normalized);
162
+ return { state: nextState, account: normalized };
163
+ };
164
+
165
+ const removeGoogleAccount = ({ state, accountId }) => {
166
+ const nextState = normalizeGoogleStateV2(state);
167
+ const removed = getGoogleAccountById(nextState, accountId);
168
+ if (!removed) return { state: nextState, account: null };
169
+ nextState.accounts = nextState.accounts.filter((account) => account.id !== accountId);
170
+ return { state: nextState, account: removed };
171
+ };
172
+
173
+ module.exports = {
174
+ kGoogleStateVersion,
175
+ kDefaultGoogleClient,
176
+ kDefaultGoogleScopes,
177
+ createGoogleAccountId,
178
+ createEmptyGoogleState,
179
+ readGoogleState,
180
+ writeGoogleState,
181
+ listGoogleAccounts,
182
+ getGoogleAccountById,
183
+ getGoogleAccountByEmailAndClient,
184
+ upsertGoogleAccount,
185
+ removeGoogleAccount,
186
+ hasPersonalGoogleAccount,
187
+ };
@@ -3,7 +3,7 @@ const crypto = require("crypto");
3
3
  const {
4
4
  CODEX_JWT_CLAIM_PATH,
5
5
  kOnboardingModelProviders,
6
- GOG_CREDENTIALS_PATH,
6
+ gogClientCredentialsPath,
7
7
  } = require("./constants");
8
8
 
9
9
  const normalizeOpenclawVersion = (rawVersion) => {
@@ -170,9 +170,10 @@ const getApiEnableUrl = (svc, projectId) => {
170
170
  return `https://console.developers.google.com/apis/api/${api}/overview${project}`;
171
171
  };
172
172
 
173
- const readGoogleCredentials = () => {
173
+ const readGoogleCredentials = (clientName = "default") => {
174
174
  try {
175
- const c = JSON.parse(fs.readFileSync(GOG_CREDENTIALS_PATH, "utf8"));
175
+ const credentialsPath = gogClientCredentialsPath(clientName);
176
+ const c = JSON.parse(fs.readFileSync(credentialsPath, "utf8"));
176
177
  return {
177
178
  clientId:
178
179
  c.web?.client_id || c.installed?.client_id || c.client_id || null,
@@ -181,9 +182,16 @@ const readGoogleCredentials = () => {
181
182
  c.installed?.client_secret ||
182
183
  c.client_secret ||
183
184
  null,
185
+ path: credentialsPath,
186
+ client: clientName,
184
187
  };
185
188
  } catch {
186
- return { clientId: null, clientSecret: null };
189
+ return {
190
+ clientId: null,
191
+ clientSecret: null,
192
+ path: gogClientCredentialsPath(clientName),
193
+ client: clientName,
194
+ };
187
195
  }
188
196
  };
189
197