Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions kiloclaw/controller/src/bootstrap.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
configureGitHub,
configureLinear,
runOnboardOrDoctor,
formatBotIdentityMarkdown,
writeBotIdentityFile,
updateToolsMdSection,
GOG_SECTION_CONFIG,
KILO_CLI_SECTION_CONFIG,
Expand Down Expand Up @@ -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<typeof vi.fn>).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<typeof vi.fn>).mock.calls).toEqual([
['/root/.openclaw/workspace/BOOTSTRAP.md'],
]);
});
});

// ---- runOnboardOrDoctor ----

describe('runOnboardOrDoctor', () => {
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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);
});
});

Expand Down
43 changes: 43 additions & 0 deletions kiloclaw/controller/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:';
Expand Down Expand Up @@ -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 ----

/**
Expand Down Expand Up @@ -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 ----
Expand Down
35 changes: 35 additions & 0 deletions kiloclaw/controller/src/routes/files.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
}));
Expand Down Expand Up @@ -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', () => {
Expand Down
73 changes: 73 additions & 0 deletions kiloclaw/controller/src/routes/files.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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'));
Expand All @@ -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 });
Expand Down
6 changes: 6 additions & 0 deletions kiloclaw/controller/src/safe-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
5 changes: 5 additions & 0 deletions kiloclaw/src/durable-objects/gateway-controller-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
59 changes: 59 additions & 0 deletions kiloclaw/src/durable-objects/kiloclaw-instance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
4 changes: 4 additions & 0 deletions kiloclaw/src/durable-objects/kiloclaw-instance/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
Loading
Loading