@chrysb/alphaclaw 0.3.5-beta.0 → 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 (64) hide show
  1. package/bin/alphaclaw.js +66 -32
  2. package/lib/public/assets/icons/google_icon.svg +8 -0
  3. package/lib/public/css/explorer.css +254 -6
  4. package/lib/public/js/app.js +165 -100
  5. package/lib/public/js/components/channels.js +1 -0
  6. package/lib/public/js/components/credentials-modal.js +36 -8
  7. package/lib/public/js/components/file-tree.js +267 -88
  8. package/lib/public/js/components/file-viewer/constants.js +6 -0
  9. package/lib/public/js/components/file-viewer/diff-viewer.js +46 -0
  10. package/lib/public/js/components/file-viewer/editor-surface.js +120 -0
  11. package/lib/public/js/components/file-viewer/frontmatter-panel.js +56 -0
  12. package/lib/public/js/components/file-viewer/index.js +202 -0
  13. package/lib/public/js/components/file-viewer/markdown-split-view.js +51 -0
  14. package/lib/public/js/components/file-viewer/media-preview.js +44 -0
  15. package/lib/public/js/components/file-viewer/scroll-sync.js +95 -0
  16. package/lib/public/js/components/file-viewer/sqlite-viewer.js +167 -0
  17. package/lib/public/js/components/file-viewer/status-banners.js +64 -0
  18. package/lib/public/js/components/file-viewer/storage.js +58 -0
  19. package/lib/public/js/components/file-viewer/toolbar.js +119 -0
  20. package/lib/public/js/components/file-viewer/use-editor-selection-restore.js +93 -0
  21. package/lib/public/js/components/file-viewer/use-file-diff.js +60 -0
  22. package/lib/public/js/components/file-viewer/use-file-loader.js +312 -0
  23. package/lib/public/js/components/file-viewer/use-file-viewer-draft-sync.js +32 -0
  24. package/lib/public/js/components/file-viewer/use-file-viewer-hotkeys.js +25 -0
  25. package/lib/public/js/components/file-viewer/use-file-viewer.js +471 -0
  26. package/lib/public/js/components/file-viewer/utils.js +11 -0
  27. package/lib/public/js/components/gateway.js +83 -30
  28. package/lib/public/js/components/google/account-row.js +98 -0
  29. package/lib/public/js/components/google/add-account-modal.js +93 -0
  30. package/lib/public/js/components/google/index.js +439 -0
  31. package/lib/public/js/components/google/use-google-accounts.js +41 -0
  32. package/lib/public/js/components/icons.js +39 -0
  33. package/lib/public/js/components/sidebar-git-panel.js +115 -25
  34. package/lib/public/js/components/sidebar.js +91 -75
  35. package/lib/public/js/components/usage-tab.js +4 -1
  36. package/lib/public/js/components/watchdog-tab.js +6 -0
  37. package/lib/public/js/lib/api.js +88 -8
  38. package/lib/public/js/lib/browse-file-policies.js +52 -0
  39. package/lib/public/js/lib/syntax-highlighters/index.js +6 -5
  40. package/lib/public/shared/browse-file-policies.json +13 -0
  41. package/lib/scripts/git +40 -0
  42. package/lib/scripts/git-askpass +6 -0
  43. package/lib/server/constants.js +20 -0
  44. package/lib/server/google-state.js +187 -0
  45. package/lib/server/helpers.js +12 -4
  46. package/lib/server/onboarding/github.js +21 -2
  47. package/lib/server/onboarding/index.js +1 -3
  48. package/lib/server/onboarding/openclaw.js +3 -0
  49. package/lib/server/onboarding/workspace.js +40 -0
  50. package/lib/server/routes/browse/constants.js +51 -0
  51. package/lib/server/routes/browse/file-helpers.js +43 -0
  52. package/lib/server/routes/browse/git.js +131 -0
  53. package/lib/server/routes/browse/index.js +660 -0
  54. package/lib/server/routes/browse/path-utils.js +53 -0
  55. package/lib/server/routes/browse/sqlite.js +140 -0
  56. package/lib/server/routes/google.js +414 -213
  57. package/lib/server/routes/proxy.js +11 -5
  58. package/lib/setup/core-prompts/TOOLS.md +0 -4
  59. package/lib/setup/gitignore +3 -0
  60. package/lib/setup/hourly-git-sync.sh +28 -1
  61. package/package.json +1 -1
  62. package/lib/public/js/components/file-viewer.js +0 -1095
  63. package/lib/public/js/components/google.js +0 -228
  64. package/lib/server/routes/browse.js +0 -500
