diff --git a/kiloclaw/controller/src/bootstrap.test.ts b/kiloclaw/controller/src/bootstrap.test.ts index e58241d5a..d81744ee5 100644 --- a/kiloclaw/controller/src/bootstrap.test.ts +++ b/kiloclaw/controller/src/bootstrap.test.ts @@ -8,6 +8,8 @@ import { configureGitHub, configureLinear, runOnboardOrDoctor, + formatBotIdentityMarkdown, + writeBotIdentityFile, updateToolsMdSection, GOG_SECTION_CONFIG, KILO_CLI_SECTION_CONFIG, @@ -552,6 +554,39 @@ describe('configureLinear', () => { }); }); +// ---- bot identity file ---- + +describe('formatBotIdentityMarkdown', () => { + it('renders the bot identity markdown with defaults', () => { + const result = formatBotIdentityMarkdown({}); + + expect(result).toContain('# IDENTITY'); + expect(result).toContain('- Name: KiloClaw'); + expect(result).toContain('- Nature: AI executive assistant'); + }); +}); + +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' + ); + + 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'], + ]); + }); +}); + // ---- runOnboardOrDoctor ---- describe('runOnboardOrDoctor', () => { @@ -603,6 +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/IDENTITY.md'))).toBe(true); }); it('runs doctor when config exists', () => { @@ -631,6 +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/IDENTITY.md'))).toBe(true); }); }); diff --git a/kiloclaw/controller/src/bootstrap.ts b/kiloclaw/controller/src/bootstrap.ts index 0359c058b..752e3b067 100644 --- a/kiloclaw/controller/src/bootstrap.ts +++ b/kiloclaw/controller/src/bootstrap.ts @@ -23,6 +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 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:'; @@ -261,6 +263,45 @@ export function generateHooksToken(env: EnvLike): void { } } +export function formatBotIdentityMarkdown(env: EnvLike): string { + const lines = [ + '# IDENTITY', + '', + `- 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 writeBotIdentityFile( + env: EnvLike, + deps: Pick< + BootstrapDeps, + 'mkdirSync' | 'writeFileSync' | 'renameSync' | 'unlinkSync' | 'existsSync' + > = defaultDeps +): void { + deps.mkdirSync(path.dirname(IDENTITY_MD_DEST), { recursive: true }); + atomicWrite(IDENTITY_MD_DEST, formatBotIdentityMarkdown(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 +447,8 @@ export function runOnboardOrDoctor(env: EnvLike, deps: BootstrapDeps = defaultDe env.KILOCLAW_FRESH_INSTALL = 'false'; } + + 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 d13255588..575a51a86 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/IDENTITY.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/IDENTITY.md`, + expect.stringContaining('- Name: Milo') + ); + + const body = (await res.json()) as any; + expect(body.path).toBe('workspace/IDENTITY.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..348e6b005 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 { formatBotIdentityMarkdown } 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_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) => { const token = getBearerToken(c.req.header('authorization')); @@ -109,6 +120,68 @@ 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_IDENTITY_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, + formatBotIdentityMarkdown({ + 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_IDENTITY_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..dcd951152 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 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/IDENTITY.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..0035d3639 100644 --- a/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts +++ b/kiloclaw/src/durable-objects/kiloclaw-instance/index.ts @@ -549,6 +549,59 @@ 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 +1466,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 +1509,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/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..156f49725 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,14 @@ 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({