Skip to content

Commit d522365

Browse files
committed
feat(kiloclaw): add GitHub machine user to secret catalog
1 parent 24254cc commit d522365

11 files changed

Lines changed: 261 additions & 11 deletions

File tree

kiloclaw/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ Before submitting any change:
172172
2. Update tests in the same PR -- do not defer
173173
3. Do not reintroduce optional `userId` or `sandboxId` parameters (they are always required)
174174
4. If changing `start-openclaw.sh`, bump the cache bust in the Dockerfile
175+
5. If adding or changing user-facing features, add a changelog entry to `src/app/(app)/claw/components/changelog-data.ts` (newest first)
175176

176177
## Test Targets by Change Type
177178

kiloclaw/Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -110,8 +110,8 @@ RUN mkdir -p /root/.openclaw \
110110
&& mkdir -p /root/clawd/skills
111111

112112
# Copy startup script
113-
# Build cache bust: 2026-03-11-v61-kilo-cli
114-
RUN echo "9"
113+
# Build cache bust: 2026-03-12-v62-github-machine-user
114+
RUN echo "10"
115115
COPY start-openclaw.sh /usr/local/bin/start-openclaw.sh
116116
COPY openclaw-pairing-list.js /usr/local/bin/openclaw-pairing-list.js
117117
COPY openclaw-device-pairing-list.js /usr/local/bin/openclaw-device-pairing-list.js

kiloclaw/packages/secret-catalog/src/__tests__/catalog.test.ts

