-
Notifications
You must be signed in to change notification settings - Fork 13
feat(kiloclaw): Google account access for bots #949
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a0dbd93
349f2ef
bfa37c7
95f953f
2e267e9
707dc67
4369257
e61fed7
4cd62bd
f6fc259
db03f1d
ec12793
06a1b00
a5949a4
6f75aa9
1291d2a
eac53f6
e4f53aa
4dd8c23
fdeffbc
bee01dd
223990c
49ad632
dc0ec6c
550f00c
17fe88b
7ee4c5e
d97e2d6
f1e5a4f
1c4a1de
b01436d
f20513b
95e4954
520b96d
93781fe
4ef1e23
23593af
47a4cfa
261d102
a5f27cc
343c7c2
99ea1d7
74b0cce
3c92b09
b5e4590
1532c97
5fb3d95
c749aa1
ecdbb0b
0c33a65
d45fa5e
7295137
e816c6a
4c1f0b9
8ab25f3
d52cee5
7d89bc4
aa48c6d
1244e47
3094457
3768361
4e3017b
7293589
a6aebd2
7ec9787
585482f
3232736
9858240
1fa8a4c
2c879bb
f7607e1
2dead7b
dde26fc
e26c3d3
7dced68
f3ddeeb
fff5bb5
3fe8171
e4a0045
9c862f0
5e25825
48e67b6
38c93e9
04409a9
94a96d8
0a9295f
a004979
3c31fca
9c04098
b2b22b8
643a470
a57b4d4
bf8ce1d
9bfac3b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
|
|
||
| function mockDeps() { | ||
| return { | ||
| mkdirSync: vi.fn(), | ||
| writeFileSync: vi.fn(), | ||
| unlinkSync: vi.fn(), | ||
| rmSync: vi.fn(), | ||
| execFileSync: vi.fn(), | ||
| }; | ||
| } | ||
|
|
||
| // A tiny valid .tar.gz base64 — content doesn't matter for unit tests since execSync is mocked | ||
| const FAKE_TARBALL_BASE64 = Buffer.from('fake-tarball-data').toString('base64'); | ||
|
|
||
| describe('writeGogCredentials', () => { | ||
| let writeGogCredentials: typeof import('./gog-credentials').writeGogCredentials; | ||
|
|
||
| beforeEach(async () => { | ||
| vi.resetModules(); | ||
| const mod = await import('./gog-credentials'); | ||
| writeGogCredentials = mod.writeGogCredentials; | ||
| }); | ||
|
|
||
| it('extracts tarball and sets env vars when GOOGLE_GOG_CONFIG_TARBALL is set', async () => { | ||
| const deps = mockDeps(); | ||
| const dir = '/root/.config/gogcli'; | ||
| const env: Record<string, string | undefined> = { | ||
| GOOGLE_GOG_CONFIG_TARBALL: FAKE_TARBALL_BASE64, | ||
| GOOGLE_ACCOUNT_EMAIL: 'user@gmail.com', | ||
| }; | ||
| const result = await writeGogCredentials(env, dir, deps); | ||
|
|
||
| expect(result).toBe(true); | ||
| // Should remove stale config before extracting | ||
| expect(deps.rmSync).toHaveBeenCalledWith(dir, { recursive: true, force: true }); | ||
| expect(deps.mkdirSync).toHaveBeenCalledWith('/root/.config', { recursive: true }); | ||
|
|
||
| // Should write temp tarball file | ||
| expect(deps.writeFileSync).toHaveBeenCalledWith( | ||
| '/root/.config/gogcli-config.tar.gz', | ||
| Buffer.from(FAKE_TARBALL_BASE64, 'base64') | ||
| ); | ||
|
|
||
| expect(deps.execFileSync).toHaveBeenCalledWith('tar', [ | ||
| 'xzf', | ||
| '/root/.config/gogcli-config.tar.gz', | ||
| '-C', | ||
| '/root/.config', | ||
| ]); | ||
|
|
||
| // Should clean up temp tarball | ||
| expect(deps.unlinkSync).toHaveBeenCalledWith('/root/.config/gogcli-config.tar.gz'); | ||
|
|
||
| // Should set gog env vars | ||
| expect(env.GOG_KEYRING_BACKEND).toBe('file'); | ||
| expect(env.GOG_KEYRING_PASSWORD).toBe('kiloclaw'); | ||
| expect(env.GOG_ACCOUNT).toBe('user@gmail.com'); | ||
| }); | ||
|
|
||
| it('works without GOOGLE_ACCOUNT_EMAIL', async () => { | ||
| const deps = mockDeps(); | ||
| const env: Record<string, string | undefined> = { | ||
| GOOGLE_GOG_CONFIG_TARBALL: FAKE_TARBALL_BASE64, | ||
| }; | ||
| const result = await writeGogCredentials(env, '/root/.config/gogcli', deps); | ||
|
|
||
| expect(result).toBe(true); | ||
| expect(env.GOG_KEYRING_BACKEND).toBe('file'); | ||
| expect(env.GOG_KEYRING_PASSWORD).toBe('kiloclaw'); | ||
| expect(env.GOG_ACCOUNT).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('returns false and cleans up when tarball env var is absent', async () => { | ||
| const deps = mockDeps(); | ||
| const dir = '/root/.config/gogcli'; | ||
| const result = await writeGogCredentials({}, dir, deps); | ||
|
|
||
| expect(result).toBe(false); | ||
| expect(deps.rmSync).toHaveBeenCalledWith(dir, { recursive: true, force: true }); | ||
| expect(deps.mkdirSync).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('clears gog env vars when tarball env var is absent', async () => { | ||
| const deps = mockDeps(); | ||
| const env: Record<string, string | undefined> = { | ||
| GOG_KEYRING_BACKEND: 'file', | ||
| GOG_KEYRING_PASSWORD: 'kiloclaw', | ||
| GOG_ACCOUNT: 'user@gmail.com', | ||
| }; | ||
| await writeGogCredentials(env, '/root/.config/gogcli', deps); | ||
|
|
||
| expect(env.GOG_KEYRING_BACKEND).toBeUndefined(); | ||
| expect(env.GOG_KEYRING_PASSWORD).toBeUndefined(); | ||
| expect(env.GOG_ACCOUNT).toBeUndefined(); | ||
| }); | ||
|
|
||
| it('removes existing config dir before extracting new tarball', async () => { | ||
| const deps = mockDeps(); | ||
| const callOrder: string[] = []; | ||
| deps.rmSync.mockImplementation(() => callOrder.push('rmSync')); | ||
| deps.mkdirSync.mockImplementation(() => callOrder.push('mkdirSync')); | ||
| deps.execFileSync.mockImplementation(() => callOrder.push('execFileSync')); | ||
|
|
||
| const env: Record<string, string | undefined> = { | ||
| GOOGLE_GOG_CONFIG_TARBALL: FAKE_TARBALL_BASE64, | ||
| }; | ||
| await writeGogCredentials(env, '/root/.config/gogcli', deps); | ||
|
|
||
| expect(callOrder).toEqual(['rmSync', 'mkdirSync', 'execFileSync']); | ||
| }); | ||
|
|
||
| it('cleans up temp tarball even if extraction fails', async () => { | ||
| const deps = mockDeps(); | ||
| deps.execFileSync.mockImplementation(() => { | ||
| throw new Error('tar failed'); | ||
| }); | ||
|
|
||
| const env: Record<string, string | undefined> = { | ||
| GOOGLE_GOG_CONFIG_TARBALL: FAKE_TARBALL_BASE64, | ||
| }; | ||
|
|
||
| await expect(writeGogCredentials(env, '/root/.config/gogcli', deps)).rejects.toThrow( | ||
| 'tar failed' | ||
| ); | ||
| expect(deps.unlinkSync).toHaveBeenCalledWith('/root/.config/gogcli-config.tar.gz'); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| /** | ||
| * Sets up gogcli credentials by extracting a pre-built config tarball. | ||
| * | ||
| * When the container starts with GOOGLE_GOG_CONFIG_TARBALL env var, this module: | ||
| * 1. Base64-decodes the tarball to a temp file | ||
| * 2. Extracts it to /root/.config/ (produces /root/.config/gogcli/) | ||
| * 3. Sets GOG_KEYRING_BACKEND, GOG_KEYRING_PASSWORD, GOG_ACCOUNT env vars | ||
| */ | ||
| import path from 'node:path'; | ||
|
|
||
| const GOG_CONFIG_DIR = '/root/.config/gogcli'; | ||
|
|
||
| export type GogCredentialsDeps = { | ||
| mkdirSync: (dir: string, opts: { recursive: boolean }) => void; | ||
| writeFileSync: (path: string, data: Buffer) => void; | ||
| unlinkSync: (path: string) => void; | ||
| rmSync: (path: string, opts: { recursive: boolean; force: boolean }) => void; | ||
| execFileSync: (file: string, args: string[]) => void; | ||
| }; | ||
|
|
||
| /** | ||
| * Extract gog config tarball if the corresponding env var is set. | ||
| * Returns true if credentials were extracted, false if skipped. | ||
| * | ||
| * Side effect: mutates the passed `env` record by setting | ||
| * GOG_KEYRING_BACKEND, GOG_KEYRING_PASSWORD, and GOG_ACCOUNT. | ||
| */ | ||
| export async function writeGogCredentials( | ||
| env: Record<string, string | undefined> = process.env as Record<string, string | undefined>, | ||
| configDir = GOG_CONFIG_DIR, | ||
| deps?: Partial<GogCredentialsDeps> | ||
| ): Promise<boolean> { | ||
| const fs = await import('node:fs'); | ||
| const cp = await import('node:child_process'); | ||
| const d: GogCredentialsDeps = { | ||
| mkdirSync: deps?.mkdirSync ?? ((dir, opts) => fs.default.mkdirSync(dir, opts)), | ||
| writeFileSync: deps?.writeFileSync ?? ((p, data) => fs.default.writeFileSync(p, data)), | ||
| unlinkSync: deps?.unlinkSync ?? (p => fs.default.unlinkSync(p)), | ||
| rmSync: deps?.rmSync ?? ((p, opts) => fs.default.rmSync(p, opts)), | ||
| execFileSync: | ||
| deps?.execFileSync ?? | ||
| ((file, args) => | ||
| cp.default.execFileSync(file, args, { | ||
| stdio: ['pipe', 'pipe', 'pipe'], | ||
| })), | ||
| }; | ||
|
|
||
| const tarballBase64 = env.GOOGLE_GOG_CONFIG_TARBALL; | ||
|
|
||
| if (!tarballBase64) { | ||
| // Clean up stale config from a previous run (e.g. after disconnect) | ||
| d.rmSync(configDir, { recursive: true, force: true }); | ||
| delete env.GOG_KEYRING_BACKEND; | ||
| delete env.GOG_KEYRING_PASSWORD; | ||
| delete env.GOG_ACCOUNT; | ||
| return false; | ||
| } | ||
|
|
||
| // Remove stale config from a previous connection before extracting the new bundle. | ||
| // Without this, files present in the old tarball but absent from the new one linger. | ||
| d.rmSync(configDir, { recursive: true, force: true }); | ||
|
|
||
| // Decode tarball and extract to /root/.config/ | ||
| const parentDir = path.dirname(configDir); | ||
| d.mkdirSync(parentDir, { recursive: true }); | ||
iscekic marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| const tarballBuffer = Buffer.from(tarballBase64, 'base64'); | ||
|
|
||
| const tmpTarball = path.join(parentDir, 'gogcli-config.tar.gz'); | ||
| d.writeFileSync(tmpTarball, tarballBuffer); | ||
|
|
||
| try { | ||
| d.execFileSync('tar', ['xzf', tmpTarball, '-C', parentDir]); | ||
| console.log(`[gog] Extracted config tarball to ${configDir}`); | ||
| } finally { | ||
| try { | ||
| d.unlinkSync(tmpTarball); | ||
| } catch { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. WARNING: Temp credential archive cleanup hides real failures This |
||
| // ignore cleanup errors | ||
| } | ||
| } | ||
|
|
||
| // Set env vars for gog runtime. | ||
| // GOG_KEYRING_PASSWORD is NOT a secret. The 99designs/keyring file backend | ||
| // requires a password to operate, but gog runs inside a single-tenant VM | ||
| // with no shared access. The value is arbitrary — it just needs to be | ||
| // consistent across setup (google-setup/setup.mjs), container startup | ||
| // (start-openclaw.sh), and here. | ||
| env.GOG_KEYRING_BACKEND = 'file'; | ||
| env.GOG_KEYRING_PASSWORD = 'kiloclaw'; | ||
| if (env.GOOGLE_ACCOUNT_EMAIL) { | ||
| env.GOG_ACCOUNT = env.GOOGLE_ACCOUNT_EMAIL; | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.