@@ -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,171 @@ 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
+ });
67
217
  });
68
218
 
69
219
  app.post("/api/google/credentials", async (req, res) => {
70
- const { clientId, clientSecret, email } = req.body;
220
+ const body = req.body || {};
221
+ const clientId = String(body.clientId || "").trim();
222
+ const clientSecret = String(body.clientSecret || "").trim();
223
+ const email = String(body.email || "").trim();
224
+ const accountId = String(body.accountId || "").trim();
225
+ const personal = Boolean(body.personal);
226
+ const client = String(body.client || (personal ? "personal" : kDefaultGoogleClient)).trim()
227
+ || kDefaultGoogleClient;
71
228
  if (!clientId || !clientSecret || !email) {
72
229
  return res.json({ ok: false, error: "Missing fields" });
73
230
  }
74
231
 
75
232
  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)}`);
233
+ const state = readState();
234
+ const existing = accountId ? getGoogleAccountById(state, accountId) : null;
235
+ const legacyClientsForEmail = listGoogleAccounts(state)
236
+ .filter(
237
+ (entry) =>
238
+ String(entry.email || "").trim().toLowerCase() ===
239
+ email.toLowerCase(),
240
+ )
241
+ .map((entry) => String(entry.client || kDefaultGoogleClient).trim());
242
+ await clearStoredGoogleAuthForEmail({
243
+ email,
244
+ preferredClient: client,
245
+ extraClients: [
246
+ ...legacyClientsForEmail,
247
+ String(existing?.client || "").trim(),
248
+ ],
249
+ });
250
+ const credentialsPath = ensureClientCredentials({
251
+ client,
252
+ clientId,
253
+ clientSecret,
254
+ req,
255
+ });
256
+ const command = client === kDefaultGoogleClient
257
+ ? `auth credentials set ${quoteShellArg(credentialsPath)}`
258
+ : `--client ${quoteShellArg(client)} auth credentials set ${quoteShellArg(credentialsPath)}`;
259
+ const result = await gogCmd(command, { quiet: true });
260
+ if (!result.ok) {
261
+ throw new Error(result.stderr || "Failed to set Google client credentials");
262
+ }
90
263
 
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 }));
264
+ const { state: nextState, account } = upsertGoogleAccount({
265
+ state,
266
+ maxAccounts: kMaxGoogleAccounts,
267
+ account: {
268
+ id: existing?.id || accountId || createGoogleAccountId(),
269
+ email,
270
+ personal,
271
+ client,
272
+ services: body.services || existing?.services || kDefaultGoogleScopes,
273
+ authenticated: false,
274
+ },
275
+ });
276
+ saveState(nextState);
277
+ syncBootstrapTools(req);
100
278
 
101
- res.json({ ok: true });
279
+ res.json({ ok: true, accountId: account.id, account });
102
280
  } catch (err) {
103
281
  console.error("[alphaclaw] Failed to save Google credentials:", err);
104
282
  res.json({ ok: false, error: err.message });
105
283
  }
106
284
  });
107
285
 
108
- app.get("/api/google/check", async (req, res) => {
109
- let email = "";
110
- let activeScopes = [];
286
+ app.post("/api/google/accounts", (req, res) => {
287
+ const body = req.body || {};
288
+ const email = String(body.email || "").trim();
289
+ const accountId = String(body.accountId || "").trim();
290
+ const personal = Boolean(body.personal);
291
+ const client = String(body.client || (personal ? "personal" : kDefaultGoogleClient)).trim()
292
+ || kDefaultGoogleClient;
293
+ if (!email) {
294
+ return res.json({ ok: false, error: "Missing fields" });
295
+ }
296
+ if (!fs.existsSync(gogClientCredentialsPath(client))) {
297
+ return res.json({
298
+ ok: false,
299
+ error: "Credentials missing for selected client. Save credentials first.",
300
+ });
301
+ }
111
302
  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" });
303
+ const state = readState();
304
+ const existing = accountId ? getGoogleAccountById(state, accountId) : null;
305
+ const { state: nextState, account } = upsertGoogleAccount({
306
+ state,
307
+ maxAccounts: kMaxGoogleAccounts,
308
+ account: {
309
+ id: existing?.id || accountId || createGoogleAccountId(),
310
+ email,
311
+ personal,
312
+ client,
313
+ services: body.services || existing?.services || kDefaultGoogleScopes,
314
+ authenticated: Boolean(existing?.authenticated),
315
+ },
316
+ });
317
+ saveState(nextState);
318
+ syncBootstrapTools(req);
319
+ res.json({ ok: true, accountId: account.id, account });
320
+ } catch (err) {
321
+ res.json({ ok: false, error: err.message });
322
+ }
323
+ });
117
324
 
118
- const enabledServices = activeScopes
119
- .map((s) => s.split(":")[0])
120
- .filter((v, i, a) => a.indexOf(v) === i);
325
+ app.get("/api/google/check", async (req, res) => {
326
+ const state = readState();
327
+ const account = getSelectedAccount({
328
+ state,
329
+ accountId: String(req.query.accountId || ""),
330
+ fallbackToFirst: true,
331
+ });
332
+ if (!account) return res.json({ error: "No Google account configured" });
333
+
334
+ const enabledServices = uniqueServiceLabels(account.services || []);
121
335
  const results = {};
122
-
123
336
  for (const svc of enabledServices) {
124
337
  const cmd = API_TEST_COMMANDS[svc];
125
338
  if (!cmd) continue;
126
-
127
- const result = await gogCmd(`${cmd} --account ${email}`, { quiet: true });
339
+ const clientArg =
340
+ account.client === kDefaultGoogleClient
341
+ ? ""
342
+ : `--client ${quoteShellArg(account.client)} `;
343
+ const result = await gogCmd(
344
+ `${clientArg}${cmd} --account ${quoteShellArg(account.email)}`,
345
+ { quiet: true },
346
+ );
128
347
  const stderr = result.stderr || "";
129
348
  if (stderr.includes("has not been used") || stderr.includes("is not enabled")) {
130
349
  const projectMatch = stderr.match(/project=(\d+)/);
@@ -135,7 +354,6 @@ const registerGoogleRoutes = ({
135
354
  } else if (result.ok || stderr.includes("not found") || stderr.includes("Not Found")) {
136
355
  results[svc] = { status: "ok", enableUrl: getApiEnableUrl(svc) };
137
356
  } else {
138
- console.log(`[alphaclaw] API check ${svc} error: ${result.stderr?.slice(0, 300)}`);
139
357
  results[svc] = {
140
358
  status: "error",
141
359
  message: result.stderr?.slice(0, 200),
@@ -143,58 +361,47 @@ const registerGoogleRoutes = ({
143
361
  };
144
362
  }
145
363
  }
146
-
147
- res.json({ email, results });
364
+ res.json({ accountId: account.id, email: account.email, results });
148
365
  });
149
366
 
150
367
  app.post("/api/google/disconnect", async (req, res) => {
368
+ const accountId = String(req.body?.accountId || "").trim();
369
+ const state = readState();
370
+ const account = getSelectedAccount({ state, accountId, fallbackToFirst: true });
371
+ if (!account) return res.json({ ok: true });
151
372
  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]) {
373
+ const revokeFile = `/tmp/gog-revoke-${Date.now()}.json`;
374
+ const clientArg =
375
+ account.client === kDefaultGoogleClient
376
+ ? ""
377
+ : `--client ${quoteShellArg(account.client)} `;
378
+ const exportResult = await gogCmd(
379
+ `${clientArg}auth tokens export ${quoteShellArg(account.email)} --out ${quoteShellArg(revokeFile)} --overwrite`,
380
+ { quiet: true },
381
+ );
382
+ if (exportResult.ok && fs.existsSync(revokeFile)) {
179
383
  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}`);
