@chrysb/alphaclaw 0.3.5-beta.1 → 0.4.1-beta.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 (68) 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/css/shell.css +21 -19
  5. package/lib/public/css/theme.css +17 -0
  6. package/lib/public/js/app.js +205 -109
  7. package/lib/public/js/components/credentials-modal.js +36 -8
  8. package/lib/public/js/components/file-tree.js +212 -22
  9. package/lib/public/js/components/file-viewer/editor-surface.js +5 -0
  10. package/lib/public/js/components/file-viewer/index.js +47 -6
  11. package/lib/public/js/components/file-viewer/markdown-split-view.js +2 -0
  12. package/lib/public/js/components/file-viewer/status-banners.js +11 -6
  13. package/lib/public/js/components/file-viewer/toolbar.js +56 -1
  14. package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +6 -0
  15. package/lib/public/js/components/file-viewer/use-file-diff.js +11 -0
  16. package/lib/public/js/components/file-viewer/use-file-loader.js +12 -2
  17. package/lib/public/js/components/file-viewer/use-file-viewer.js +142 -15
  18. package/lib/public/js/components/google/account-row.js +131 -0
  19. package/lib/public/js/components/google/add-account-modal.js +93 -0
  20. package/lib/public/js/components/google/gmail-setup-wizard.js +450 -0
  21. package/lib/public/js/components/google/gmail-watch-toggle.js +81 -0
  22. package/lib/public/js/components/google/index.js +553 -0
  23. package/lib/public/js/components/google/use-gmail-watch.js +140 -0
  24. package/lib/public/js/components/google/use-google-accounts.js +41 -0
  25. package/lib/public/js/components/icons.js +26 -0
  26. package/lib/public/js/components/scope-picker.js +1 -1
  27. package/lib/public/js/components/sidebar-git-panel.js +48 -20
  28. package/lib/public/js/components/sidebar.js +93 -75
  29. package/lib/public/js/components/toast.js +11 -7
  30. package/lib/public/js/components/usage-tab/constants.js +31 -0
  31. package/lib/public/js/components/usage-tab/formatters.js +24 -0
  32. package/lib/public/js/components/usage-tab/index.js +72 -0
  33. package/lib/public/js/components/usage-tab/overview-section.js +147 -0
  34. package/lib/public/js/components/usage-tab/sessions-section.js +175 -0
  35. package/lib/public/js/components/usage-tab/use-usage-tab.js +241 -0
  36. package/lib/public/js/components/webhooks.js +182 -129
  37. package/lib/public/js/lib/api.js +178 -9
  38. package/lib/public/js/lib/browse-file-policies.js +29 -11
  39. package/lib/public/js/lib/format.js +71 -0
  40. package/lib/public/js/lib/syntax-highlighters/index.js +6 -5
  41. package/lib/public/shared/browse-file-policies.json +13 -0
  42. package/lib/server/constants.js +47 -7
  43. package/lib/server/gmail-push.js +109 -0
  44. package/lib/server/gmail-serve.js +254 -0
  45. package/lib/server/gmail-watch.js +725 -0
  46. package/lib/server/google-state.js +317 -0
  47. package/lib/server/helpers.js +17 -11
  48. package/lib/server/internal-files-migration.js +31 -3
  49. package/lib/server/onboarding/github.js +21 -2
  50. package/lib/server/onboarding/index.js +1 -3
  51. package/lib/server/onboarding/openclaw.js +3 -0
  52. package/lib/server/onboarding/workspace.js +40 -0
  53. package/lib/server/routes/browse/index.js +90 -2
  54. package/lib/server/routes/gmail.js +128 -0
  55. package/lib/server/routes/google.js +433 -213
  56. package/lib/server/routes/system.js +107 -0
  57. package/lib/server/routes/usage.js +29 -2
  58. package/lib/server/routes/webhooks.js +52 -17
  59. package/lib/server/usage-db.js +283 -15
  60. package/lib/server/watchdog.js +66 -0
  61. package/lib/server/webhook-middleware.js +99 -1
  62. package/lib/server/webhooks.js +214 -65
  63. package/lib/server.js +27 -0
  64. package/lib/setup/gitignore +6 -0
  65. package/lib/setup/hourly-git-sync.sh +29 -2
  66. package/package.json +1 -1
  67. package/lib/public/js/components/google.js +0 -228
  68. package/lib/public/js/components/usage-tab.js +0 -531
