From 443f7b369b85708dedb598a37bf3ce52e218f0c3 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 2 Apr 2026 13:17:12 +0200 Subject: [PATCH 1/4] feat(kiloclaw): add bot identity onboarding --- kiloclaw/controller/src/bootstrap.test.ts | 36 +++ kiloclaw/controller/src/bootstrap.ts | 46 +++ kiloclaw/controller/src/routes/files.test.ts | 35 +++ kiloclaw/controller/src/routes/files.ts | 70 +++++ kiloclaw/controller/src/safe-path.test.ts | 6 + .../gateway-controller-types.ts | 5 + .../durable-objects/kiloclaw-instance.test.ts | 59 ++++ .../kiloclaw-instance/config.ts | 4 + .../kiloclaw-instance/gateway.ts | 21 ++ .../kiloclaw-instance/index.ts | 62 ++++ .../kiloclaw-instance/state.ts | 12 + .../kiloclaw-instance/types.ts | 4 + kiloclaw/src/gateway/env.ts | 9 + kiloclaw/src/routes/controller.test.ts | 4 + kiloclaw/src/routes/controller.ts | 4 + kiloclaw/src/routes/platform.ts | 30 ++ kiloclaw/src/schemas/instance-config.ts | 4 + plan.md | 272 ++++++++++++++++++ .../(app)/claw/components/BotIdentityStep.tsx | 99 +++++++ .../claw/components/ChannelPairingStep.tsx | 8 +- .../claw/components/ChannelSelectionStep.tsx | 4 +- .../(app)/claw/components/ClawDashboard.tsx | 22 +- .../(app)/claw/components/PermissionStep.tsx | 4 +- .../claw/components/ProvisioningStep.tsx | 22 +- src/app/(app)/claw/components/claw.types.ts | 14 + .../withStatusQueryBoundary.test.ts | 4 + src/hooks/useKiloClaw.ts | 3 + src/hooks/useOrgKiloClaw.ts | 4 + src/lib/kiloclaw/kiloclaw-internal-client.ts | 18 ++ src/lib/kiloclaw/types.ts | 18 ++ src/routers/kiloclaw-router.ts | 13 + .../organization-kiloclaw-router.ts | 20 ++ 32 files changed, 920 insertions(+), 16 deletions(-) create mode 100644 plan.md create mode 100644 src/app/(app)/claw/components/BotIdentityStep.tsx diff --git a/kiloclaw/controller/src/bootstrap.test.ts b/kiloclaw/controller/src/bootstrap.test.ts index e58241d5a..fccedb483 100644 --- a/kiloclaw/controller/src/bootstrap.test.ts +++ b/kiloclaw/controller/src/bootstrap.test.ts @@ -8,6 +8,8 @@ import { configureGitHub, configureLinear, runOnboardOrDoctor, + formatBotSoulMarkdown, + writeBotSoulFile, updateToolsMdSection, GOG_SECTION_CONFIG, KILO_CLI_SECTION_CONFIG, @@ -552,6 +554,38 @@ describe('configureLinear', () => { }); }); +// ---- bot soul file ---- + +describe('formatBotSoulMarkdown', () => { + it('renders the bot soul markdown with defaults', () => { + const result = formatBotSoulMarkdown({}); + + expect(result).toContain('# SOUL'); + expect(result).toContain('- Name: KiloClaw'); + expect(result).toContain('- Nature: AI executive assistant'); + }); +}); + +describe('writeBotSoulFile', () => { + it('writes workspace/SOUL.md and removes legacy files when present', () => { + const harness = fakeDeps(); + (harness.deps.existsSync as ReturnType).mockImplementation((p: string) => + p === '/root/.openclaw/workspace/BOOTSTRAP.md' || p === '/root/.openclaw/workspace/IDENTITY.md' + ); + + writeBotSoulFile( + { KILOCLAW_BOT_NAME: 'Milo', KILOCLAW_BOT_NATURE: 'Operator' }, + harness.deps + ); + + expect(harness.renameCalls.some(call => call.to === '/root/.openclaw/workspace/SOUL.md')).toBe(true); + expect((harness.deps.unlinkSync as ReturnType).mock.calls).toEqual([ + ['/root/.openclaw/workspace/BOOTSTRAP.md'], + ['/root/.openclaw/workspace/IDENTITY.md'], + ]); + }); +}); + // ---- runOnboardOrDoctor ---- describe('runOnboardOrDoctor', () => { @@ -603,6 +637,7 @@ describe('runOnboardOrDoctor', () => { const toolsCopy = harness.copyCalls.find(c => c.dest.endsWith('TOOLS.md')); expect(toolsCopy).toBeDefined(); + expect(harness.renameCalls.some(call => call.to.endsWith('/workspace/SOUL.md'))).toBe(true); }); it('runs doctor when config exists', () => { @@ -631,6 +666,7 @@ describe('runOnboardOrDoctor', () => { expect(doctorCall?.args).toContain('--fix'); expect(doctorCall?.args).toContain('--non-interactive'); expect(env.KILOCLAW_FRESH_INSTALL).toBe('false'); + expect(harness.renameCalls.some(call => call.to.endsWith('/workspace/SOUL.md'))).toBe(true); }); }); diff --git a/kiloclaw/controller/src/bootstrap.ts b/kiloclaw/controller/src/bootstrap.ts index 0359c058b..b6b299f4e 100644 --- a/kiloclaw/controller/src/bootstrap.ts +++ b/kiloclaw/controller/src/bootstrap.ts @@ -23,6 +23,11 @@ const WORKSPACE_DIR = '/root/clawd'; const COMPILE_CACHE_DIR = '/var/tmp/openclaw-compile-cache'; const TOOLS_MD_SOURCE = '/usr/local/share/kiloclaw/TOOLS.md'; const TOOLS_MD_DEST = '/root/.openclaw/workspace/TOOLS.md'; +const SOUL_MD_DEST = '/root/.openclaw/workspace/SOUL.md'; +const LEGACY_BOT_IDENTITY_DESTS = [ + '/root/.openclaw/workspace/BOOTSTRAP.md', + '/root/.openclaw/workspace/IDENTITY.md', +]; const ENC_PREFIX = 'KILOCLAW_ENC_'; const VALUE_PREFIX = 'enc:v1:'; @@ -261,6 +266,45 @@ export function generateHooksToken(env: EnvLike): void { } } +export function formatBotSoulMarkdown(env: EnvLike): string { + const lines = [ + '# SOUL', + '', + `- Name: ${env.KILOCLAW_BOT_NAME ?? 'KiloClaw'}`, + `- Nature: ${env.KILOCLAW_BOT_NATURE ?? 'AI executive assistant'}`, + `- Vibe: ${env.KILOCLAW_BOT_VIBE ?? 'Helpful, calm, and proactive'}`, + `- Emoji: ${env.KILOCLAW_BOT_EMOJI ?? '๐Ÿฆพ'}`, + '', + 'Use this file as the canonical identity and tone reference for the bot.', + '', + ]; + return lines.join('\n'); +} + +export function writeBotSoulFile( + env: EnvLike, + deps: Pick< + BootstrapDeps, + 'mkdirSync' | 'writeFileSync' | 'renameSync' | 'unlinkSync' | 'existsSync' + > = defaultDeps +): void { + deps.mkdirSync(path.dirname(SOUL_MD_DEST), { recursive: true }); + atomicWrite(SOUL_MD_DEST, formatBotSoulMarkdown(env), { + writeFileSync: deps.writeFileSync, + renameSync: deps.renameSync, + unlinkSync: deps.unlinkSync, + }); + + for (const legacyPath of LEGACY_BOT_IDENTITY_DESTS) { + if (!deps.existsSync(legacyPath)) continue; + try { + deps.unlinkSync(legacyPath); + } catch (error) { + console.warn(`[controller] Failed to remove legacy bot identity file ${legacyPath}:`, error); + } + } +} + // ---- Step 5: GitHub config ---- /** @@ -406,6 +450,8 @@ export function runOnboardOrDoctor(env: EnvLike, deps: BootstrapDeps = defaultDe env.KILOCLAW_FRESH_INSTALL = 'false'; } + + writeBotSoulFile(env, deps); } // ---- TOOLS.md bounded-section helper ---- diff --git a/kiloclaw/controller/src/routes/files.test.ts b/kiloclaw/controller/src/routes/files.test.ts index d13255588..c81fec4c6 100644 --- a/kiloclaw/controller/src/routes/files.test.ts +++ b/kiloclaw/controller/src/routes/files.test.ts @@ -9,6 +9,8 @@ vi.mock('node:fs', () => ({ existsSync: vi.fn(), lstatSync: vi.fn(), statSync: vi.fn(), + mkdirSync: vi.fn(), + unlinkSync: vi.fn(), realpathSync: vi.fn((p: string) => p), // identity by default (no symlinks) }, })); @@ -62,6 +64,39 @@ describe('file routes', () => { }); expect(res.status).toBe(401); }); + + it('protects the bot identity route', async () => { + const res = await app.request('/_kilo/bot-identity', { + method: 'POST', + headers: { Authorization: 'Bearer wrong-token', 'Content-Type': 'application/json' }, + body: JSON.stringify({ botName: 'Milo' }), + }); + expect(res.status).toBe(401); + }); + }); + + describe('POST /_kilo/bot-identity', () => { + it('writes workspace/SOUL.md', async () => { + vi.mocked(fs.existsSync).mockImplementation((path: any) => + typeof path === 'string' && path.endsWith('BOOTSTRAP.md') + ); + + const res = await app.request('/_kilo/bot-identity', { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ botName: 'Milo', botNature: 'Operator' }), + }); + + expect(res.status).toBe(200); + expect(atomicWrite).toHaveBeenCalledWith( + `${ROOT}/workspace/SOUL.md`, + expect.stringContaining('- Name: Milo') + ); + + const body = (await res.json()) as any; + expect(body.path).toBe('workspace/SOUL.md'); + expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(`${ROOT}/workspace/BOOTSTRAP.md`); + }); }); describe('GET /_kilo/files/tree', () => { diff --git a/kiloclaw/controller/src/routes/files.ts b/kiloclaw/controller/src/routes/files.ts index cacb0a708..79ec19df4 100644 --- a/kiloclaw/controller/src/routes/files.ts +++ b/kiloclaw/controller/src/routes/files.ts @@ -8,6 +8,7 @@ import { timingSafeTokenEqual } from '../auth'; import { resolveSafePath, verifyCanonicalized, SafePathError } from '../safe-path'; import { atomicWrite } from '../atomic-write'; import { backupFile } from '../backup-file'; +import { formatBotSoulMarkdown } from '../bootstrap'; function computeEtag(content: string): string { return crypto.createHash('md5').update(content).digest('hex'); @@ -100,6 +101,16 @@ function resolveAndValidateFile( return resolved; } +const BotIdentityBodySchema = z.object({ + botName: z.string().trim().min(1).max(80).nullable().optional(), + botNature: z.string().trim().min(1).max(120).nullable().optional(), + botVibe: z.string().trim().min(1).max(120).nullable().optional(), + botEmoji: z.string().trim().min(1).max(16).nullable().optional(), +}); + +const BOT_SOUL_RELATIVE_PATH = 'workspace/SOUL.md'; +const LEGACY_BOT_IDENTITY_RELATIVE_PATHS = ['workspace/BOOTSTRAP.md', 'workspace/IDENTITY.md']; + export function registerFileRoutes(app: Hono, expectedToken: string, rootDir: string): void { app.use('/_kilo/files/*', async (c, next) => { const token = getBearerToken(c.req.header('authorization')); @@ -109,6 +120,65 @@ export function registerFileRoutes(app: Hono, expectedToken: string, rootDir: st await next(); }); + app.use('/_kilo/bot-identity', async (c, next) => { + const token = getBearerToken(c.req.header('authorization')); + if (!timingSafeTokenEqual(token, expectedToken)) { + return c.json({ error: 'Unauthorized' }, 401); + } + await next(); + }); + + app.post('/_kilo/bot-identity', async c => { + let rawBody: unknown; + try { + rawBody = await c.req.json(); + } catch { + return c.json({ error: 'Invalid JSON body' }, 400); + } + + const parsed = BotIdentityBodySchema.safeParse(rawBody); + if (!parsed.success) { + return c.json({ error: 'Missing or invalid bot identity fields' }, 400); + } + + let targetPath: string; + try { + targetPath = resolveSafePath(BOT_SOUL_RELATIVE_PATH, rootDir); + } catch (err) { + if (err instanceof SafePathError) { + return c.json({ error: err.message }, 400); + } + throw err; + } + + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + + try { + atomicWrite(targetPath, formatBotSoulMarkdown({ + KILOCLAW_BOT_NAME: parsed.data.botName ?? undefined, + KILOCLAW_BOT_NATURE: parsed.data.botNature ?? undefined, + KILOCLAW_BOT_VIBE: parsed.data.botVibe ?? undefined, + KILOCLAW_BOT_EMOJI: parsed.data.botEmoji ?? undefined, + })); + + for (const legacyPath of LEGACY_BOT_IDENTITY_RELATIVE_PATHS) { + try { + const resolvedLegacyPath = resolveSafePath(legacyPath, rootDir); + if (fs.existsSync(resolvedLegacyPath)) { + fs.unlinkSync(resolvedLegacyPath); + } + } catch (error) { + console.warn('[files] Failed to remove legacy bot identity file:', legacyPath, error); + } + } + + return c.json({ ok: true, path: BOT_SOUL_RELATIVE_PATH }); + } catch (err) { + console.error('[files] Failed to write bot identity:', err); + return c.json({ error: 'Failed to write bot identity' }, 500); + } + }); + app.get('/_kilo/files/tree', c => { const tree = buildTree(rootDir, rootDir); return c.json({ tree }); diff --git a/kiloclaw/controller/src/safe-path.test.ts b/kiloclaw/controller/src/safe-path.test.ts index ee2accc99..88c1ade01 100644 --- a/kiloclaw/controller/src/safe-path.test.ts +++ b/kiloclaw/controller/src/safe-path.test.ts @@ -12,6 +12,12 @@ describe('resolveSafePath', () => { expect(resolveSafePath('workspace/SOUL.md', ROOT)).toBe('/root/.openclaw/workspace/SOUL.md'); }); + it('resolves the legacy bootstrap path in workspace', () => { + expect(resolveSafePath('workspace/BOOTSTRAP.md', ROOT)).toBe( + '/root/.openclaw/workspace/BOOTSTRAP.md' + ); + }); + it('rejects path traversal with ..', () => { expect(() => resolveSafePath('../etc/passwd', ROOT)).toThrow(); }); diff --git a/kiloclaw/src/durable-objects/gateway-controller-types.ts b/kiloclaw/src/durable-objects/gateway-controller-types.ts index c01fd17c3..0760ece10 100644 --- a/kiloclaw/src/durable-objects/gateway-controller-types.ts +++ b/kiloclaw/src/durable-objects/gateway-controller-types.ts @@ -32,6 +32,11 @@ export const GatewayCommandResponseSchema = z.object({ ok: z.boolean(), }); +export const BotIdentityResponseSchema = z.object({ + ok: z.boolean(), + path: z.string(), +}); + export const ConfigRestoreResponseSchema = z.object({ ok: z.boolean(), signaled: z.boolean(), diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts b/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts index 11093d0c2..030b6cfce 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts @@ -6098,6 +6098,65 @@ describe('updateExecPreset', () => { }); }); +describe('updateBotIdentity', () => { + it('persists bot identity fields to DO storage', async () => { + const { instance, storage } = createInstance(); + await seedProvisioned(storage); + + const result = await instance.updateBotIdentity({ + botName: 'Milo', + botNature: 'Operations copilot', + botVibe: 'Dry wit', + botEmoji: '๐Ÿค–', + }); + + expect(result).toEqual({ + botName: 'Milo', + botNature: 'Operations copilot', + botVibe: 'Dry wit', + botEmoji: '๐Ÿค–', + }); + expect(storage._store.get('botName')).toBe('Milo'); + expect(storage._store.get('botNature')).toBe('Operations copilot'); + expect(storage._store.get('botVibe')).toBe('Dry wit'); + expect(storage._store.get('botEmoji')).toBe('๐Ÿค–'); + }); + + it('writes SOUL.md on running instances', async () => { + const env = createFakeEnv(); + env.FLY_APP_NAME = 'bot-app'; + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ ok: true, path: 'workspace/SOUL.md' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }) + ); + vi.stubGlobal('fetch', fetchMock); + const { instance, storage } = createInstance(undefined, env); + await seedProvisioned(storage, { + status: 'running', + flyMachineId: 'machine-1', + sandboxId: 'sandbox-1', + }); + + await instance.updateBotIdentity({ botName: 'Milo' }); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://bot-app.fly.dev/_kilo/bot-identity', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + botName: 'Milo', + botNature: null, + botVibe: null, + botEmoji: null, + }), + }) + ); + vi.unstubAllGlobals(); + }); +}); + describe('tryMarkInstanceReady', () => { it('returns shouldNotify: true on first call and persists the flag', async () => { const { instance, storage } = createInstance(); diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance/config.ts b/kiloclaw/src/durable-objects/kiloclaw-instance/config.ts index da3087888..efb22b2cb 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance/config.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance/config.ts @@ -147,6 +147,10 @@ export async function buildUserEnvVars( instanceFeatures: state.instanceFeatures, execSecurity: state.execSecurity ?? undefined, execAsk: state.execAsk ?? undefined, + botName: state.botName ?? undefined, + botNature: state.botNature ?? undefined, + botVibe: state.botVibe ?? undefined, + botEmoji: state.botEmoji ?? undefined, orgId: state.orgId, customSecretMeta: state.customSecretMeta ?? undefined, } diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts b/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts index 9a4208ae7..9ebcdc0a5 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts @@ -5,6 +5,7 @@ import { type GatewayProcessStatus, GatewayProcessStatusSchema, GatewayCommandResponseSchema, + BotIdentityResponseSchema, ConfigRestoreResponseSchema, ControllerVersionResponseSchema, GatewayReadyResponseSchema, @@ -189,6 +190,26 @@ export function restartGatewayProcess( ); } +export function writeBotIdentity( + state: InstanceMutableState, + env: KiloClawEnv, + botIdentity: { + botName?: string | null; + botNature?: string | null; + botVibe?: string | null; + botEmoji?: string | null; + } +): Promise<{ ok: boolean; path: string }> { + return callGatewayController( + state, + env, + '/_kilo/bot-identity', + 'POST', + BotIdentityResponseSchema, + botIdentity + ); +} + export function restoreConfig( state: InstanceMutableState, env: KiloClawEnv, diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts b/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts index ae691b1b1..1ccd82dab 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts @@ -549,6 +549,60 @@ export class KiloClawInstance extends DurableObject { }; } + + async updateBotIdentity(patch: { + botName?: string | null; + botNature?: string | null; + botVibe?: string | null; + botEmoji?: string | null; + }): Promise<{ + botName: string | null; + botNature: string | null; + botVibe: string | null; + botEmoji: string | null; + }> { + await this.loadState(); + + const pending: Partial = {}; + + if (patch.botName !== undefined) { + this.s.botName = patch.botName; + pending.botName = patch.botName; + } + if (patch.botNature !== undefined) { + this.s.botNature = patch.botNature; + pending.botNature = patch.botNature; + } + if (patch.botVibe !== undefined) { + this.s.botVibe = patch.botVibe; + pending.botVibe = patch.botVibe; + } + if (patch.botEmoji !== undefined) { + this.s.botEmoji = patch.botEmoji; + pending.botEmoji = patch.botEmoji; + } + + if (Object.keys(pending).length > 0) { + await this.ctx.storage.put(pending); + } + + if (this.s.status === 'running' && Object.keys(pending).length > 0) { + await gateway.writeBotIdentity(this.s, this.env, { + botName: this.s.botName, + botNature: this.s.botNature, + botVibe: this.s.botVibe, + botEmoji: this.s.botEmoji, + }); + } + + return { + botName: this.s.botName, + botNature: this.s.botNature, + botVibe: this.s.botVibe, + botEmoji: this.s.botEmoji, + }; + } + async updateChannels(patch: { telegramBotToken?: EncryptedEnvelope | null; discordBotToken?: EncryptedEnvelope | null; @@ -1413,6 +1467,10 @@ export class KiloClawInstance extends DurableObject { gmailNotificationsEnabled: boolean; execSecurity: string | null; execAsk: string | null; + botName: string | null; + botNature: string | null; + botVibe: string | null; + botEmoji: string | null; }> { await this.loadState(); @@ -1452,6 +1510,10 @@ export class KiloClawInstance extends DurableObject { gmailNotificationsEnabled: this.s.gmailNotificationsEnabled, execSecurity: this.s.execSecurity, execAsk: this.s.execAsk, + botName: this.s.botName, + botNature: this.s.botNature, + botVibe: this.s.botVibe, + botEmoji: this.s.botEmoji, }; } diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance/state.ts b/kiloclaw/src/durable-objects/kiloclaw-instance/state.ts index d5480c321..7a3d6a0bf 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance/state.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance/state.ts @@ -73,6 +73,10 @@ export async function loadState(ctx: DurableObjectState, s: InstanceMutableState s.gmailPushOidcEmail = d.gmailPushOidcEmail; s.execSecurity = d.execSecurity; s.execAsk = d.execAsk; + s.botName = d.botName; + s.botNature = d.botNature; + s.botVibe = d.botVibe; + s.botEmoji = d.botEmoji; s.previousVolumeId = d.previousVolumeId; s.restoreStartedAt = d.restoreStartedAt; s.preRestoreStatus = d.preRestoreStatus; @@ -148,6 +152,10 @@ export function resetMutableState(s: InstanceMutableState): void { s.gmailPushOidcEmail = null; s.execSecurity = null; s.execAsk = null; + s.botName = null; + s.botNature = null; + s.botVibe = null; + s.botEmoji = null; s.previousVolumeId = null; s.restoreStartedAt = null; s.preRestoreStatus = null; @@ -214,6 +222,10 @@ export function createMutableState(): InstanceMutableState { gmailPushOidcEmail: null, execSecurity: null, execAsk: null, + botName: null, + botNature: null, + botVibe: null, + botEmoji: null, previousVolumeId: null, restoreStartedAt: null, preRestoreStatus: null, diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance/types.ts b/kiloclaw/src/durable-objects/kiloclaw-instance/types.ts index e3deadd71..e4a80f1ae 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance/types.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance/types.ts @@ -89,6 +89,10 @@ export type InstanceMutableState = { gmailPushOidcEmail: string | null; execSecurity: string | null; execAsk: string | null; + botName: string | null; + botNature: string | null; + botVibe: string | null; + botEmoji: string | null; // Snapshot restore tracking previousVolumeId: string | null; restoreStartedAt: string | null; diff --git a/kiloclaw/src/gateway/env.ts b/kiloclaw/src/gateway/env.ts index 9812daa71..d44b27c54 100644 --- a/kiloclaw/src/gateway/env.ts +++ b/kiloclaw/src/gateway/env.ts @@ -30,6 +30,10 @@ export type UserConfig = { instanceFeatures?: string[]; execSecurity?: string | null; execAsk?: string | null; + botName?: string | null; + botNature?: string | null; + botVibe?: string | null; + botEmoji?: string | null; /** Organization ID โ€” injected as KILOCODE_ORGANIZATION_ID for org instances. */ orgId?: string | null; customSecretMeta?: Record | null; @@ -212,6 +216,11 @@ export async function buildEnvVars( if (userConfig?.execSecurity) plainEnv.KILOCLAW_EXEC_SECURITY = userConfig.execSecurity; if (userConfig?.execAsk) plainEnv.KILOCLAW_EXEC_ASK = userConfig.execAsk; + if (userConfig?.botName) plainEnv.KILOCLAW_BOT_NAME = userConfig.botName; + if (userConfig?.botNature) plainEnv.KILOCLAW_BOT_NATURE = userConfig.botNature; + if (userConfig?.botVibe) plainEnv.KILOCLAW_BOT_VIBE = userConfig.botVibe; + if (userConfig?.botEmoji) plainEnv.KILOCLAW_BOT_EMOJI = userConfig.botEmoji; + // Instance feature flags โ†’ env vars (non-sensitive, not user-overridable). // Applied after user env vars so users cannot suppress features via envVars config. if (userConfig?.instanceFeatures) { diff --git a/kiloclaw/src/routes/controller.test.ts b/kiloclaw/src/routes/controller.test.ts index 94b7ffb19..5482e83ce 100644 --- a/kiloclaw/src/routes/controller.test.ts +++ b/kiloclaw/src/routes/controller.test.ts @@ -42,6 +42,10 @@ function makeEnv(options?: { }); const getStatus = vi.fn().mockResolvedValue({ userId: 'user-1', + botName: 'Milo', + botNature: 'Operations copilot', + botVibe: 'Dry wit', + botEmoji: '๐Ÿค–', }); const tryMarkInstanceReady = options?.tryMarkInstanceReady ?? diff --git a/kiloclaw/src/routes/controller.ts b/kiloclaw/src/routes/controller.ts index 1b9becd24..be740a1d8 100644 --- a/kiloclaw/src/routes/controller.ts +++ b/kiloclaw/src/routes/controller.ts @@ -20,6 +20,10 @@ const ProductTelemetrySchema = z.object({ enabledChannels: z.array(z.string()), toolsProfile: z.string().nullable(), execSecurity: z.string().nullable(), + botName: z.string().nullable(), + botNature: z.string().nullable(), + botVibe: z.string().nullable(), + botEmoji: z.string().nullable(), browserEnabled: z.boolean(), }); diff --git a/kiloclaw/src/routes/platform.ts b/kiloclaw/src/routes/platform.ts index 639226884..09b8151d6 100644 --- a/kiloclaw/src/routes/platform.ts +++ b/kiloclaw/src/routes/platform.ts @@ -432,6 +432,14 @@ const ExecPresetPatchSchema = z.object({ ask: z.string().optional(), }); +const BotIdentityPatchSchema = z.object({ + userId: z.string().min(1), + botName: z.string().trim().min(1).max(80).nullable().optional(), + botNature: z.string().trim().min(1).max(120).nullable().optional(), + botVibe: z.string().trim().min(1).max(120).nullable().optional(), + botEmoji: z.string().trim().min(1).max(16).nullable().optional(), +}); + platform.patch('/exec-preset', async c => { const result = await parseBody(c, ExecPresetPatchSchema); if ('error' in result) return result.error; @@ -454,6 +462,28 @@ platform.patch('/exec-preset', async c => { } }); +platform.patch('/bot-identity', async c => { + const result = await parseBody(c, BotIdentityPatchSchema); + if ('error' in result) return result.error; + + const iidResult = parseInstanceIdQuery(c); + if ('error' in iidResult) return iidResult.error; + + const { userId, botName, botNature, botVibe, botEmoji } = result.data; + + try { + const updated = await withDORetry( + instanceStubFactory(c.env, userId, iidResult.instanceId), + stub => stub.updateBotIdentity({ botName, botNature, botVibe, botEmoji }), + 'updateBotIdentity' + ); + return c.json(updated, 200); + } catch (err) { + const { message, status } = sanitizeError(err, 'bot-identity patch'); + return jsonError(message, status); + } +}); + // POST /api/platform/google-credentials const GoogleCredentialsPatchSchema = z.object({ userId: z.string().min(1), diff --git a/kiloclaw/src/schemas/instance-config.ts b/kiloclaw/src/schemas/instance-config.ts index 37a6663e4..042673968 100644 --- a/kiloclaw/src/schemas/instance-config.ts +++ b/kiloclaw/src/schemas/instance-config.ts @@ -232,6 +232,10 @@ export const PersistedStateSchema = z.object({ // null = use defaults (security: 'allowlist', ask: 'on-miss'). execSecurity: z.string().nullable().default(null), execAsk: z.string().nullable().default(null), + botName: z.string().nullable().default(null), + botNature: z.string().nullable().default(null), + botVibe: z.string().nullable().default(null), + botEmoji: z.string().nullable().default(null), // Snapshot restore: tracks the volume before the most recent restore for admin revert path. previousVolumeId: z.string().nullable().default(null), // Snapshot restore: timestamp set at enqueue time. Used by alarm for stuck-restore detection diff --git a/plan.md b/plan.md new file mode 100644 index 000000000..66edf3a6e --- /dev/null +++ b/plan.md @@ -0,0 +1,272 @@ +# Plan: Bot Identity Onboarding Step + +## Goal + +Add a new step to the KiloClaw onboarding flow that asks users about their bot's identity (name, nature, vibe, emoji). The identity data must be persisted in durable storage and written to `workspace/IDENTITY.md` on the instance filesystem so the bot is ready to go when the instance starts. + +## Data Model + +```ts +type BotIdentity = { + botName: string | null; // "What should they call you?" + botNature: string | null; // "What kind of creature are you?" + botVibe: string | null; // "Formal? Casual? Snarky? Warm?" + botEmoji: string | null; // "Everyone needs a signature." +}; +``` + +## Architecture + +Follows the same pattern as the exec permissions preset (`execSecurity`/`execAsk`): + +1. **Persist in DO state** โ€” fields survive restarts +2. **Transport via env vars** โ€” `KILOCLAW_BOT_NAME`, `KILOCLAW_BOT_NATURE`, `KILOCLAW_BOT_VIBE`, `KILOCLAW_BOT_EMOJI` +3. **Controller bootstrap writes `workspace/IDENTITY.md`** from env vars on every boot +4. **During onboarding**, the ProvisioningStep also writes the file to the running instance via a new controller endpoint (since the machine is already booted before the user fills the form) + +## Onboarding Flow (Updated) + +``` +CreateInstanceCard โ†’ BotIdentityStep (NEW) โ†’ PermissionStep โ†’ ChannelSelectionStep โ†’ ProvisioningStep โ†’ [ChannelPairingStep] โ†’ Done +``` + +Step indicator numbering: 2, 3, 4, 5, [6]. Total steps: `hasPairingStep ? 6 : 5` (was `hasPairingStep ? 5 : 4`). + +## Changes by Layer + +### 1. Frontend โ€” New `BotIdentityStep` Component + +**New file:** `src/app/(app)/claw/components/BotIdentityStep.tsx` + +Form with four fields: +- **Name** โ€” text input, placeholder "e.g. Clawdia, Byte, Archie" +- **Nature** โ€” text input, placeholder "e.g. AI assistant, digital familiar, code gremlin" +- **Vibe** โ€” text input or select with options like Casual, Formal, Snarky, Warm, Playful +- **Emoji** โ€” text input (single emoji), placeholder "e.g. ๐Ÿฆ€, ๐Ÿค–, โšก" + +"Continue" button advances to PermissionStep. All fields optional (user can skip/leave blank). + +Uses `OnboardingStepView` wrapper with `currentStep={2}`, `showProvisioningBanner={!instanceRunning}`. + +### 2. Frontend โ€” Update `ClawDashboard.tsx` + +- Add `'identity'` to the onboarding step union type: `'identity' | 'permissions' | 'channels' | 'provisioning' | 'pairing' | 'done'` +- Set initial step to `'identity'` instead of `'permissions'` +- Add state: `botIdentity` (holds `{ botName, botNature, botVibe, botEmoji }`) +- Wire up the new step in the render chain: + ``` + isNewSetup && onboardingStep === 'identity' โ†’ + ``` +- On identity step completion: store identity state, advance to `'permissions'` +- Pass `botIdentity` to `ProvisioningStep` +- Bump `totalSteps` from 4/5 to 5/6 + +### 3. Frontend โ€” Update `ProvisioningStep.tsx` + +- Accept new `botIdentity` prop +- When `instanceRunning` becomes true, alongside existing config patching: + - Call `mutations.patchBotIdentity(identity)` to persist to DO **and** write IDENTITY.md to the running instance + +### 4. Frontend โ€” Update `claw.types.ts` + +- Add `BotIdentity` type export +- Export it from claw.types so it's available to components + +### 5. Frontend โ€” Update Step Numbering + +- `PermissionStep`: `currentStep` 2 โ†’ 3 +- `ChannelSelectionStep`: `currentStep` 3 โ†’ 4 +- `ProvisioningStep`: `currentStep` 4 โ†’ 5 +- `ChannelPairingStep`: `currentStep` 5 โ†’ 6 + +### 6. Hooks โ€” `useKiloClaw.ts` + +Add mutation: +```ts +patchBotIdentity: useMutation( + trpc.kiloclaw.patchBotIdentity.mutationOptions({ onSuccess: invalidateStatus }) +), +``` + +### 7. Hooks โ€” `useOrgKiloClaw.ts` + +Add equivalent org mutation for org-scoped instances. + +### 8. tRPC Router โ€” `kiloclaw-router.ts` + +New mutation: +```ts +patchBotIdentity: clawAccessProcedure + .input(z.object({ + botName: z.string().max(100).nullable().optional(), + botNature: z.string().max(200).nullable().optional(), + botVibe: z.string().max(200).nullable().optional(), + botEmoji: z.string().max(10).nullable().optional(), + })) + .mutation(async ({ ctx, input }) => { + const instance = await getActiveInstance(ctx.user.id); + const client = new KiloClawInternalClient(); + return client.patchBotIdentity(ctx.user.id, input, workerInstanceId(instance)); + }), +``` + +### 9. tRPC Router โ€” `organization-kiloclaw-router.ts` + +Mirror the `patchBotIdentity` mutation for org-scoped access. + +### 10. Internal Client โ€” `kiloclaw-internal-client.ts` + +New method: +```ts +async patchBotIdentity( + userId: string, + patch: { botName?: string | null; botNature?: string | null; botVibe?: string | null; botEmoji?: string | null }, + instanceId?: string, +): Promise<{ ok: boolean }> { + // PATCH /api/platform/bot-identity +} +``` + +### 11. CF Worker Platform Route โ€” `platform.ts` + +New route: +``` +PATCH /api/platform/bot-identity +``` +Schema: `{ userId, botName?, botNature?, botVibe?, botEmoji? }` + +Calls `stub.updateBotIdentity(patch)` on the Instance DO. + +### 12. DO Method โ€” `kiloclaw-instance/index.ts` + +New method `updateBotIdentity()`: +1. Persist fields to DO state (`ctx.storage.put(...)`) +2. If machine is running, call `gateway.writeBotIdentity(this.s, this.env, identity)` to write IDENTITY.md to the running instance (fire-and-forget, non-fatal) +3. Return `{ ok: true }` + +### 13. DO State โ€” Schema & Types + +**`instance-config.ts` (PersistedStateSchema):** +```ts +botName: z.string().nullable().default(null), +botNature: z.string().nullable().default(null), +botVibe: z.string().nullable().default(null), +botEmoji: z.string().nullable().default(null), +``` + +**`types.ts` (InstanceMutableState):** +Add four nullable string fields. + +**`state.ts` (loadState/clearState):** +Add fields to state loading from persisted data and clearing on destroy. + +### 14. Gateway Module โ€” `gateway.ts` + +New function: +```ts +export async function writeBotIdentity( + state: InstanceMutableState, + env: KiloClawEnv, + identity: { botName?: string | null; botNature?: string | null; botVibe?: string | null; botEmoji?: string | null }, +): Promise<{ ok: boolean }> { + return callGatewayController(state, env, '/_kilo/bot-identity', 'POST', GatewayCommandResponseSchema, identity); +} +``` + +### 15. Env Vars โ€” `env.ts` + +In `buildEnvVars()`, add after the exec preset block: +```ts +if (userConfig?.botName) plainEnv.KILOCLAW_BOT_NAME = userConfig.botName; +if (userConfig?.botNature) plainEnv.KILOCLAW_BOT_NATURE = userConfig.botNature; +if (userConfig?.botVibe) plainEnv.KILOCLAW_BOT_VIBE = userConfig.botVibe; +if (userConfig?.botEmoji) plainEnv.KILOCLAW_BOT_EMOJI = userConfig.botEmoji; +``` + +Also add to `UserConfig` type. + +### 16. Controller โ€” New Endpoint `POST /_kilo/bot-identity` + +**File:** `kiloclaw/controller/src/routes/` (new route registration, or in an existing routes file) + +Accepts: `{ botName?, botNature?, botVibe?, botEmoji? }` + +Generates markdown: +```markdown +# Identity + +- **Name:** Clawdia +- **Nature:** AI assistant with a taste for automation +- **Vibe:** Casual and a little snarky +- **Emoji:** ๐Ÿฆ€ +``` + +Writes to `workspace/IDENTITY.md` (creates file + parent dirs if needed). Uses `resolveSafePath` for security, `atomicWrite` for reliability. + +Returns `{ ok: true }`. + +### 17. Controller Bootstrap โ€” `bootstrap.ts` + +In `runOnboardOrDoctor()`, after seeding TOOLS.md on first provision, also write IDENTITY.md from env vars: + +```ts +const identityEnv = { + name: env.KILOCLAW_BOT_NAME, + nature: env.KILOCLAW_BOT_NATURE, + vibe: env.KILOCLAW_BOT_VIBE, + emoji: env.KILOCLAW_BOT_EMOJI, +}; +if (Object.values(identityEnv).some(Boolean)) { + writeIdentityMd(identityEnv, deps); +} +``` + +This runs on EVERY boot (not just first provision), ensuring identity changes from the DO state are picked up on restart. Extract the markdown generation into a shared helper used by both the `/_kilo/bot-identity` endpoint and bootstrap. + +### 18. DO Config Module โ€” `config.ts` + +In the `buildUserConfig()` function that assembles the UserConfig passed to `buildEnvVars`, add identity fields: +```ts +botName: state.botName ?? undefined, +botNature: state.botNature ?? undefined, +botVibe: state.botVibe ?? undefined, +botEmoji: state.botEmoji ?? undefined, +``` + +## Tests + +| Layer | Test file | What to test | +|-------|-----------|-------------| +| DO method | `kiloclaw-instance.test.ts` | `updateBotIdentity` persists state, returns values | +| Env vars | `env.test.ts` | Identity env vars appear when set, absent when null | +| Controller endpoint | New test file or `files.test.ts` | `/_kilo/bot-identity` writes IDENTITY.md correctly | +| Bootstrap | `bootstrap.test.ts` / `config-writer.test.ts` | Identity env vars produce IDENTITY.md on boot | +| tRPC | Existing router test patterns | Mutation calls through to internal client | + +## File Summary + +| File | Action | +|------|--------| +| `src/app/(app)/claw/components/BotIdentityStep.tsx` | **Create** | +| `src/app/(app)/claw/components/ClawDashboard.tsx` | Modify | +| `src/app/(app)/claw/components/ProvisioningStep.tsx` | Modify | +| `src/app/(app)/claw/components/PermissionStep.tsx` | Modify (step number) | +| `src/app/(app)/claw/components/ChannelSelectionStep.tsx` | Modify (step number) | +| `src/app/(app)/claw/components/ChannelPairingStep.tsx` | Modify (step number) | +| `src/app/(app)/claw/components/claw.types.ts` | Modify | +| `src/hooks/useKiloClaw.ts` | Modify | +| `src/hooks/useOrgKiloClaw.ts` | Modify | +| `src/routers/kiloclaw-router.ts` | Modify | +| `src/routers/organizations/organization-kiloclaw-router.ts` | Modify | +| `src/lib/kiloclaw/kiloclaw-internal-client.ts` | Modify | +| `kiloclaw/src/schemas/instance-config.ts` | Modify | +| `kiloclaw/src/durable-objects/kiloclaw-instance/types.ts` | Modify | +| `kiloclaw/src/durable-objects/kiloclaw-instance/state.ts` | Modify | +| `kiloclaw/src/durable-objects/kiloclaw-instance/index.ts` | Modify | +| `kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts` | Modify | +| `kiloclaw/src/durable-objects/kiloclaw-instance/config.ts` | Modify | +| `kiloclaw/src/routes/platform.ts` | Modify | +| `kiloclaw/src/gateway/env.ts` | Modify | +| `kiloclaw/controller/src/bootstrap.ts` | Modify | +| `kiloclaw/controller/src/routes/` | Modify (add identity endpoint) | +| Test files | Modify/create as needed | diff --git a/src/app/(app)/claw/components/BotIdentityStep.tsx b/src/app/(app)/claw/components/BotIdentityStep.tsx new file mode 100644 index 000000000..5169b0f2c --- /dev/null +++ b/src/app/(app)/claw/components/BotIdentityStep.tsx @@ -0,0 +1,99 @@ +'use client'; + +import { useState } from 'react'; +import { ChevronRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { OnboardingStepView } from './OnboardingStepView'; +import type { BotIdentity } from './claw.types'; +import { DEFAULT_BOT_IDENTITY } from './claw.types'; + +export function BotIdentityStep({ + instanceRunning, + onContinue, +}: { + instanceRunning: boolean; + onContinue: (identity: BotIdentity) => void; +}) { + const [identity, setIdentity] = useState(DEFAULT_BOT_IDENTITY); + + function updateField(key: K, value: BotIdentity[K]) { + setIdentity(current => ({ ...current, [key]: value })); + } + + return ( + +
+
+ + updateField('botName', event.target.value)} + maxLength={80} + placeholder="KiloClaw" + /> +
+ +
+ + updateField('botNature', event.target.value)} + maxLength={120} + placeholder="AI executive assistant" + /> +
+ +
+ + updateField('botEmoji', event.target.value)} + maxLength={16} + placeholder="๐Ÿฆพ" + /> +
+ +
+ + updateField('botVibe', event.target.value)} + maxLength={120} + placeholder="Helpful, calm, and proactive" + /> +
+
+ +
+