384
+ const tokenData = parseJsonSafe(fs.readFileSync(revokeFile, "utf8"), {});
385
+ if (tokenData.refresh_token) {
386
+ await fetch(`https://oauth2.googleapis.com/revoke?token=${tokenData.refresh_token}`, {
387
+ method: "POST",
388
+ });
185
389
  }
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
- );
390
+ } catch {}
195
391
  }
196
-
197
- console.log(`[alphaclaw] Google disconnected: ${email}`);
392
+ try {
393
+ fs.unlinkSync(revokeFile);
394
+ } catch {}
395
+ await gogCmd(
396
+ `${clientArg}auth remove ${quoteShellArg(account.email)} --force`,
397
+ { quiet: true },
398
+ );
399
+ const { state: nextState } = removeGoogleAccount({
400
+ state,
401
+ accountId: account.id,
402
+ });
403
+ saveState(nextState);
404
+ syncBootstrapTools(req);
198
405
  res.json({ ok: true });
199
406
  } catch (err) {
200
407
  console.error("[alphaclaw] Google disconnect error:", err);
@@ -203,31 +410,44 @@ const registerGoogleRoutes = ({
203
410
  });
204
411
 
205
412
  app.get("/auth/google/start", (req, res) => {
206
- const email = req.query.email || "";
413
+ const state = readState();
414
+ const requestedAccountId = String(req.query.accountId || "").trim();
415
+ const requestedClient = String(req.query.client || "").trim();
416
+ let account = requestedAccountId
417
+ ? getGoogleAccountById(state, requestedAccountId)
418
+ : null;
419
+ if (!account && req.query.email) {
420
+ account = getGoogleAccountByEmailAndClient(
421
+ state,
422
+ String(req.query.email || "").trim(),
423
+ requestedClient || kDefaultGoogleClient,
424
+ );
425
+ }
426
+ const client = account?.client || requestedClient || kDefaultGoogleClient;
427
+ const email = account?.email || String(req.query.email || "").trim();
207
428
  const services = (
208
429
  req.query.services ||
209
- "gmail:read,calendar:read,calendar:write,drive:read,sheets:read,docs:read"
430
+ (account?.services || kDefaultGoogleScopes).join(",")
210
431
  )
211
432
  .split(",")
433
+ .map((scope) => String(scope || "").trim())
212
434
  .filter(Boolean);
213
-
214
435
  try {
215
- const { clientId } = readGoogleCredentials();
436
+ const { clientId } = readGoogleCredentials(client);
216
437
  if (!clientId) throw new Error("No client_id found");
217
-
218
438
  const scopes = [
219
439
  ...BASE_SCOPES,
220
- ...services.map((s) => SCOPE_MAP[s]).filter(Boolean),
440
+ ...services.map((scope) => SCOPE_MAP[scope]).filter(Boolean),
221
441
  ].join(" ");
222
- console.log(
223
- `[alphaclaw] Google OAuth scopes: services=${services.join(",")} resolved=${scopes}`,
224
- );
225
-
226
442
  const redirectUri = `${getBaseUrl(req)}/auth/google/callback`;
227
- const state = Buffer.from(JSON.stringify({ email, services })).toString(
228
- "base64url",
229
- );
230
-
443
+ const encodedState = Buffer.from(
444
+ JSON.stringify({
445
+ accountId: account?.id || requestedAccountId || "",
446
+ client,
447
+ email,
448
+ services,
449
+ }),
450
+ ).toString("base64url");
231
451
  const authUrl = new URL("https://accounts.google.com/o/oauth2/auth");
232
452
  authUrl.searchParams.set("client_id", clientId);
233
453
  authUrl.searchParams.set("redirect_uri", redirectUri);
@@ -235,31 +455,41 @@ const registerGoogleRoutes = ({
235
455
  authUrl.searchParams.set("scope", scopes);
236
456
  authUrl.searchParams.set("access_type", "offline");
237
457
  authUrl.searchParams.set("prompt", "consent");
238
- authUrl.searchParams.set("state", state);
458
+ authUrl.searchParams.set("state", encodedState);
239
459
  if (email) authUrl.searchParams.set("login_hint", email);
240
-
241
460
  res.redirect(authUrl.toString());
242
461
  } catch (err) {
243
462
  console.error("[alphaclaw] Failed to start Google auth:", err);
244
- res.redirect("/setup?google=error&message=" + encodeURIComponent(err.message));
463
+ res.redirect(`/setup?google=error&message=${encodeURIComponent(err.message)}`);
245
464
  }
246
465
  });
247
466
 
248
467
  app.get("/auth/google/callback", async (req, res) => {
249
468
  const { code, error, state } = req.query;
250
- if (error) return res.redirect("/setup?google=error&message=" + encodeURIComponent(error));
469
+ if (error) return res.redirect(`/setup?google=error&message=${encodeURIComponent(error)}`);
251
470
  if (!code) return res.redirect("/setup?google=error&message=no_code");
252
471
 
253
472
  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();
473
+ const decodedState = parseJsonSafe(
474
+ Buffer.from(String(state || ""), "base64url").toString(),
475
+ {},
476
+ );
477
+ const accountId = String(decodedState.accountId || "").trim();
478
+ const requestedClient = String(decodedState.client || "").trim();
479
+ const stateData = readState();
480
+ const existingAccount = accountId
481
+ ? getGoogleAccountById(stateData, accountId)
482
+ : getGoogleAccountByEmailAndClient(
483
+ stateData,
484
+ String(decodedState.email || "").trim(),
485
+ requestedClient || kDefaultGoogleClient,
486
+ );
487
+ const client = existingAccount?.client || requestedClient || kDefaultGoogleClient;
488
+ const { clientId, clientSecret } = readGoogleCredentials(client);
489
+ if (!clientId || !clientSecret) {
490
+ throw new Error(`Google credentials missing for client "${client}"`);
491
+ }
261
492
  const redirectUri = `${getBaseUrl(req)}/auth/google/callback`;
262
-
263
493
  const tokenRes = await fetch("https://oauth2.googleapis.com/token", {
264
494
  method: "POST",
265
495
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
@@ -271,42 +501,25 @@ const registerGoogleRoutes = ({
271
501
  grant_type: "authorization_code",
272
502
  }),
273
503
  });
274
-
275
504
  const tokens = await tokenRes.json();
276
505
  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}`);
506
+ throw new Error(`Google token error: ${tokens.error_description || tokens.error || "exchange_failed"}`);
283
507
  }
284
508
 
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
- }
509
+ if (!tokens.refresh_token && !existingAccount?.authenticated) {
510
+ throw new Error(
511
+ "No refresh token received. Revoke app access at myaccount.google.com/permissions and retry.",
512
+ );
301
513
  }
302
514
 
515
+ let email = String(existingAccount?.email || decodedState.email || "").trim();
303
516
  if (!email && tokens.access_token) {
304
517
  try {
305
518
  const infoRes = await fetch("https://www.googleapis.com/oauth2/v2/userinfo", {
306
519
  headers: { Authorization: `Bearer ${tokens.access_token}` },
307
520
  });
308
521
  const info = await infoRes.json();
309
- email = info.email || email;
522
+ email = String(info.email || "").trim();
310
523
  } catch {}
311
524
  }
312
525
 
@@ -314,73 +527,61 @@ const registerGoogleRoutes = ({
314
527
  const tokenFile = `/tmp/gog-token-${Date.now()}.json`;
315
528
  const tokenData = {
316
529
  email,
317
- client: "default",
530
+ client,
318
531
  created_at: new Date().toISOString(),
319
532
  refresh_token: tokens.refresh_token,
320
533
  };
321
534
  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
-
535
+ const importCmd =
536
+ client === kDefaultGoogleClient
537
+ ? `auth tokens import ${quoteShellArg(tokenFile)}`
538
+ : `--client ${quoteShellArg(client)} auth tokens import ${quoteShellArg(tokenFile)}`;
539
+ const result = await gogCmd(importCmd, { quiet: true });
342
540
  try {
343
541
  fs.unlinkSync(tokenFile);
344
542
  } catch {}
543
+ if (!result.ok) {
544
+ throw new Error(result.stderr || "Failed to import Google token");
545
+ }
345
546
  }
346
547
 
347
- let services = [];
348
- try {
349
- const decoded = JSON.parse(Buffer.from(state, "base64url").toString());
350
- services = decoded.services || [];
351
- } catch {}
352
-
548
+ const requestedServices = Array.isArray(decodedState.services)
549
+ ? decodedState.services
550
+ : [];
353
551
  const grantedServices = tokens.scope
354
552
  ? tokens.scope
355
553
  .split(" ")
356
- .map((s) => REVERSE_SCOPE_MAP[s])
554
+ .map((scope) => REVERSE_SCOPE_MAP[scope])
357
555
  .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({
556
+ : requestedServices;
557
+ const { state: nextState, account } = upsertGoogleAccount({
558
+ state: stateData,
559
+ maxAccounts: kMaxGoogleAccounts,
560
+ account: {
561
+ id: existingAccount?.id || accountId || createGoogleAccountId(),
366
562
  email,
367
- clientId,
368
- clientSecret,
369
- services: grantedServices,
563
+ personal: Boolean(existingAccount?.personal),
564
+ client,
565
+ services: grantedServices.length ? grantedServices : requestedServices,
370
566
  authenticated: true,
371
- }),
372
- );
567
+ },
568
+ });
569
+ saveState(nextState);
570
+ syncBootstrapTools(req);
373
571
 
572
+ const safeEmail = String(email || "").replace(/'/g, "\\'");
573
+ const safeAccountId = String(account.id || "").replace(/'/g, "\\'");
374
574
  res.send(`<!DOCTYPE html><html><body><script>
375
- window.opener?.postMessage({ google: 'success', email: '${email}' }, '*');
575
+ window.opener?.postMessage({ google: 'success', accountId: '${safeAccountId}', email: '${safeEmail}' }, '*');
376
576
  window.close();
377
577
  </script><p>Google connected! You can close this window.</p></body></html>`);
378
578
  } catch (err) {
379
579
  console.error("[alphaclaw] Google OAuth callback error:", err);
580
+ const safeMessage = String(err.message || "unknown_error").replace(/'/g, "\\'");
380
581
  res.send(`<!DOCTYPE html><html><body><script>
381
- window.opener?.postMessage({ google: 'error', message: '${err.message.replace(/'/g, "\\'")}' }, '*');
582
+ window.opener?.postMessage({ google: 'error', message: '${safeMessage}' }, '*');
382
583
  window.close();
383
- </script><p>Error: ${err.message}. You can close this window.</p></body></html>`);
584
+ </script><p>Error: ${safeMessage}. You can close this window.</p></body></html>`);
384
585
  }
385
586
  });
386
587
  };