@@ -1,3 +1,36 @@
1
+ const {
2
+ kDefaultGoogleClient,
3
+ kDefaultGoogleScopes,
4
+ createGoogleAccountId,
5
+ readGoogleState,
6
+ writeGoogleState,
7
+ listGoogleAccounts,
8
+ getGoogleAccountById,
9
+ getGoogleAccountByEmailAndClient,
10
+ upsertGoogleAccount,
11
+ removeGoogleAccount,
12
+ } = require("../google-state");
13
+ const { syncBootstrapPromptFiles } = require("../onboarding/workspace");
14
+
15
+ const quoteShellArg = (value) => `"${String(value || "").replace(/(["\\$`])/g, "\\$1")}"`;
16
+
17
+ const parseJsonSafe = (raw, fallbackValue) => {
18
+ try {
19
+ return JSON.parse(String(raw || ""));
20
+ } catch {
21
+ return fallbackValue;
22
+ }
23
+ };
24
+
25
+ const uniqueServiceLabels = (scopes) =>
26
+ Array.from(
27
+ new Set(
28
+ (scopes || [])
29
+ .map((scope) => String(scope || "").split(":")[0])
30
+ .filter(Boolean),
31
+ ),
32
+ );
33
+
1
34
  const registerGoogleRoutes = ({
2
35
  app,
3
36
  fs,
@@ -9,15 +42,136 @@ const registerGoogleRoutes = ({
9
42
  constants,
10
43
  }) => {
11
44
  const {
12
- GOG_CREDENTIALS_PATH,
13
45
  GOG_CONFIG_DIR,
14
46
  GOG_STATE_PATH,
15
47
  API_TEST_COMMANDS,
16
48
  BASE_SCOPES,
17
49
  SCOPE_MAP,
18
50
  REVERSE_SCOPE_MAP,
51
+ kMaxGoogleAccounts,
52
+ gogClientCredentialsPath,
19
53
  } = constants;
20
54
 
55
+ const readState = () => readGoogleState({ fs, statePath: GOG_STATE_PATH });
56
+ const saveState = (state) => writeGoogleState({ fs, statePath: GOG_STATE_PATH, state });
57
+ const syncBootstrapTools = (req) => {
58
+ try {
59
+ syncBootstrapPromptFiles({
60
+ fs,
61
+ workspaceDir: constants.WORKSPACE_DIR,
62
+ baseUrl: getBaseUrl(req),
63
+ });
64
+ } catch {}
65
+ };
66
+
67
+ const listAuthenticatedAccounts = async (state) => {
68
+ const configuredClients = new Set([kDefaultGoogleClient]);
69
+ listGoogleAccounts(state).forEach((account) => {
70
+ const client = String(account.client || kDefaultGoogleClient).trim() || kDefaultGoogleClient;
71
+ configuredClients.add(client);
72
+ });
73
+ const combined = [];
74
+ for (const client of configuredClients) {
75
+ const command =
76
+ client === kDefaultGoogleClient
77
+ ? "auth list --json --check"
78
+ : `--client ${quoteShellArg(client)} auth list --json --check`;
79
+ const result = await gogCmd(command, { quiet: true });
80
+ if (!result.ok) continue;
81
+ const parsed = parseJsonSafe(result.stdout, { accounts: [] });
82
+ const accounts = Array.isArray(parsed?.accounts) ? parsed.accounts : [];
83
+ accounts.forEach((entry) => {
84
+ combined.push({
85
+ ...entry,
86
+ client: String(entry.client || client || kDefaultGoogleClient).trim() || kDefaultGoogleClient,
87
+ });
88
+ });
89
+ }
90
+ return combined;
91
+ };
92
+
93
+ const accountIsAuthenticated = ({ account, authenticatedAccounts }) =>
94
+ authenticatedAccounts.some(
95
+ (entry) =>
96
+ String(entry.email || "").trim().toLowerCase() === String(account.email || "").trim().toLowerCase() &&
97
+ String(entry.client || kDefaultGoogleClient).trim() === String(account.client || kDefaultGoogleClient).trim() &&
98
+ (entry.valid !== false),
99
+ );
100
+
101
+ const getSelectedAccount = ({ state, accountId, fallbackToFirst = true }) => {
102
+ if (accountId) {
103
+ return getGoogleAccountById(state, accountId);
104
+ }
105
+ return fallbackToFirst ? listGoogleAccounts(state)[0] || null : null;
106
+ };
107
+
108
+ const clearStoredGoogleAuthForEmail = async ({
109
+ email,
110
+ preferredClient = kDefaultGoogleClient,
111
+ extraClients = [],
112
+ }) => {
113
+ const normalizedEmail = String(email || "").trim();
114
+ if (!normalizedEmail) return;
115
+ const clientCandidates = new Set([
116
+ kDefaultGoogleClient,
117
+ preferredClient,
118
+ ...extraClients,
119
+ ]);
120
+ for (const clientName of clientCandidates) {
121
+ const safeClientName =
122
+ String(clientName || "").trim() || kDefaultGoogleClient;
123
+ const clientArg =
124
+ safeClientName === kDefaultGoogleClient
125
+ ? ""
126
+ : `--client ${quoteShellArg(safeClientName)} `;
127
+ await gogCmd(
128
+ `${clientArg}auth remove ${quoteShellArg(normalizedEmail)} --force`,
129
+ { quiet: true },
130
+ );
131
+ }
132
+ };
133
+
134
+ const ensureClientCredentials = ({ client, clientId, clientSecret, req }) => {
135
+ const credentialsPath = gogClientCredentialsPath(client);
136
+ fs.mkdirSync(GOG_CONFIG_DIR, { recursive: true });
137
+ const credentials = {
138
+ web: {
139
+ client_id: clientId,
140
+ client_secret: clientSecret,
141
+ auth_uri: "https://accounts.google.com/o/oauth2/auth",
142
+ token_uri: "https://oauth2.googleapis.com/token",
143
+ redirect_uris: [`${getBaseUrl(req)}/auth/google/callback`],
144
+ },
145
+ };
146
+ fs.writeFileSync(credentialsPath, JSON.stringify(credentials, null, 2));
147
+ return credentialsPath;
148
+ };
149
+
150
+ app.get("/api/google/accounts", async (req, res) => {
151
+ const state = readState();
152
+ const authenticatedAccounts = await listAuthenticatedAccounts(state);
153
+ const accounts = listGoogleAccounts(state).map((account) => {
154
+ const activeScopes = account.services || [];
155
+ const services = uniqueServiceLabels(activeScopes).join(", ");
156
+ const hasCredentials = fs.existsSync(gogClientCredentialsPath(account.client));
157
+ return {
158
+ ...account,
159
+ services,
160
+ activeScopes,
161
+ hasCredentials,
162
+ authenticated:
163
+ hasCredentials &&
164
+ (Boolean(account.authenticated) || accountIsAuthenticated({ account, authenticatedAccounts })),
165
+ };
166
+ });
167
+ res.json({
168
+ ok: true,
169
+ hasCompanyCredentials: fs.existsSync(gogClientCredentialsPath(kDefaultGoogleClient)),
170
+ hasPersonalCredentials: fs.existsSync(gogClientCredentialsPath("personal")),
171
+ accounts,
172
+ });
173
+ });
174
+
21
175
  app.get("/api/google/status", async (req, res) => {
22
176
  if (!(await isGatewayRunning())) {
23
177
  return res.json({
@@ -25,106 +179,190 @@ const registerGoogleRoutes = ({
25
179
  authenticated: false,
26
180
  email: "",
27
181
  services: "",
182
+ activeScopes: [],
28
183
  });
29
184
  }
30
- const hasCredentials = fs.existsSync(GOG_CREDENTIALS_PATH);
31
- let authenticated = false;
32
- let email = "";
33
-
34
- if (hasCredentials) {
35
- const result = await gogCmd("auth list --plain", { quiet: true });
36
- if (result.ok && result.stdout && !result.stdout.includes("no accounts")) {
37
- authenticated = true;
38
- email = result.stdout.split("\n")[0]?.split("\t")[0] || "";
39
- }
40
- if (!email) {
41
- try {
42
- const state = JSON.parse(fs.readFileSync(GOG_STATE_PATH, "utf8"));
43
- email = state.email || "";
44
- } catch {}
45
- }
185
+ const state = readState();
186
+ const selected = getSelectedAccount({
187
+ state,
188
+ accountId: String(req.query.accountId || ""),
189
+ fallbackToFirst: true,
190
+ });
191
+ if (!selected) {
192
+ return res.json({
193
+ hasCredentials: false,
194
+ authenticated: false,
195
+ email: "",
196
+ services: "",
197
+ activeScopes: [],
198
+ });
46
199
  }
47
-
48
- let services = "";
49
- let activeScopes = [];
50
- try {
51
- const stateData = JSON.parse(fs.readFileSync(GOG_STATE_PATH, "utf8"));
52
- activeScopes = stateData.services || [];
53
- services = activeScopes
54
- .map((s) => s.split(":")[0])
55
- .filter((v, i, a) => a.indexOf(v) === i)
56
- .join(", ");
57
- } catch {}
58
-
59
- const status = {
200
+ const authenticatedAccounts = await listAuthenticatedAccounts(state);
201
+ const activeScopes = selected.services || [];
202
+ const services = uniqueServiceLabels(activeScopes).join(", ");
203
+ const hasCredentials = fs.existsSync(gogClientCredentialsPath(selected.client));
204
+ res.json({
205
+ accountId: selected.id,
206
+ client: selected.client,
207
+ personal: selected.personal,
60
208
  hasCredentials,
61
- authenticated,
62
- email,
209
+ authenticated:
210
+ hasCredentials &&
211
+ (Boolean(selected.authenticated) ||
212
+ accountIsAuthenticated({ account: selected, authenticatedAccounts })),
213
+ email: selected.email,
63
214
  services,
64
215
  activeScopes,
65
- };
66
- res.json(status);
216
+ });
217
+ });
218
+
219
+ app.get("/api/google/credentials", (req, res) => {
220
+ const state = readState();
221
+ const accountId = String(req.query.accountId || "").trim();
222
+ const requestedClient = String(req.query.client || "").trim();
223
+ const account = accountId ? getGoogleAccountById(state, accountId) : null;
224
+ const client =
225
+ String(account?.client || requestedClient || kDefaultGoogleClient).trim()
226
+ || kDefaultGoogleClient;
227
+ const credentials = readGoogleCredentials(client);
228
+ const hasCredentials = Boolean(credentials.clientId && credentials.clientSecret);
229
+ res.json({
230
+ ok: true,
231
+ client,
232
+ hasCredentials,
233
+ clientId: credentials.clientId || "",
234
+ clientSecret: credentials.clientSecret || "",
235
+ });
67
236
  });
68
237
 
69
238
  app.post("/api/google/credentials", async (req, res) => {
70
- const { clientId, clientSecret, email } = req.body;
239
+ const body = req.body || {};
240
+ const clientId = String(body.clientId || "").trim();
241
+ const clientSecret = String(body.clientSecret || "").trim();
242
+ const email = String(body.email || "").trim();
243
+ const accountId = String(body.accountId || "").trim();
244
+ const personal = Boolean(body.personal);
245
+ const client = String(body.client || (personal ? "personal" : kDefaultGoogleClient)).trim()
246
+ || kDefaultGoogleClient;
71
247
  if (!clientId || !clientSecret || !email) {
72
248
  return res.json({ ok: false, error: "Missing fields" });
73
249
  }
74
250
 
75
251
  try {
76
- fs.mkdirSync(GOG_CONFIG_DIR, { recursive: true });
77
- const credentials = {
78
- web: {
79
- client_id: clientId,
80
- client_secret: clientSecret,
81
- auth_uri: "https://accounts.google.com/o/oauth2/auth",
82
- token_uri: "https://oauth2.googleapis.com/token",
83
- redirect_uris: [`${getBaseUrl(req)}/auth/google/callback`],
84
- },
85
- };
86
-
87
- fs.writeFileSync(GOG_CREDENTIALS_PATH, JSON.stringify(credentials, null, 2));
88
- const result = await gogCmd(`auth credentials set ${GOG_CREDENTIALS_PATH}`);
89
- console.log(`[alphaclaw] gog credentials set: ${JSON.stringify(result)}`);
252
+ const state = readState();
253
+ const existing = accountId ? getGoogleAccountById(state, accountId) : null;
254
+ const legacyClientsForEmail = listGoogleAccounts(state)
255
+ .filter(
256
+ (entry) =>
257
+ String(entry.email || "").trim().toLowerCase() ===
258
+ email.toLowerCase(),
259
+ )
260
+ .map((entry) => String(entry.client || kDefaultGoogleClient).trim());
261
+ await clearStoredGoogleAuthForEmail({
262
+ email,
263
+ preferredClient: client,
264
+ extraClients: [
265
+ ...legacyClientsForEmail,
266
+ String(existing?.client || "").trim(),
267
+ ],
268
+ });
269
+ const credentialsPath = ensureClientCredentials({
270
+ client,
271
+ clientId,
272
+ clientSecret,
273
+ req,
274
+ });
275
+ const command = client === kDefaultGoogleClient
276
+ ? `auth credentials set ${quoteShellArg(credentialsPath)}`
277
+ : `--client ${quoteShellArg(client)} auth credentials set ${quoteShellArg(credentialsPath)}`;
278
+ const result = await gogCmd(command, { quiet: true });
279
+ if (!result.ok) {
280
+ throw new Error(result.stderr || "Failed to set Google client credentials");
281
+ }
90
282
 
91
- const services = req.body.services || [
92
- "gmail:read",
93
- "calendar:read",
94
- "calendar:write",
95
- "drive:read",
96
- "sheets:read",
97
- "docs:read",
98
- ];
99
- fs.writeFileSync(GOG_STATE_PATH, JSON.stringify({ email, services }));
283
+ const { state: nextState, account } = upsertGoogleAccount({
284
+ state,
285
+ maxAccounts: kMaxGoogleAccounts,
286
+ account: {
287
+ id: existing?.id || accountId || createGoogleAccountId(),
288
+ email,
289
+ personal,
290
+ client,
291
+ services: body.services || existing?.services || kDefaultGoogleScopes,
292
+ authenticated: false,
293
+ },
294
+ });
295
+ saveState(nextState);
296
+ syncBootstrapTools(req);
100
297
 
101
- res.json({ ok: true });
298
+ res.json({ ok: true, accountId: account.id, account });
102
299
  } catch (err) {
103
300
  console.error("[alphaclaw] Failed to save Google credentials:", err);
104
301
  res.json({ ok: false, error: err.message });
105
302
  }
106
303
  });
107
304
 
108
- app.get("/api/google/check", async (req, res) => {
109
- let email = "";
110
- let activeScopes = [];
305
+ app.post("/api/google/accounts", (req, res) => {
306
+ const body = req.body || {};
307
+ const email = String(body.email || "").trim();
308
+ const accountId = String(body.accountId || "").trim();
309
+ const personal = Boolean(body.personal);
310
+ const client = String(body.client || (personal ? "personal" : kDefaultGoogleClient)).trim()
311
+ || kDefaultGoogleClient;
312
+ if (!email) {
313
+ return res.json({ ok: false, error: "Missing fields" });
314
+ }
315
+ if (!fs.existsSync(gogClientCredentialsPath(client))) {
316
+ return res.json({
317
+ ok: false,
318
+ error: "Credentials missing for selected client. Save credentials first.",
319
+ });
320
+ }
111
321
  try {
112
- const stateData = JSON.parse(fs.readFileSync(GOG_STATE_PATH, "utf8"));
113
- email = stateData.email || "";
114
- activeScopes = stateData.services || [];
115
- } catch {}
116
- if (!email) return res.json({ error: "No Google account configured" });
322
+ const state = readState();
323
+ const existing = accountId ? getGoogleAccountById(state, accountId) : null;
324
+ const { state: nextState, account } = upsertGoogleAccount({
325
+ state,
326
+ maxAccounts: kMaxGoogleAccounts,
327
+ account: {
328
+ id: existing?.id || accountId || createGoogleAccountId(),
329
+ email,
330
+ personal,
331
+ client,
332
+ services: body.services || existing?.services || kDefaultGoogleScopes,
333
+ authenticated: Boolean(existing?.authenticated),
334
+ },
335
+ });
336
+ saveState(nextState);
337
+ syncBootstrapTools(req);
338
+ res.json({ ok: true, accountId: account.id, account });
339
+ } catch (err) {
340
+ res.json({ ok: false, error: err.message });
341
+ }
342
+ });
117
343
 
118
- const enabledServices = activeScopes
119
- .map((s) => s.split(":")[0])
120
- .filter((v, i, a) => a.indexOf(v) === i);
344
+ app.get("/api/google/check", async (req, res) => {
345
+ const state = readState();
346
+ const account = getSelectedAccount({
347
+ state,
348
+ accountId: String(req.query.accountId || ""),
349
+ fallbackToFirst: true,
350
+ });
351
+ if (!account) return res.json({ error: "No Google account configured" });
352
+
353
+ const enabledServices = uniqueServiceLabels(account.services || []);
121
354
  const results = {};
122
-
123
355
  for (const svc of enabledServices) {
124
356
  const cmd = API_TEST_COMMANDS[svc];
125
357
  if (!cmd) continue;
126
-
127
- const result = await gogCmd(`${cmd} --account ${email}`, { quiet: true });
358
+ const clientArg =
359
+ account.client === kDefaultGoogleClient
360
+ ? ""
361
+ : `--client ${quoteShellArg(account.client)} `;
362
+ const result = await gogCmd(
363
+ `${clientArg}${cmd} --account ${quoteShellArg(account.email)}`,
364
+ { quiet: true },
365
+ );
128
366
  const stderr = result.stderr || "";
129
367
  if (stderr.includes("has not been used") || stderr.includes("is not enabled")) {
130
368
  const projectMatch = stderr.match(/project=(\d+)/);
@@ -135,7 +373,6 @@ const registerGoogleRoutes = ({
135
373
  } else if (result.ok || stderr.includes("not found") || stderr.includes("Not Found")) {
136
374
  results[svc] = { status: "ok", enableUrl: getApiEnableUrl(svc) };
137
375
  } else {
138
- console.log(`[alphaclaw] API check ${svc} error: ${result.stderr?.slice(0, 300)}`);
139
376
  results[svc] = {
140
377
  status: "error",
141
378
  message: result.stderr?.slice(0, 200),
@@ -143,58 +380,47 @@ const registerGoogleRoutes = ({
143
380
  };
144
381
  }
145
382
  }
146
-
147
- res.json({ email, results });
383
+ res.json({ accountId: account.id, email: account.email, results });
148
384
  });
149
385
 
150
386
  app.post("/api/google/disconnect", async (req, res) => {
387
+ const accountId = String(req.body?.accountId || "").trim();
388
+ const state = readState();
389
+ const account = getSelectedAccount({ state, accountId, fallbackToFirst: true });
390
+ if (!account) return res.json({ ok: true });
151
391
  try {
152
- let email = "";
153
- try {
154
- const stateData = JSON.parse(fs.readFileSync(GOG_STATE_PATH, "utf8"));
155
- email = stateData.email || "";
156
- } catch {}
157
-
158
- if (email) {
159
- const exportResult = await gogCmd(
160
- `auth tokens export ${email} --out /tmp/gog-revoke.json --overwrite`,
161
- { quiet: true },
162
- );
163
- if (exportResult.ok) {
164
- try {
165
- const tokenData = JSON.parse(fs.readFileSync("/tmp/gog-revoke.json", "utf8"));
166
- if (tokenData.refresh_token) {
167
- await fetch(`https://oauth2.googleapis.com/revoke?token=${tokenData.refresh_token}`, {
168
- method: "POST",
169
- });
170
- console.log(`[alphaclaw] Revoked Google token for ${email}`);
171
- }
172
- fs.unlinkSync("/tmp/gog-revoke.json");
173
- } catch {}
174
- }
175
- await gogCmd(`auth remove ${email} --force`);
176
- }
177
-
178
- for (const f of [GOG_STATE_PATH, GOG_CREDENTIALS_PATH]) {
392
+ const revokeFile = `/tmp/gog-revoke-${Date.now()}.json`;
393
+ const clientArg =
394
+ account.client === kDefaultGoogleClient
395
+ ? ""
396
+ : `--client ${quoteShellArg(account.client)} `;
397
+ const exportResult = await gogCmd(
398
+ `${clientArg}auth tokens export ${quoteShellArg(account.email)} --out ${quoteShellArg(revokeFile)} --overwrite`,
399
+ { quiet: true },
400
+ );
401
+ if (exportResult.ok && fs.existsSync(revokeFile)) {
179
402
  try {
180
- fs.unlinkSync(f);
181
- console.log(`[alphaclaw] Deleted ${f}`);
182
- } catch (e) {
183
- if (e.code !== "ENOENT") {
184
- console.error(`[alphaclaw] Failed to delete ${f}: ${e.message}`);
403
+ const tokenData = parseJsonSafe(fs.readFileSync(revokeFile, "utf8"), {});
404
+ if (tokenData.refresh_token) {
405
+ await fetch(`https://oauth2.googleapis.com/revoke?token=${tokenData.refresh_token}`, {
406
+ method: "POST",
407
+ });
185
408
  }
186
- }
187
- }
188
-
189
- const stateStillExists = fs.existsSync(GOG_STATE_PATH);
190
- const credsStillExists = fs.existsSync(GOG_CREDENTIALS_PATH);
191
- if (stateStillExists || credsStillExists) {
192
- console.error(
193
- `[alphaclaw] Files survived deletion! state=${stateStillExists} creds=${credsStillExists}`,
194
- );
409
+ } catch {}
195
410
  }
196
-
197
- console.log(`[alphaclaw] Google disconnected: ${email}`);
411
+ try {
412
+ fs.unlinkSync(revokeFile);
413
+ } catch {}
414
+ await gogCmd(
415
+ `${clientArg}auth remove ${quoteShellArg(account.email)} --force`,
416
+ { quiet: true },
417
+ );
418
+ const { state: nextState } = removeGoogleAccount({
419
+ state,
420
+ accountId: account.id,
421
+ });
422
+ saveState(nextState);
423
+ syncBootstrapTools(req);
198
424
  res.json({ ok: true });
199
425
  } catch (err) {
200
426
  console.error("[alphaclaw] Google disconnect error:", err);
@@ -203,31 +429,44 @@ const registerGoogleRoutes = ({
203
429
  });
204
430
 
205
431
  app.get("/auth/google/start", (req, res) => {
206
- const email = req.query.email || "";
432
+ const state = readState();
433
+ const requestedAccountId = String(req.query.accountId || "").trim();
434
+ const requestedClient = String(req.query.client || "").trim();
435
+ let account = requestedAccountId
436
+ ? getGoogleAccountById(state, requestedAccountId)
437
+ : null;
438
+ if (!account && req.query.email) {
439
+ account = getGoogleAccountByEmailAndClient(
440
+ state,
441
+ String(req.query.email || "").trim(),
442
+ requestedClient || kDefaultGoogleClient,
443
+ );
444
+ }
445
+ const client = account?.client || requestedClient || kDefaultGoogleClient;
446
+ const email = account?.email || String(req.query.email || "").trim();
207
447
  const services = (
208
448
  req.query.services ||
209
- "gmail:read,calendar:read,calendar:write,drive:read,sheets:read,docs:read"
449
+ (account?.services || kDefaultGoogleScopes).join(",")
210
450
  )
211
451
  .split(",")
452
+ .map((scope) => String(scope || "").trim())
212
453
  .filter(Boolean);
213
-
214
454
  try {
215
- const { clientId } = readGoogleCredentials();
455
+ const { clientId } = readGoogleCredentials(client);
216
456
  if (!clientId) throw new Error("No client_id found");
217
-
218
457
  const scopes = [
219
458
  ...BASE_SCOPES,
220
- ...services.map((s) => SCOPE_MAP[s]).filter(Boolean),
459
+ ...services.map((scope) => SCOPE_MAP[scope]).filter(Boolean),
221
460
  ].join(" ");
222
- console.log(
223
- `[alphaclaw] Google OAuth scopes: services=${services.join(",")} resolved=${scopes}`,
224
- );
225
-
226
461
  const redirectUri = `${getBaseUrl(req)}/auth/google/callback`;
227
- const state = Buffer.from(JSON.stringify({ email, services })).toString(
228
- "base64url",
229
- );
230
-
462
+ const encodedState = Buffer.from(
463
+ JSON.stringify({
464
+ accountId: account?.id || requestedAccountId || "",
465
+ client,
466
+ email,
467
+ services,
468
+ }),
469
+ ).toString("base64url");
231
470
  const authUrl = new URL("https://accounts.google.com/o/oauth2/auth");
232
471
  authUrl.searchParams.set("client_id", clientId);
233
472
  authUrl.searchParams.set("redirect_uri", redirectUri);
@@ -235,31 +474,41 @@ const registerGoogleRoutes = ({
235
474
  authUrl.searchParams.set("scope", scopes);
236
475
  authUrl.searchParams.set("access_type", "offline");
237
476
  authUrl.searchParams.set("prompt", "consent");
238
- authUrl.searchParams.set("state", state);
477
+ authUrl.searchParams.set("state", encodedState);
239
478
  if (email) authUrl.searchParams.set("login_hint", email);
240
-
241
479
  res.redirect(authUrl.toString());
242
480
  } catch (err) {
243
481
  console.error("[alphaclaw] Failed to start Google auth:", err);
244
- res.redirect("/setup?google=error&message=" + encodeURIComponent(err.message));
482
+ res.redirect(`/setup?google=error&message=${encodeURIComponent(err.message)}`);
245
483
  }
246
484
  });
247
485
 
248
486
  app.get("/auth/google/callback", async (req, res) => {
249
487
  const { code, error, state } = req.query;
250
- if (error) return res.redirect("/setup?google=error&message=" + encodeURIComponent(error));
488
+ if (error) return res.redirect(`/setup?google=error&message=${encodeURIComponent(error)}`);
251
489
  if (!code) return res.redirect("/setup?google=error&message=no_code");
252
490
 
253
491
  try {
254
- let email = "";
255
- try {
256
- const decoded = JSON.parse(Buffer.from(state, "base64url").toString());
257
- email = decoded.email || "";
258
- } catch {}
259
-
260
- const { clientId, clientSecret } = readGoogleCredentials();
492
+ const decodedState = parseJsonSafe(
493
+ Buffer.from(String(state || ""), "base64url").toString(),
494
+ {},
495
+ );
496
+ const accountId = String(decodedState.accountId || "").trim();
497
+ const requestedClient = String(decodedState.client || "").trim();
498
+ const stateData = readState();
499
+ const existingAccount = accountId
500
+ ? getGoogleAccountById(stateData, accountId)
501
+ : getGoogleAccountByEmailAndClient(
502
+ stateData,
503
+ String(decodedState.email || "").trim(),
504
+ requestedClient || kDefaultGoogleClient,
505
+ );
506
+ const client = existingAccount?.client || requestedClient || kDefaultGoogleClient;
507
+ const { clientId, clientSecret } = readGoogleCredentials(client);
508
+ if (!clientId || !clientSecret) {
509
+ throw new Error(`Google credentials missing for client "${client}"`);
510
+ }
261
511
  const redirectUri = `${getBaseUrl(req)}/auth/google/callback`;
262
-
263
512
  const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
264
513
  method: "POST",
265
514
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -271,42 +520,25 @@ const registerGoogleRoutes = ({
271
520
  grant_type: "authorization_code",
272
521
  }),
273
522
  });
274
-
275
523
  const tokens = await tokenRes.json();
276
524
  if (!tokenRes.ok || tokens.error) {
277
- console.log(
278
- `[alphaclaw] Google token exchange failed: status=${tokenRes.status} error=${tokens.error} desc=${tokens.error_description}`,
279
- );
280
- }
281
- if (tokens.error) {
282
- throw new Error(`Google token error: ${tokens.error_description || tokens.error}`);
525
+ throw new Error(`Google token error: ${tokens.error_description || tokens.error || "exchange_failed"}`);
283
526
  }
284
527
 
285
- if (!tokens.refresh_token) {
286
- let hasExisting = false;
287
- try {
288
- const stateData = JSON.parse(fs.readFileSync(GOG_STATE_PATH, "utf8"));
289
- hasExisting = stateData.authenticated;
290
- } catch {}
291
-
292
- if (hasExisting) {
293
- console.log(
294
- "[alphaclaw] No new refresh token (already authorized), keeping existing",
295
- );
296
- } else {
297
- throw new Error(
298
- "No refresh token received. Revoke app access at myaccount.google.com/permissions and retry.",
299
- );
300
- }
528
+ if (!tokens.refresh_token && !existingAccount?.authenticated) {
529
+ throw new Error(
530
+ "No refresh token received. Revoke app access at myaccount.google.com/permissions and retry.",
531
+ );
301
532
  }
302
533
 
534
+ let email = String(existingAccount?.email || decodedState.email || "").trim();
303
535
  if (!email && tokens.access_token) {
304
536
  try {
305
537
  const infoRes = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
306
538
  headers: { Authorization: `Bearer ${tokens.access_token}` },
307
539
  });
308
540
  const info = await infoRes.json();
309
- email = info.email || email;
541
+ email = String(info.email || "").trim();
310
542
  } catch {}
311
543
  }
312
544
 
@@ -314,73 +546,61 @@ const registerGoogleRoutes = ({
314
546
  const tokenFile = `/tmp/gog-token-${Date.now()}.json`;
315
547
  const tokenData = {
316
548
  email,
317
- client: "default",
549
+ client,
318
550
  created_at: new Date().toISOString(),
319
551
  refresh_token: tokens.refresh_token,
320
552
  };
321
553
  fs.writeFileSync(tokenFile, JSON.stringify(tokenData, null, 2));
322
- const result = await gogCmd(`auth tokens import ${tokenFile}`);
323
- if (result.ok) {
324
- console.log(`[alphaclaw] Google token imported for ${email}`);
325
- } else {
326
- console.error(`[alphaclaw] Token import failed: ${result.stderr}`);
327
- }
328
-
329
- if (!result.ok) {
330
- console.error("[alphaclaw] Token import failed, trying gog auth add --manual");
331
- const keyringDir = `${GOG_CONFIG_DIR}/keyring`;
332
- fs.mkdirSync(keyringDir, { recursive: true });
333
- fs.writeFileSync(
334
- `${keyringDir}/token-${email}.json`,
335
- JSON.stringify(tokenData, null, 2),
336
- );
337
- console.log(
338
- `[alphaclaw] Token written directly to keyring: ${keyringDir}/token-${email}.json`,
339
- );
340
- }
341
-
554
+ const importCmd =
555
+ client === kDefaultGoogleClient
556
+ ? `auth tokens import ${quoteShellArg(tokenFile)}`
557
+ : `--client ${quoteShellArg(client)} auth tokens import ${quoteShellArg(tokenFile)}`;
558
+ const result = await gogCmd(importCmd, { quiet: true });
342
559
  try {
343
560
  fs.unlinkSync(tokenFile);
344
561
  } catch {}
562
+ if (!result.ok) {
563
+ throw new Error(result.stderr || "Failed to import Google token");
564
+ }
345
565
  }
346
566
 
347
- let services = [];
348
- try {
349
- const decoded = JSON.parse(Buffer.from(state, "base64url").toString());
350
- services = decoded.services || [];
351
- } catch {}
352
-
567
+ const requestedServices = Array.isArray(decodedState.services)
568
+ ? decodedState.services
569
+ : [];
353
570
  const grantedServices = tokens.scope
354
571
  ? tokens.scope
355
572
  .split(" ")
356
- .map((s) => REVERSE_SCOPE_MAP[s])
573
+ .map((scope) => REVERSE_SCOPE_MAP[scope])
357
574
  .filter(Boolean)
358
- : services;
359
- console.log(
360
- `[alphaclaw] Requested: ${services.join(",")} → Granted: ${grantedServices.join(",")}`,
361
- );
362
-
363
- fs.writeFileSync(
364
- GOG_STATE_PATH,
365
- JSON.stringify({
575
+ : requestedServices;
576
+ const { state: nextState, account } = upsertGoogleAccount({
577
+ state: stateData,
578
+ maxAccounts: kMaxGoogleAccounts,
579
+ account: {
580
+ id: existingAccount?.id || accountId || createGoogleAccountId(),
366
581
  email,
367
- clientId,
368
- clientSecret,
369
- services: grantedServices,
582
+ personal: Boolean(existingAccount?.personal),
583
+ client,
584
+ services: grantedServices.length ? grantedServices : requestedServices,
370
585
  authenticated: true,
371
- }),
372
- );
586
+ },
587
+ });
588
+ saveState(nextState);
589
+ syncBootstrapTools(req);
373
590
 
591
+ const safeEmail = String(email || "").replace(/'/g, "\\'");
592
+ const safeAccountId = String(account.id || "").replace(/'/g, "\\'");
374
593
  res.send(`<!DOCTYPE html><html><body><script>
375
- window.opener?.postMessage({ google: 'success', email: '${email}' }, '*');
594
+ window.opener?.postMessage({ google: 'success', accountId: '${safeAccountId}', email: '${safeEmail}' }, '*');
376
595
  window.close();
377
596
  </script><p>Google connected! You can close this window.</p></body></html>`);
378
597
  } catch (err) {
379
598
  console.error("[alphaclaw] Google OAuth callback error:", err);
599
+ const safeMessage = String(err.message || "unknown_error").replace(/'/g, "\\'");
380
600
  res.send(`<!DOCTYPE html><html><body><script>
381
- window.opener?.postMessage({ google: 'error', message: '${err.message.replace(/'/g, "\\'")}' }, '*');
601
+ window.opener?.postMessage({ google: 'error', message: '${safeMessage}' }, '*');
382
602
  window.close();
383
- </script><p>Error: ${err.message}. You can close this window.</p></body></html>`);
603
+ </script><p>Error: ${safeMessage}. You can close this window.</p></body></html>`);
384
604
  }
385
605
  });
386
606
  };