+ {identity.botEmoji || '๐Ÿค–'} {identity.botName || 'Your bot'} +

+

+ {identity.botNature || 'AI assistant'} ยท {identity.botVibe || 'Ready to help'} +

+
+ + +
+ ); +} diff --git a/src/app/(app)/claw/components/ChannelPairingStep.tsx b/src/app/(app)/claw/components/ChannelPairingStep.tsx index daca5a7c4..986367f44 100644 --- a/src/app/(app)/claw/components/ChannelPairingStep.tsx +++ b/src/app/(app)/claw/components/ChannelPairingStep.tsx @@ -125,8 +125,8 @@ export function ChannelPairingStepView({ if (matchingRequest) { return ( ('permissions'); + 'identity' | 'permissions' | 'channels' | 'provisioning' | 'pairing' | 'done' + >('identity'); const [selectedPreset, setSelectedPreset] = useState(null); + const [botIdentity, setBotIdentity] = useState(null); const [channelTokens, setChannelTokens] = useState | null>(null); const [selectedChannelId, setSelectedChannelId] = useState(null); const hasPairingStep = selectedChannelId === 'telegram' || selectedChannelId === 'discord'; @@ -165,8 +167,9 @@ function ClawDashboardInner({ const prevIsNewSetup = useRef(isNewSetup); useEffect(() => { if (isNewSetup && !prevIsNewSetup.current) { - setOnboardingStep('permissions'); + setOnboardingStep('identity'); setSelectedPreset(null); + setBotIdentity(null); setChannelTokens(null); setSelectedChannelId(null); } @@ -283,6 +286,14 @@ function ClawDashboardInner({ onProvisionStart={() => onNewSetupChange(true)} onProvisionFailed={() => onNewSetupChange(false)} /> + ) : isNewSetup && onboardingStep === 'identity' ? ( + { + setBotIdentity(identity); + setOnboardingStep('permissions'); + }} + /> ) : isNewSetup && onboardingStep === 'permissions' ? ( setOnboardingStep(hasPairingStep ? 'pairing' : 'done')} /> ) : isNewSetup && diff --git a/src/app/(app)/claw/components/PermissionStep.tsx b/src/app/(app)/claw/components/PermissionStep.tsx index 54b351f04..8b0b90e19 100644 --- a/src/app/(app)/claw/components/PermissionStep.tsx +++ b/src/app/(app)/claw/components/PermissionStep.tsx @@ -13,8 +13,8 @@ export function PermissionStep({ }) { return ( | null; + botIdentity: BotIdentity | null; instanceRunning: boolean; mutations: ClawMutations; totalSteps?: number; @@ -60,8 +63,12 @@ export function ProvisioningStep({ patchChannelsRef.current = mutations.patchChannels.mutate; const patchExecPresetRef = useRef(mutations.patchExecPreset.mutate); patchExecPresetRef.current = mutations.patchExecPreset.mutate; + const patchBotIdentityRef = useRef(mutations.patchBotIdentity.mutate); + patchBotIdentityRef.current = mutations.patchBotIdentity.mutate; const channelTokensRef = useRef(channelTokens); channelTokensRef.current = channelTokens; + const botIdentityRef = useRef(botIdentity); + botIdentityRef.current = botIdentity; useEffect(() => { if (!instanceRunning || completedRef.current) return; @@ -93,6 +100,15 @@ export function ProvisioningStep({ patchExecPresetRef.current({ security, ask }); } + if (botIdentityRef.current) { + patchBotIdentityRef.current({ + botName: botIdentityRef.current.botName, + botNature: botIdentityRef.current.botNature, + botVibe: botIdentityRef.current.botVibe, + botEmoji: botIdentityRef.current.botEmoji, + }); + } + if (Object.keys(configPatch).length === 0) { setConfigReady(true); return; @@ -165,7 +181,7 @@ const PROVISIONING_PHRASES = [ ]; /** Pure visual shell โ€” extracted so Storybook can render it without wiring up mutations. */ -export function ProvisioningStepView({ totalSteps = 4 }: { totalSteps?: number }) { +export function ProvisioningStepView({ totalSteps = 5 }: { totalSteps?: number }) { const [phraseIndex, setPhraseIndex] = useState(() => Math.floor(Math.random() * PROVISIONING_PHRASES.length) ); @@ -190,7 +206,7 @@ export function ProvisioningStepView({ totalSteps = 4 }: { totalSteps?: number } }, []); return ( ; export function execPresetToConfig(preset: ExecPreset): { security: string; ask: string } { diff --git a/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts b/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts index 12002b625..557761de9 100644 --- a/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts +++ b/src/app/(app)/claw/components/withStatusQueryBoundary.test.ts @@ -28,6 +28,10 @@ const baseStatus: KiloClawDashboardStatus = { gmailNotificationsEnabled: false, execSecurity: null, execAsk: null, + botName: null, + botNature: null, + botVibe: null, + botEmoji: null, workerUrl: 'https://claw.kilo.ai', instanceId: null, }; diff --git a/src/hooks/useKiloClaw.ts b/src/hooks/useKiloClaw.ts index 1fa9fb906..1fc35afe8 100644 --- a/src/hooks/useKiloClaw.ts +++ b/src/hooks/useKiloClaw.ts @@ -298,6 +298,9 @@ export function useKiloClawMutations() { patchExecPreset: useMutation( trpc.kiloclaw.patchExecPreset.mutationOptions({ onSuccess: invalidateStatus }) ), + patchBotIdentity: useMutation( + trpc.kiloclaw.patchBotIdentity.mutationOptions({ onSuccess: invalidateStatus }) + ), patchOpenclawConfig: useMutation(trpc.kiloclaw.patchOpenclawConfig.mutationOptions()), disconnectGoogle: useMutation( trpc.kiloclaw.disconnectGoogle.mutationOptions({ onSuccess: invalidateStatus }) diff --git a/src/hooks/useOrgKiloClaw.ts b/src/hooks/useOrgKiloClaw.ts index 53fd4f2ad..699231957 100644 --- a/src/hooks/useOrgKiloClaw.ts +++ b/src/hooks/useOrgKiloClaw.ts @@ -361,6 +361,9 @@ export function useOrgKiloClawMutations(organizationId: string) { const rawPatchExecPreset = useMutation( trpc.organizations.kiloclaw.patchExecPreset.mutationOptions({ onSuccess: invalidateStatus }) ); + const rawPatchBotIdentity = useMutation( + trpc.organizations.kiloclaw.patchBotIdentity.mutationOptions({ onSuccess: invalidateStatus }) + ); const rawPatchOpenclawConfig = useMutation( trpc.organizations.kiloclaw.patchOpenclawConfig.mutationOptions() ); @@ -409,6 +412,7 @@ export function useOrgKiloClawMutations(organizationId: string) { removeMyPin: bindVoid(rawRemoveMyPin), writeFile: bind(rawWriteFile), patchExecPreset: bind(rawPatchExecPreset), + patchBotIdentity: bind(rawPatchBotIdentity), patchOpenclawConfig: bind(rawPatchOpenclawConfig), disconnectGoogle: bindVoid(rawDisconnectGoogle), setGmailNotifications: bind(rawSetGmailNotifications), diff --git a/src/lib/kiloclaw/kiloclaw-internal-client.ts b/src/lib/kiloclaw/kiloclaw-internal-client.ts index 6f27c815a..21ceb0efd 100644 --- a/src/lib/kiloclaw/kiloclaw-internal-client.ts +++ b/src/lib/kiloclaw/kiloclaw-internal-client.ts @@ -11,6 +11,8 @@ import type { KiloCodeConfigResponse, ChannelsPatchInput, ChannelsPatchResponse, + BotIdentityPatchInput, + BotIdentityPatchResponse, SecretsPatchInput, SecretsPatchResponse, PairingListResponse, @@ -279,6 +281,22 @@ export class KiloClawInternalClient { ); } + async patchBotIdentity( + userId: string, + patch: BotIdentityPatchInput, + instanceId?: string + ): Promise { + const params = instanceId ? `?instanceId=${encodeURIComponent(instanceId)}` : ''; + return this.request( + `/api/platform/bot-identity${params}`, + { + method: 'PATCH', + body: JSON.stringify({ userId, ...patch }), + }, + { userId } + ); + } + async patchSecrets( userId: string, input: SecretsPatchInput, diff --git a/src/lib/kiloclaw/types.ts b/src/lib/kiloclaw/types.ts index bf58c35ce..cc8a17d99 100644 --- a/src/lib/kiloclaw/types.ts +++ b/src/lib/kiloclaw/types.ts @@ -38,6 +38,20 @@ export type KiloCodeConfigResponse = { kilocodeDefaultModel: string | null; }; +export type BotIdentityPatchInput = { + botName?: string | null; + botNature?: string | null; + botVibe?: string | null; + botEmoji?: string | null; +}; + +export type BotIdentityPatchResponse = { + botName: string | null; + botNature: string | null; + botVibe: string | null; + botEmoji: string | null; +}; + /** Input to PATCH /api/platform/channels */ export type ChannelsPatchInput = { channels: { @@ -154,6 +168,10 @@ export type PlatformStatusResponse = { gmailNotificationsEnabled: boolean; execSecurity: string | null; execAsk: string | null; + botName: string | null; + botNature: string | null; + botVibe: string | null; + botEmoji: string | null; }; /** A single registry DO's entries + migration status. */ diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index 2c94a5e74..df7631517 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -257,6 +257,13 @@ const patchChannelsSchema = z.object({ slackAppToken: z.string().nullable().optional(), }); +const patchBotIdentitySchema = z.object({ + botName: z.string().trim().min(1).max(80).nullable().optional(), + botNature: z.string().trim().min(1).max(120).nullable().optional(), + botVibe: z.string().trim().min(1).max(120).nullable().optional(), + botEmoji: z.string().trim().min(1).max(16).nullable().optional(), +}); + /** * Build the worker provision payload from plaintext channel tokens. * The worker expects the flat encrypted envelope shape for channels. @@ -831,6 +838,12 @@ export const kiloclawRouter = createTRPCRouter({ return client.patchExecPreset(ctx.user.id, input, workerInstanceId(instance)); }), + patchBotIdentity: clawAccessProcedure.input(patchBotIdentitySchema).mutation(async ({ ctx, input }) => { + const instance = await getActiveInstance(ctx.user.id); + const client = new KiloClawInternalClient(); + return client.patchBotIdentity(ctx.user.id, input, workerInstanceId(instance)); + }), + /** * Generic secret patch โ€” supports both catalog secrets and custom user secrets. * diff --git a/src/routers/organizations/organization-kiloclaw-router.ts b/src/routers/organizations/organization-kiloclaw-router.ts index 2a0a22f0b..6b14f7245 100644 --- a/src/routers/organizations/organization-kiloclaw-router.ts +++ b/src/routers/organizations/organization-kiloclaw-router.ts @@ -156,6 +156,14 @@ const patchChannelsSchema = z.object({ slackAppToken: z.string().nullable().optional(), }); +const patchBotIdentitySchema = z.object({ + organizationId: z.uuid(), + botName: z.string().trim().min(1).max(80).nullable().optional(), + botNature: z.string().trim().min(1).max(120).nullable().optional(), + botVibe: z.string().trim().min(1).max(120).nullable().optional(), + botEmoji: z.string().trim().min(1).max(16).nullable().optional(), +}); + // โ”€โ”€ Helpers โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ function buildWorkerChannels(channels: z.infer['channels']) { @@ -268,6 +276,10 @@ export const organizationKiloclawRouter = createTRPCRouter({ gmailNotificationsEnabled: false, execSecurity: null, execAsk: null, + botName: null, + botNature: null, + botVibe: null, + botEmoji: null, workerUrl, name: null, instanceId: null, @@ -513,6 +525,14 @@ export const organizationKiloclawRouter = createTRPCRouter({ return client.patchExecPreset(ctx.user.id, input, workerInstanceId(instance)); }), + patchBotIdentity: organizationMemberMutationProcedure + .input(patchBotIdentitySchema) + .mutation(async ({ ctx, input }) => { + const instance = await requireOrgInstance(ctx.user.id, input.organizationId); + const client = new KiloClawInternalClient(); + return client.patchBotIdentity(ctx.user.id, input, workerInstanceId(instance)); + }), + patchSecrets: organizationMemberMutationProcedure .input( z.object({ From 26df7c4af859f4dd4825f3a9dfa40926e1b4e393 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 2 Apr 2026 13:17:37 +0200 Subject: [PATCH 2/4] chore(kiloclaw): remove planning artifact --- plan.md | 272 -------------------------------------------------------- 1 file changed, 272 deletions(-) delete mode 100644 plan.md diff --git a/plan.md b/plan.md deleted file mode 100644 index 66edf3a6e..000000000 --- a/plan.md +++ /dev/null @@ -1,272 +0,0 @@ -# Plan: Bot Identity Onboarding Step - -## Goal - -Add a new step to the KiloClaw onboarding flow that asks users about their bot's identity (name, nature, vibe, emoji). The identity data must be persisted in durable storage and written to `workspace/IDENTITY.md` on the instance filesystem so the bot is ready to go when the instance starts. - -## Data Model - -```ts -type BotIdentity = { - botName: string | null; // "What should they call you?" - botNature: string | null; // "What kind of creature are you?" - botVibe: string | null; // "Formal? Casual? Snarky? Warm?" - botEmoji: string | null; // "Everyone needs a signature." -}; -``` - -## Architecture - -Follows the same pattern as the exec permissions preset (`execSecurity`/`execAsk`): - -1. **Persist in DO state** โ€” fields survive restarts -2. **Transport via env vars** โ€” `KILOCLAW_BOT_NAME`, `KILOCLAW_BOT_NATURE`, `KILOCLAW_BOT_VIBE`, `KILOCLAW_BOT_EMOJI` -3. **Controller bootstrap writes `workspace/IDENTITY.md`** from env vars on every boot -4. **During onboarding**, the ProvisioningStep also writes the file to the running instance via a new controller endpoint (since the machine is already booted before the user fills the form) - -## Onboarding Flow (Updated) - -``` -CreateInstanceCard โ†’ BotIdentityStep (NEW) โ†’ PermissionStep โ†’ ChannelSelectionStep โ†’ ProvisioningStep โ†’ [ChannelPairingStep] โ†’ Done -``` - -Step indicator numbering: 2, 3, 4, 5, [6]. Total steps: `hasPairingStep ? 6 : 5` (was `hasPairingStep ? 5 : 4`). - -## Changes by Layer - -### 1. Frontend โ€” New `BotIdentityStep` Component - -**New file:** `src/app/(app)/claw/components/BotIdentityStep.tsx` - -Form with four fields: -- **Name** โ€” text input, placeholder "e.g. Clawdia, Byte, Archie" -- **Nature** โ€” text input, placeholder "e.g. AI assistant, digital familiar, code gremlin" -- **Vibe** โ€” text input or select with options like Casual, Formal, Snarky, Warm, Playful -- **Emoji** โ€” text input (single emoji), placeholder "e.g. ๐Ÿฆ€, ๐Ÿค–, โšก" - -"Continue" button advances to PermissionStep. All fields optional (user can skip/leave blank). - -Uses `OnboardingStepView` wrapper with `currentStep={2}`, `showProvisioningBanner={!instanceRunning}`. - -### 2. Frontend โ€” Update `ClawDashboard.tsx` - -- Add `'identity'` to the onboarding step union type: `'identity' | 'permissions' | 'channels' | 'provisioning' | 'pairing' | 'done'` -- Set initial step to `'identity'` instead of `'permissions'` -- Add state: `botIdentity` (holds `{ botName, botNature, botVibe, botEmoji }`) -- Wire up the new step in the render chain: - ``` - isNewSetup && onboardingStep === 'identity' โ†’ - ``` -- On identity step completion: store identity state, advance to `'permissions'` -- Pass `botIdentity` to `ProvisioningStep` -- Bump `totalSteps` from 4/5 to 5/6 - -### 3. Frontend โ€” Update `ProvisioningStep.tsx` - -- Accept new `botIdentity` prop -- When `instanceRunning` becomes true, alongside existing config patching: - - Call `mutations.patchBotIdentity(identity)` to persist to DO **and** write IDENTITY.md to the running instance - -### 4. Frontend โ€” Update `claw.types.ts` - -- Add `BotIdentity` type export -- Export it from claw.types so it's available to components - -### 5. Frontend โ€” Update Step Numbering - -- `PermissionStep`: `currentStep` 2 โ†’ 3 -- `ChannelSelectionStep`: `currentStep` 3 โ†’ 4 -- `ProvisioningStep`: `currentStep` 4 โ†’ 5 -- `ChannelPairingStep`: `currentStep` 5 โ†’ 6 - -### 6. Hooks โ€” `useKiloClaw.ts` - -Add mutation: -```ts -patchBotIdentity: useMutation( - trpc.kiloclaw.patchBotIdentity.mutationOptions({ onSuccess: invalidateStatus }) -), -``` - -### 7. Hooks โ€” `useOrgKiloClaw.ts` - -Add equivalent org mutation for org-scoped instances. - -### 8. tRPC Router โ€” `kiloclaw-router.ts` - -New mutation: -```ts -patchBotIdentity: clawAccessProcedure - .input(z.object({ - botName: z.string().max(100).nullable().optional(), - botNature: z.string().max(200).nullable().optional(), - botVibe: z.string().max(200).nullable().optional(), - botEmoji: z.string().max(10).nullable().optional(), - })) - .mutation(async ({ ctx, input }) => { - const instance = await getActiveInstance(ctx.user.id); - const client = new KiloClawInternalClient(); - return client.patchBotIdentity(ctx.user.id, input, workerInstanceId(instance)); - }), -``` - -### 9. tRPC Router โ€” `organization-kiloclaw-router.ts` - -Mirror the `patchBotIdentity` mutation for org-scoped access. - -### 10. Internal Client โ€” `kiloclaw-internal-client.ts` - -New method: -```ts -async patchBotIdentity( - userId: string, - patch: { botName?: string | null; botNature?: string | null; botVibe?: string | null; botEmoji?: string | null }, - instanceId?: string, -): Promise<{ ok: boolean }> { - // PATCH /api/platform/bot-identity -} -``` - -### 11. CF Worker Platform Route โ€” `platform.ts` - -New route: -``` -PATCH /api/platform/bot-identity -``` -Schema: `{ userId, botName?, botNature?, botVibe?, botEmoji? }` - -Calls `stub.updateBotIdentity(patch)` on the Instance DO. - -### 12. DO Method โ€” `kiloclaw-instance/index.ts` - -New method `updateBotIdentity()`: -1. Persist fields to DO state (`ctx.storage.put(...)`) -2. If machine is running, call `gateway.writeBotIdentity(this.s, this.env, identity)` to write IDENTITY.md to the running instance (fire-and-forget, non-fatal) -3. Return `{ ok: true }` - -### 13. DO State โ€” Schema & Types - -**`instance-config.ts` (PersistedStateSchema):** -```ts -botName: z.string().nullable().default(null), -botNature: z.string().nullable().default(null), -botVibe: z.string().nullable().default(null), -botEmoji: z.string().nullable().default(null), -``` - -**`types.ts` (InstanceMutableState):** -Add four nullable string fields. - -**`state.ts` (loadState/clearState):** -Add fields to state loading from persisted data and clearing on destroy. - -### 14. Gateway Module โ€” `gateway.ts` - -New function: -```ts -export async function writeBotIdentity( - state: InstanceMutableState, - env: KiloClawEnv, - identity: { botName?: string | null; botNature?: string | null; botVibe?: string | null; botEmoji?: string | null }, -): Promise<{ ok: boolean }> { - return callGatewayController(state, env, '/_kilo/bot-identity', 'POST', GatewayCommandResponseSchema, identity); -} -``` - -### 15. Env Vars โ€” `env.ts` - -In `buildEnvVars()`, add after the exec preset block: -```ts -if (userConfig?.botName) plainEnv.KILOCLAW_BOT_NAME = userConfig.botName; -if (userConfig?.botNature) plainEnv.KILOCLAW_BOT_NATURE = userConfig.botNature; -if (userConfig?.botVibe) plainEnv.KILOCLAW_BOT_VIBE = userConfig.botVibe; -if (userConfig?.botEmoji) plainEnv.KILOCLAW_BOT_EMOJI = userConfig.botEmoji; -``` - -Also add to `UserConfig` type. - -### 16. Controller โ€” New Endpoint `POST /_kilo/bot-identity` - -**File:** `kiloclaw/controller/src/routes/` (new route registration, or in an existing routes file) - -Accepts: `{ botName?, botNature?, botVibe?, botEmoji? }` - -Generates markdown: -```markdown -# Identity - -- **Name:** Clawdia -- **Nature:** AI assistant with a taste for automation -- **Vibe:** Casual and a little snarky -- **Emoji:** ๐Ÿฆ€ -``` - -Writes to `workspace/IDENTITY.md` (creates file + parent dirs if needed). Uses `resolveSafePath` for security, `atomicWrite` for reliability. - -Returns `{ ok: true }`. - -### 17. Controller Bootstrap โ€” `bootstrap.ts` - -In `runOnboardOrDoctor()`, after seeding TOOLS.md on first provision, also write IDENTITY.md from env vars: - -```ts -const identityEnv = { - name: env.KILOCLAW_BOT_NAME, - nature: env.KILOCLAW_BOT_NATURE, - vibe: env.KILOCLAW_BOT_VIBE, - emoji: env.KILOCLAW_BOT_EMOJI, -}; -if (Object.values(identityEnv).some(Boolean)) { - writeIdentityMd(identityEnv, deps); -} -``` - -This runs on EVERY boot (not just first provision), ensuring identity changes from the DO state are picked up on restart. Extract the markdown generation into a shared helper used by both the `/_kilo/bot-identity` endpoint and bootstrap. - -### 18. DO Config Module โ€” `config.ts` - -In the `buildUserConfig()` function that assembles the UserConfig passed to `buildEnvVars`, add identity fields: -```ts -botName: state.botName ?? undefined, -botNature: state.botNature ?? undefined, -botVibe: state.botVibe ?? undefined, -botEmoji: state.botEmoji ?? undefined, -``` - -## Tests - -| Layer | Test file | What to test | -|-------|-----------|-------------| -| DO method | `kiloclaw-instance.test.ts` | `updateBotIdentity` persists state, returns values | -| Env vars | `env.test.ts` | Identity env vars appear when set, absent when null | -| Controller endpoint | New test file or `files.test.ts` | `/_kilo/bot-identity` writes IDENTITY.md correctly | -| Bootstrap | `bootstrap.test.ts` / `config-writer.test.ts` | Identity env vars produce IDENTITY.md on boot | -| tRPC | Existing router test patterns | Mutation calls through to internal client | - -## File Summary - -| File | Action | -|------|--------| -| `src/app/(app)/claw/components/BotIdentityStep.tsx` | **Create** | -| `src/app/(app)/claw/components/ClawDashboard.tsx` | Modify | -| `src/app/(app)/claw/components/ProvisioningStep.tsx` | Modify | -| `src/app/(app)/claw/components/PermissionStep.tsx` | Modify (step number) | -| `src/app/(app)/claw/components/ChannelSelectionStep.tsx` | Modify (step number) | -| `src/app/(app)/claw/components/ChannelPairingStep.tsx` | Modify (step number) | -| `src/app/(app)/claw/components/claw.types.ts` | Modify | -| `src/hooks/useKiloClaw.ts` | Modify | -| `src/hooks/useOrgKiloClaw.ts` | Modify | -| `src/routers/kiloclaw-router.ts` | Modify | -| `src/routers/organizations/organization-kiloclaw-router.ts` | Modify | -| `src/lib/kiloclaw/kiloclaw-internal-client.ts` | Modify | -| `kiloclaw/src/schemas/instance-config.ts` | Modify | -| `kiloclaw/src/durable-objects/kiloclaw-instance/types.ts` | Modify | -| `kiloclaw/src/durable-objects/kiloclaw-instance/state.ts` | Modify | -| `kiloclaw/src/durable-objects/kiloclaw-instance/index.ts` | Modify | -| `kiloclaw/src/durable-objects/kiloclaw-instance/gateway.ts` | Modify | -| `kiloclaw/src/durable-objects/kiloclaw-instance/config.ts` | Modify | -| `kiloclaw/src/routes/platform.ts` | Modify | -| `kiloclaw/src/gateway/env.ts` | Modify | -| `kiloclaw/controller/src/bootstrap.ts` | Modify | -| `kiloclaw/controller/src/routes/` | Modify (add identity endpoint) | -| Test files | Modify/create as needed | From fba9db252ea5cf97deabbfd470656f91bc4c62e2 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 2 Apr 2026 13:22:03 +0200 Subject: [PATCH 3/4] style(kiloclaw): format bot identity files --- kiloclaw/controller/src/bootstrap.test.ts | 15 ++++++++------- kiloclaw/controller/src/routes/files.test.ts | 4 ++-- kiloclaw/controller/src/routes/files.ts | 15 +++++++++------ .../durable-objects/kiloclaw-instance/index.ts | 1 - src/routers/kiloclaw-router.ts | 12 +++++++----- 5 files changed, 26 insertions(+), 21 deletions(-) diff --git a/kiloclaw/controller/src/bootstrap.test.ts b/kiloclaw/controller/src/bootstrap.test.ts index fccedb483..ee9be751a 100644 --- a/kiloclaw/controller/src/bootstrap.test.ts +++ b/kiloclaw/controller/src/bootstrap.test.ts @@ -569,16 +569,17 @@ describe('formatBotSoulMarkdown', () => { describe('writeBotSoulFile', () => { it('writes workspace/SOUL.md and removes legacy files when present', () => { const harness = fakeDeps(); - (harness.deps.existsSync as ReturnType).mockImplementation((p: string) => - p === '/root/.openclaw/workspace/BOOTSTRAP.md' || p === '/root/.openclaw/workspace/IDENTITY.md' + (harness.deps.existsSync as ReturnType).mockImplementation( + (p: string) => + p === '/root/.openclaw/workspace/BOOTSTRAP.md' || + p === '/root/.openclaw/workspace/IDENTITY.md' ); - writeBotSoulFile( - { KILOCLAW_BOT_NAME: 'Milo', KILOCLAW_BOT_NATURE: 'Operator' }, - harness.deps - ); + writeBotSoulFile({ KILOCLAW_BOT_NAME: 'Milo', KILOCLAW_BOT_NATURE: 'Operator' }, harness.deps); - expect(harness.renameCalls.some(call => call.to === '/root/.openclaw/workspace/SOUL.md')).toBe(true); + expect(harness.renameCalls.some(call => call.to === '/root/.openclaw/workspace/SOUL.md')).toBe( + true + ); expect((harness.deps.unlinkSync as ReturnType).mock.calls).toEqual([ ['/root/.openclaw/workspace/BOOTSTRAP.md'], ['/root/.openclaw/workspace/IDENTITY.md'], diff --git a/kiloclaw/controller/src/routes/files.test.ts b/kiloclaw/controller/src/routes/files.test.ts index c81fec4c6..0e4e6ca5c 100644 --- a/kiloclaw/controller/src/routes/files.test.ts +++ b/kiloclaw/controller/src/routes/files.test.ts @@ -77,8 +77,8 @@ describe('file routes', () => { describe('POST /_kilo/bot-identity', () => { it('writes workspace/SOUL.md', async () => { - vi.mocked(fs.existsSync).mockImplementation((path: any) => - typeof path === 'string' && path.endsWith('BOOTSTRAP.md') + vi.mocked(fs.existsSync).mockImplementation( + (path: any) => typeof path === 'string' && path.endsWith('BOOTSTRAP.md') ); const res = await app.request('/_kilo/bot-identity', { diff --git a/kiloclaw/controller/src/routes/files.ts b/kiloclaw/controller/src/routes/files.ts index 79ec19df4..f588a5cdc 100644 --- a/kiloclaw/controller/src/routes/files.ts +++ b/kiloclaw/controller/src/routes/files.ts @@ -154,12 +154,15 @@ export function registerFileRoutes(app: Hono, expectedToken: string, rootDir: st fs.mkdirSync(path.dirname(targetPath), { recursive: true }); try { - atomicWrite(targetPath, formatBotSoulMarkdown({ - KILOCLAW_BOT_NAME: parsed.data.botName ?? undefined, - KILOCLAW_BOT_NATURE: parsed.data.botNature ?? undefined, - KILOCLAW_BOT_VIBE: parsed.data.botVibe ?? undefined, - KILOCLAW_BOT_EMOJI: parsed.data.botEmoji ?? undefined, - })); + atomicWrite( + targetPath, + formatBotSoulMarkdown({ + KILOCLAW_BOT_NAME: parsed.data.botName ?? undefined, + KILOCLAW_BOT_NATURE: parsed.data.botNature ?? undefined, + KILOCLAW_BOT_VIBE: parsed.data.botVibe ?? undefined, + KILOCLAW_BOT_EMOJI: parsed.data.botEmoji ?? undefined, + }) + ); for (const legacyPath of LEGACY_BOT_IDENTITY_RELATIVE_PATHS) { try { diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts b/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts index 1ccd82dab..0035d3639 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts @@ -549,7 +549,6 @@ export class KiloClawInstance extends DurableObject { }; } - async updateBotIdentity(patch: { botName?: string | null; botNature?: string | null; diff --git a/src/routers/kiloclaw-router.ts b/src/routers/kiloclaw-router.ts index df7631517..156f49725 100644 --- a/src/routers/kiloclaw-router.ts +++ b/src/routers/kiloclaw-router.ts @@ -838,11 +838,13 @@ export const kiloclawRouter = createTRPCRouter({ return client.patchExecPreset(ctx.user.id, input, workerInstanceId(instance)); }), - patchBotIdentity: clawAccessProcedure.input(patchBotIdentitySchema).mutation(async ({ ctx, input }) => { - const instance = await getActiveInstance(ctx.user.id); - const client = new KiloClawInternalClient(); - return client.patchBotIdentity(ctx.user.id, input, workerInstanceId(instance)); - }), + patchBotIdentity: clawAccessProcedure + .input(patchBotIdentitySchema) + .mutation(async ({ ctx, input }) => { + const instance = await getActiveInstance(ctx.user.id); + const client = new KiloClawInternalClient(); + return client.patchBotIdentity(ctx.user.id, input, workerInstanceId(instance)); + }), /** * Generic secret patch โ€” supports both catalog secrets and custom user secrets. From 47dfb42de29b02942c0de3793a10b760f3dcda16 Mon Sep 17 00:00:00 2001 From: Remon Oldenbeuving Date: Thu, 2 Apr 2026 13:40:56 +0200 Subject: [PATCH 4/4] fix(kiloclaw): write bot identity to IDENTITY.md instead of SOUL.md The identity form was incorrectly overwriting SOUL.md. This changes the target to IDENTITY.md so SOUL.md remains untouched, and removes IDENTITY.md from the legacy cleanup list. --- kiloclaw/controller/src/bootstrap.test.ts | 38 +++++++++---------- kiloclaw/controller/src/bootstrap.ts | 19 ++++------ kiloclaw/controller/src/routes/files.test.ts | 6 +-- kiloclaw/controller/src/routes/files.ts | 12 +++--- .../durable-objects/kiloclaw-instance.test.ts | 4 +- 5 files changed, 38 insertions(+), 41 deletions(-) diff --git a/kiloclaw/controller/src/bootstrap.test.ts b/kiloclaw/controller/src/bootstrap.test.ts index ee9be751a..d81744ee5 100644 --- a/kiloclaw/controller/src/bootstrap.test.ts +++ b/kiloclaw/controller/src/bootstrap.test.ts @@ -8,8 +8,8 @@ import { configureGitHub, configureLinear, runOnboardOrDoctor, - formatBotSoulMarkdown, - writeBotSoulFile, + formatBotIdentityMarkdown, + writeBotIdentityFile, updateToolsMdSection, GOG_SECTION_CONFIG, KILO_CLI_SECTION_CONFIG, @@ -554,35 +554,35 @@ describe('configureLinear', () => { }); }); -// ---- bot soul file ---- +// ---- bot identity file ---- -describe('formatBotSoulMarkdown', () => { - it('renders the bot soul markdown with defaults', () => { - const result = formatBotSoulMarkdown({}); +describe('formatBotIdentityMarkdown', () => { + it('renders the bot identity markdown with defaults', () => { + const result = formatBotIdentityMarkdown({}); - expect(result).toContain('# SOUL'); + expect(result).toContain('# IDENTITY'); expect(result).toContain('- Name: KiloClaw'); expect(result).toContain('- Nature: AI executive assistant'); }); }); -describe('writeBotSoulFile', () => { - it('writes workspace/SOUL.md and removes legacy files when present', () => { +describe('writeBotIdentityFile', () => { + it('writes workspace/IDENTITY.md and removes legacy files when present', () => { const harness = fakeDeps(); (harness.deps.existsSync as ReturnType).mockImplementation( - (p: string) => - p === '/root/.openclaw/workspace/BOOTSTRAP.md' || - p === '/root/.openclaw/workspace/IDENTITY.md' + (p: string) => p === '/root/.openclaw/workspace/BOOTSTRAP.md' ); - writeBotSoulFile({ KILOCLAW_BOT_NAME: 'Milo', KILOCLAW_BOT_NATURE: 'Operator' }, harness.deps); - - expect(harness.renameCalls.some(call => call.to === '/root/.openclaw/workspace/SOUL.md')).toBe( - true + writeBotIdentityFile( + { KILOCLAW_BOT_NAME: 'Milo', KILOCLAW_BOT_NATURE: 'Operator' }, + harness.deps ); + + expect( + harness.renameCalls.some(call => call.to === '/root/.openclaw/workspace/IDENTITY.md') + ).toBe(true); expect((harness.deps.unlinkSync as ReturnType).mock.calls).toEqual([ ['/root/.openclaw/workspace/BOOTSTRAP.md'], - ['/root/.openclaw/workspace/IDENTITY.md'], ]); }); }); @@ -638,7 +638,7 @@ describe('runOnboardOrDoctor', () => { const toolsCopy = harness.copyCalls.find(c => c.dest.endsWith('TOOLS.md')); expect(toolsCopy).toBeDefined(); - expect(harness.renameCalls.some(call => call.to.endsWith('/workspace/SOUL.md'))).toBe(true); + expect(harness.renameCalls.some(call => call.to.endsWith('/workspace/IDENTITY.md'))).toBe(true); }); it('runs doctor when config exists', () => { @@ -667,7 +667,7 @@ describe('runOnboardOrDoctor', () => { expect(doctorCall?.args).toContain('--fix'); expect(doctorCall?.args).toContain('--non-interactive'); expect(env.KILOCLAW_FRESH_INSTALL).toBe('false'); - expect(harness.renameCalls.some(call => call.to.endsWith('/workspace/SOUL.md'))).toBe(true); + expect(harness.renameCalls.some(call => call.to.endsWith('/workspace/IDENTITY.md'))).toBe(true); }); }); diff --git a/kiloclaw/controller/src/bootstrap.ts b/kiloclaw/controller/src/bootstrap.ts index b6b299f4e..752e3b067 100644 --- a/kiloclaw/controller/src/bootstrap.ts +++ b/kiloclaw/controller/src/bootstrap.ts @@ -23,11 +23,8 @@ const WORKSPACE_DIR = '/root/clawd'; const COMPILE_CACHE_DIR = '/var/tmp/openclaw-compile-cache'; const TOOLS_MD_SOURCE = '/usr/local/share/kiloclaw/TOOLS.md'; const TOOLS_MD_DEST = '/root/.openclaw/workspace/TOOLS.md'; -const SOUL_MD_DEST = '/root/.openclaw/workspace/SOUL.md'; -const LEGACY_BOT_IDENTITY_DESTS = [ - '/root/.openclaw/workspace/BOOTSTRAP.md', - '/root/.openclaw/workspace/IDENTITY.md', -]; +const IDENTITY_MD_DEST = '/root/.openclaw/workspace/IDENTITY.md'; +const LEGACY_BOT_IDENTITY_DESTS = ['/root/.openclaw/workspace/BOOTSTRAP.md']; const ENC_PREFIX = 'KILOCLAW_ENC_'; const VALUE_PREFIX = 'enc:v1:'; @@ -266,9 +263,9 @@ export function generateHooksToken(env: EnvLike): void { } } -export function formatBotSoulMarkdown(env: EnvLike): string { +export function formatBotIdentityMarkdown(env: EnvLike): string { const lines = [ - '# SOUL', + '# IDENTITY', '', `- Name: ${env.KILOCLAW_BOT_NAME ?? 'KiloClaw'}`, `- Nature: ${env.KILOCLAW_BOT_NATURE ?? 'AI executive assistant'}`, @@ -281,15 +278,15 @@ export function formatBotSoulMarkdown(env: EnvLike): string { return lines.join('\n'); } -export function writeBotSoulFile( +export function writeBotIdentityFile( env: EnvLike, deps: Pick< BootstrapDeps, 'mkdirSync' | 'writeFileSync' | 'renameSync' | 'unlinkSync' | 'existsSync' > = defaultDeps ): void { - deps.mkdirSync(path.dirname(SOUL_MD_DEST), { recursive: true }); - atomicWrite(SOUL_MD_DEST, formatBotSoulMarkdown(env), { + deps.mkdirSync(path.dirname(IDENTITY_MD_DEST), { recursive: true }); + atomicWrite(IDENTITY_MD_DEST, formatBotIdentityMarkdown(env), { writeFileSync: deps.writeFileSync, renameSync: deps.renameSync, unlinkSync: deps.unlinkSync, @@ -451,7 +448,7 @@ export function runOnboardOrDoctor(env: EnvLike, deps: BootstrapDeps = defaultDe env.KILOCLAW_FRESH_INSTALL = 'false'; } - writeBotSoulFile(env, deps); + writeBotIdentityFile(env, deps); } // ---- TOOLS.md bounded-section helper ---- diff --git a/kiloclaw/controller/src/routes/files.test.ts b/kiloclaw/controller/src/routes/files.test.ts index 0e4e6ca5c..575a51a86 100644 --- a/kiloclaw/controller/src/routes/files.test.ts +++ b/kiloclaw/controller/src/routes/files.test.ts @@ -76,7 +76,7 @@ describe('file routes', () => { }); describe('POST /_kilo/bot-identity', () => { - it('writes workspace/SOUL.md', async () => { + it('writes workspace/IDENTITY.md', async () => { vi.mocked(fs.existsSync).mockImplementation( (path: any) => typeof path === 'string' && path.endsWith('BOOTSTRAP.md') ); @@ -89,12 +89,12 @@ describe('file routes', () => { expect(res.status).toBe(200); expect(atomicWrite).toHaveBeenCalledWith( - `${ROOT}/workspace/SOUL.md`, + `${ROOT}/workspace/IDENTITY.md`, expect.stringContaining('- Name: Milo') ); const body = (await res.json()) as any; - expect(body.path).toBe('workspace/SOUL.md'); + expect(body.path).toBe('workspace/IDENTITY.md'); expect(vi.mocked(fs.unlinkSync)).toHaveBeenCalledWith(`${ROOT}/workspace/BOOTSTRAP.md`); }); }); diff --git a/kiloclaw/controller/src/routes/files.ts b/kiloclaw/controller/src/routes/files.ts index f588a5cdc..348e6b005 100644 --- a/kiloclaw/controller/src/routes/files.ts +++ b/kiloclaw/controller/src/routes/files.ts @@ -8,7 +8,7 @@ import { timingSafeTokenEqual } from '../auth'; import { resolveSafePath, verifyCanonicalized, SafePathError } from '../safe-path'; import { atomicWrite } from '../atomic-write'; import { backupFile } from '../backup-file'; -import { formatBotSoulMarkdown } from '../bootstrap'; +import { formatBotIdentityMarkdown } from '../bootstrap'; function computeEtag(content: string): string { return crypto.createHash('md5').update(content).digest('hex'); @@ -108,8 +108,8 @@ const BotIdentityBodySchema = z.object({ botEmoji: z.string().trim().min(1).max(16).nullable().optional(), }); -const BOT_SOUL_RELATIVE_PATH = 'workspace/SOUL.md'; -const LEGACY_BOT_IDENTITY_RELATIVE_PATHS = ['workspace/BOOTSTRAP.md', 'workspace/IDENTITY.md']; +const BOT_IDENTITY_RELATIVE_PATH = 'workspace/IDENTITY.md'; +const LEGACY_BOT_IDENTITY_RELATIVE_PATHS = ['workspace/BOOTSTRAP.md']; export function registerFileRoutes(app: Hono, expectedToken: string, rootDir: string): void { app.use('/_kilo/files/*', async (c, next) => { @@ -143,7 +143,7 @@ export function registerFileRoutes(app: Hono, expectedToken: string, rootDir: st let targetPath: string; try { - targetPath = resolveSafePath(BOT_SOUL_RELATIVE_PATH, rootDir); + targetPath = resolveSafePath(BOT_IDENTITY_RELATIVE_PATH, rootDir); } catch (err) { if (err instanceof SafePathError) { return c.json({ error: err.message }, 400); @@ -156,7 +156,7 @@ export function registerFileRoutes(app: Hono, expectedToken: string, rootDir: st try { atomicWrite( targetPath, - formatBotSoulMarkdown({ + formatBotIdentityMarkdown({ KILOCLAW_BOT_NAME: parsed.data.botName ?? undefined, KILOCLAW_BOT_NATURE: parsed.data.botNature ?? undefined, KILOCLAW_BOT_VIBE: parsed.data.botVibe ?? undefined, @@ -175,7 +175,7 @@ export function registerFileRoutes(app: Hono, expectedToken: string, rootDir: st } } - return c.json({ ok: true, path: BOT_SOUL_RELATIVE_PATH }); + return c.json({ ok: true, path: BOT_IDENTITY_RELATIVE_PATH }); } catch (err) { console.error('[files] Failed to write bot identity:', err); return c.json({ error: 'Failed to write bot identity' }, 500); diff --git a/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts b/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts index 030b6cfce..dcd951152 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance.test.ts @@ -6122,11 +6122,11 @@ describe('updateBotIdentity', () => { expect(storage._store.get('botEmoji')).toBe('๐Ÿค–'); }); - it('writes SOUL.md on running instances', async () => { + it('writes IDENTITY.md on running instances', async () => { const env = createFakeEnv(); env.FLY_APP_NAME = 'bot-app'; const fetchMock = vi.fn().mockResolvedValue( - new Response(JSON.stringify({ ok: true, path: 'workspace/SOUL.md' }), { + new Response(JSON.stringify({ ok: true, path: 'workspace/IDENTITY.md' }), { status: 200, headers: { 'content-type': 'application/json' }, })