Lines changed: 95 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ describe('Secret Catalog', () => {
3737

3838
describe('Icon validation', () => {
3939
it('all icon values are valid SecretIconKey members', () => {
40-
const validIcons: Set<SecretIconKey> = new Set(['send', 'discord', 'slack', 'key']);
40+
const validIcons: Set<SecretIconKey> = new Set(['send', 'discord', 'slack', 'key', 'github']);
4141
for (const entry of SECRET_CATALOG) {
4242
expect(validIcons.has(entry.icon)).toBe(true);
4343
}
@@ -108,6 +108,9 @@ describe('Secret Catalog', () => {
108108
'DISCORD_BOT_TOKEN',
109109
'SLACK_BOT_TOKEN',
110110
'SLACK_APP_TOKEN',
111+
'GITHUB_TOKEN',
112+
'GITHUB_USERNAME',
113+
'GITHUB_EMAIL',
111114
]);
112115

113116
const catalogEnvVars = new Set(FIELD_KEY_TO_ENV_VAR.values());
@@ -122,6 +125,9 @@ describe('Secret Catalog', () => {
122125
expect(FIELD_KEY_TO_ENV_VAR.get('discordBotToken')).toBe('DISCORD_BOT_TOKEN');
123126
expect(FIELD_KEY_TO_ENV_VAR.get('slackBotToken')).toBe('SLACK_BOT_TOKEN');
124127
expect(FIELD_KEY_TO_ENV_VAR.get('slackAppToken')).toBe('SLACK_APP_TOKEN');
128+
expect(FIELD_KEY_TO_ENV_VAR.get('githubToken')).toBe('GITHUB_TOKEN');
129+
expect(FIELD_KEY_TO_ENV_VAR.get('githubUsername')).toBe('GITHUB_USERNAME');
130+
expect(FIELD_KEY_TO_ENV_VAR.get('githubEmail')).toBe('GITHUB_EMAIL');
125131
});
126132

127133
it('ENV_VAR_TO_FIELD_KEY is the exact reverse of FIELD_KEY_TO_ENV_VAR', () => {
@@ -136,6 +142,9 @@ describe('Secret Catalog', () => {
136142
expect(ENV_VAR_TO_FIELD_KEY.get('DISCORD_BOT_TOKEN')).toBe('discordBotToken');
137143
expect(ENV_VAR_TO_FIELD_KEY.get('SLACK_BOT_TOKEN')).toBe('slackBotToken');
138144
expect(ENV_VAR_TO_FIELD_KEY.get('SLACK_APP_TOKEN')).toBe('slackAppToken');
145+
expect(ENV_VAR_TO_FIELD_KEY.get('GITHUB_TOKEN')).toBe('githubToken');
146+
expect(ENV_VAR_TO_FIELD_KEY.get('GITHUB_USERNAME')).toBe('githubUsername');
147+
expect(ENV_VAR_TO_FIELD_KEY.get('GITHUB_EMAIL')).toBe('githubEmail');
139148
});
140149
});
141150

@@ -160,6 +169,9 @@ describe('Secret Catalog', () => {
160169
expect(FIELD_KEY_TO_ENTRY.get('discordBotToken')?.id).toBe('discord');
161170
expect(FIELD_KEY_TO_ENTRY.get('slackBotToken')?.id).toBe('slack');
162171
expect(FIELD_KEY_TO_ENTRY.get('slackAppToken')?.id).toBe('slack');
172+
expect(FIELD_KEY_TO_ENTRY.get('githubToken')?.id).toBe('github');
173+
expect(FIELD_KEY_TO_ENTRY.get('githubUsername')?.id).toBe('github');
174+
expect(FIELD_KEY_TO_ENTRY.get('githubEmail')?.id).toBe('github');
163175
});
164176
});
165177

@@ -172,9 +184,15 @@ describe('Secret Catalog', () => {
172184
expect(channels[2].id).toBe('slack');
173185
});
174186

175-
it('returns empty array for categories with no entries', () => {
187+
it('returns all tool entries sorted by order', () => {
176188
const tools = getEntriesByCategory('tool');
177-
expect(tools).toEqual([]);
189+
expect(tools.length).toBe(1);
190+
expect(tools[0].id).toBe('github');
191+
});
192+
193+
it('returns empty array for categories with no entries', () => {
194+
const providers = getEntriesByCategory('provider');
195+
expect(providers).toEqual([]);
178196
});
179197
});
180198

@@ -188,8 +206,16 @@ describe('Secret Catalog', () => {
188206
expect(keys.size).toBe(4);
189207
});
190208

191-
it('returns empty set for categories with no entries', () => {
209+
it('returns all tool field keys', () => {
192210
const keys = getFieldKeysByCategory('tool');
211+
expect(keys).toContain('githubToken');
212+
expect(keys).toContain('githubUsername');
213+
expect(keys).toContain('githubEmail');
214+
expect(keys.size).toBe(3);
215+
});
216+
217+
it('returns empty set for categories with no entries', () => {
218+
const keys = getFieldKeysByCategory('provider');
193219
expect(keys.size).toBe(0);
194220
});
195221
});
@@ -272,6 +298,56 @@ describe('Secret Catalog', () => {
272298
expect(validateFieldValue('xapp-short', pattern)).toBe(false);
273299
});
274300

301+
it('accepts valid GitHub usernames', () => {
302+
const pattern = '^[a-zA-Z\\d](?:[a-zA-Z\\d]|-(?=[a-zA-Z\\d])){0,38}$';
303+
expect(validateFieldValue('octocat', pattern)).toBe(true);
304+
expect(validateFieldValue('my-bot-user', pattern)).toBe(true);
305+
expect(validateFieldValue('a', pattern)).toBe(true);
306+
expect(validateFieldValue('User123', pattern)).toBe(true);
307+
});
308+
309+
it('rejects invalid GitHub usernames', () => {
310+
const pattern = '^[a-zA-Z\\d](?:[a-zA-Z\\d]|-(?=[a-zA-Z\\d])){0,38}$';
311+
expect(validateFieldValue('-octocat', pattern)).toBe(false);
312+
expect(validateFieldValue('octocat-', pattern)).toBe(false);
313+
expect(validateFieldValue('my--name', pattern)).toBe(false);
314+
expect(validateFieldValue('my_name', pattern)).toBe(false);
315+
expect(validateFieldValue('user name', pattern)).toBe(false);
316+
});
317+
318+
it('accepts valid email addresses', () => {
319+
const pattern = '^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$';
320+
expect(validateFieldValue('bot@example.com', pattern)).toBe(true);
321+
expect(validateFieldValue('my-bot@my-org.io', pattern)).toBe(true);
322+
});
323+
324+
it('rejects invalid email addresses', () => {
325+
const pattern = '^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$';
326+
expect(validateFieldValue('notanemail', pattern)).toBe(false);
327+
expect(validateFieldValue('missing@domain', pattern)).toBe(false);
328+
expect(validateFieldValue('has space@example.com', pattern)).toBe(false);
329+
});
330+
331+
it('accepts valid GitHub classic tokens (ghp_)', () => {
332+
const pattern = '^(ghp_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})$';
333+
expect(validateFieldValue('ghp_' + 'A'.repeat(36), pattern)).toBe(true);
334+
expect(validateFieldValue('ghp_' + 'abcDEF123456'.repeat(5), pattern)).toBe(true);
335+
});
336+
337+
it('accepts valid GitHub fine-grained tokens (github_pat_)', () => {
338+
const pattern = '^(ghp_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})$';
339+
expect(validateFieldValue('github_pat_' + 'A'.repeat(22), pattern)).toBe(true);
340+
expect(validateFieldValue('github_pat_' + 'abc_DEF_123'.repeat(5), pattern)).toBe(true);
341+
});
342+
343+
it('rejects invalid GitHub tokens', () => {
344+
const pattern = '^(ghp_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})$';
345+
expect(validateFieldValue('ghp_short', pattern)).toBe(false);
346+
expect(validateFieldValue('github_pat_short', pattern)).toBe(false);
347+
expect(validateFieldValue('gho_invalidprefix', pattern)).toBe(false);
348+
expect(validateFieldValue('invalid', pattern)).toBe(false);
349+
});
350+
275351
it('rejects empty strings', () => {
276352
const pattern = '^\\d{8,}:[A-Za-z0-9_-]{30,50}$';
277353
expect(validateFieldValue('', pattern)).toBe(false);
@@ -311,6 +387,21 @@ describe('Secret Catalog', () => {
311387
expect(slack?.fields.map(f => f.key)).toEqual(['slackBotToken', 'slackAppToken']);
312388
});
313389

390+
it('github entry has allFieldsRequired set', () => {
391+
const github = SECRET_CATALOG_MAP.get('github');
392+
expect(github?.allFieldsRequired).toBe(true);
393+
});
394+
395+
it('github entry has exactly 3 fields', () => {
396+
const github = SECRET_CATALOG_MAP.get('github');
397+
expect(github?.fields.length).toBe(3);
398+
expect(github?.fields.map(f => f.key)).toEqual([
399+
'githubUsername',
400+
'githubEmail',
401+
'githubToken',
402+
]);
403+
});
404+
314405
it('telegram and discord do not have allFieldsRequired', () => {
315406
expect(SECRET_CATALOG_MAP.get('telegram')?.allFieldsRequired).toBeFalsy();
316407
expect(SECRET_CATALOG_MAP.get('discord')?.allFieldsRequired).toBeFalsy();

kiloclaw/packages/secret-catalog/src/catalog.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,50 @@ const SECRET_CATALOG_RAW = [
8989
helpText: 'Get tokens from Slack App Management. Both Bot Token and App Token are required.',
9090
helpUrl: 'https://api.slack.com/apps',
9191
},
92+
{
93+
id: 'github',
94+
label: 'GitHub',
95+
category: 'tool',
96+
icon: 'github',
97+
order: 1,
98+
allFieldsRequired: true,
99+
fields: [
100+
{
101+
key: 'githubUsername',
102+
label: 'Username',
103+
placeholder: 'my-bot-user',
104+
placeholderConfigured: 'Enter new username to replace',
105+
envVar: 'GITHUB_USERNAME',
106+
validationPattern: '^[a-zA-Z\\d](?:[a-zA-Z\\d]|-(?=[a-zA-Z\\d])){0,38}$',
107+
validationMessage:
108+
'GitHub usernames can only contain alphanumeric characters and hyphens, and cannot start or end with a hyphen.',
109+
maxLength: 39,
110+
},
111+
{
112+
key: 'githubEmail',
113+
label: 'Email',
114+
placeholder: 'bot@example.com',
115+
placeholderConfigured: 'Enter new email to replace',
116+
envVar: 'GITHUB_EMAIL',
117+
validationPattern: '^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$',
118+
validationMessage: 'Enter a valid email address.',
119+
maxLength: 254,
120+
},
121+
{
122+
key: 'githubToken',
123+
label: 'Personal Access Token',
124+
placeholder: 'github_pat_...',
125+
placeholderConfigured: 'Enter new token to replace',
126+
envVar: 'GITHUB_TOKEN',
127+
validationPattern: '^(ghp_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})$',
128+
validationMessage:
129+
'Personal access tokens only: classic (ghp_) or fine-grained (github_pat_). OAuth and Actions tokens are not supported.',
130+
maxLength: 300,
131+
},
132+
],
133+
helpText: 'Manage your token from the GitHub developer settings.',
134+
helpUrl: 'https://github.com/settings/tokens?type=beta',
135+
},
92136
] as const satisfies readonly SecretCatalogEntry[];
93137

94138
// Runtime validation — fails fast at module load if catalog data is malformed

kiloclaw/packages/secret-catalog/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { z } from 'zod';
44

55
export const SecretCategorySchema = z.enum(['channel', 'tool', 'provider', 'custom']);
66

7-
export const SecretIconKeySchema = z.enum(['send', 'discord', 'slack', 'key']);
7+
export const SecretIconKeySchema = z.enum(['send', 'discord', 'slack', 'key', 'github']);
88

99
/**
1010
* How a secret is delivered to the OpenClaw process at runtime.

kiloclaw/src/routes/kiloclaw.test.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ describe('buildConfiguredSecrets', () => {
1111

1212
it('returns all entries as false when no secrets are configured', () => {
1313
const result = buildConfiguredSecrets({});
14-
expect(result).toEqual({ telegram: false, discord: false, slack: false });
14+
expect(result).toEqual({ telegram: false, discord: false, slack: false, github: false });
1515
});
1616

1717
it('marks entry as configured when encryptedSecrets has the env var key', () => {
@@ -60,17 +60,39 @@ describe('buildConfiguredSecrets', () => {
6060
expect(result.slack).toBe(true);
6161
});
6262

63+
it('marks github as configured only when all three fields are present', () => {
64+
const partial = buildConfiguredSecrets({
65+
encryptedSecrets: { GITHUB_TOKEN: envelope },
66+
});
67+
expect(partial.github).toBe(false);
68+
69+
const twoOfThree = buildConfiguredSecrets({
70+
encryptedSecrets: { GITHUB_TOKEN: envelope, GITHUB_USERNAME: envelope },
71+
});
72+
expect(twoOfThree.github).toBe(false);
73+
74+
const full = buildConfiguredSecrets({
75+
encryptedSecrets: {
76+
GITHUB_TOKEN: envelope,
77+
GITHUB_USERNAME: envelope,
78+
GITHUB_EMAIL: envelope,
79+
},
80+
});
81+
expect(full.github).toBe(true);
82+
});
83+
6384
it('does not use legacy channels fallback for non-channel category entries', () => {
6485
// If a non-channel entry were added, legacy channels storage should not count
6586
// This tests that CHANNEL_FIELD_KEYS gate is effective — a key not in the
6687
// channel category won't match even if present in config.channels
6788
const result = buildConfiguredSecrets({
6889
channels: { someNonChannelKey: envelope },
6990
});
70-
// All current entries are channels, so this just verifies no crash
91+
// Channel entries should be false, tool entries unaffected by legacy channels
7192
expect(result.telegram).toBe(false);
7293
expect(result.discord).toBe(false);
7394
expect(result.slack).toBe(false);
95+
expect(result.github).toBe(false);
7496
});
7597

7698
it('uses entry.id as the result key', () => {
@@ -79,7 +101,7 @@ describe('buildConfiguredSecrets', () => {
79101
expect(keys).toContain('telegram');
80102
expect(keys).toContain('discord');
81103
expect(keys).toContain('slack');
82-
expect(keys).toHaveLength(3);
104+
expect(keys).toHaveLength(4);
83105
});
84106

85107
it('treats null values as not configured', () => {

kiloclaw/start-openclaw.sh

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,34 @@ if [ "${KILOCLAW_KILO_CLI:-}" = "true" ]; then
212212
echo "Kilo CLI auto-configuration enabled"
213213
fi
214214

215+
# ============================================================
216+
# GITHUB CONFIGURATION
217+
# ============================================================
218+
if [ -n "${GITHUB_TOKEN:-}" ]; then
219+
echo "Configuring GitHub access..."
220+
221+
echo "$GITHUB_TOKEN" | gh auth login --with-token 2>/dev/null \
222+
&& gh auth setup-git 2>/dev/null \
223+
&& echo "gh CLI authenticated" \
224+
|| echo "WARNING: gh auth login failed"
225+
226+
if [ -n "${GITHUB_USERNAME:-}" ]; then
227+
git config --global user.name "$GITHUB_USERNAME"
228+
echo "git user.name set to $GITHUB_USERNAME"
229+
fi
230+
if [ -n "${GITHUB_EMAIL:-}" ]; then
231+
git config --global user.email "$GITHUB_EMAIL"
232+
echo "git user.email set to $GITHUB_EMAIL"
233+
fi
234+
else
235+
# Clean up any previously stored credentials from the persistent volume
236+
# so that removing GitHub secrets actually de-authenticates the machine.
237+
gh auth logout --hostname github.com 2>/dev/null || true
238+
git config --global --unset user.name 2>/dev/null || true
239+
git config --global --unset user.email 2>/dev/null || true
240+
echo "GitHub: not configured (credentials cleared)"
241+
fi
242+
215243
# ============================================================
216244
# PATCH CONFIG (channels, gateway auth, exec policy)
217245
# ============================================================

src/app/(app)/claw/components/SecretEntrySection.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use client';
22

3+
import type React from 'react';
34
import { useState } from 'react';
45
import { AlertCircle, Save, X } from 'lucide-react';
56
import { toast } from 'sonner';
@@ -22,12 +23,14 @@ export function SecretEntrySection({
2223
mutations,
2324
onSecretsChanged,
2425
isDirty,
26+
actionRowExtra,
2527
}: {
2628
entry: SecretCatalogEntry;
2729
configured: boolean;
2830
mutations: ClawMutations;
2931
onSecretsChanged?: (entryId: string) => void;
3032
isDirty: boolean;
33+
actionRowExtra?: React.ReactNode;
3134
}) {
3235
const [tokens, setTokens] = useState<Record<string, string>>({});
3336
const [formatError, setFormatError] = useState<string | null>(null);
@@ -156,6 +159,7 @@ export function SecretEntrySection({
156159
Remove
157160
</Button>
158161
)}
162+
{actionRowExtra}
159163
</div>
160164

161165
<p className="text-muted-foreground text-xs">

0 commit comments

Comments
 (0)