Skip to content

Commit 34ab6f3

Browse files
authored
feat(kiloclaw): bundle Morning Briefing plugin into instance image (#2778)
* feat(kiloclaw): bundle Morning Briefing plugin into instance image * fix(kiloclaw): honor briefing timezone and serialize status writes * fix(kiloclaw): validate briefing timezone before persistence * fix(kiloclaw): recover from legacy invalid briefing timezones * fix(kiloclaw): return 400 for invalid briefing enable input * test(kiloclaw): stabilize bot identity gateway test
1 parent b39051a commit 34ab6f3

30 files changed

Lines changed: 2977 additions & 13 deletions

.github/workflows/deploy-kiloclaw.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ jobs:
6060
working-directory: services/kiloclaw
6161
run: |
6262
# Validate all expected paths exist before hashing
63-
for path in Dockerfile controller container plugins/kiloclaw-customizer skills \
63+
for path in Dockerfile controller container plugins/kiloclaw-customizer plugins/kiloclaw-morning-briefing skills \
6464
openclaw-pairing-list.js openclaw-device-pairing-list.js; do
6565
if [ ! -e "$path" ]; then
6666
echo "::error::Required path not found: $path"
@@ -69,7 +69,7 @@ jobs:
6969
done
7070
7171
CONTENT_HASH=$(
72-
find Dockerfile controller/ container/ plugins/kiloclaw-customizer/ skills/ \
72+
find Dockerfile controller/ container/ plugins/kiloclaw-customizer/ plugins/kiloclaw-morning-briefing/ skills/ \
7373
openclaw-pairing-list.js openclaw-device-pairing-list.js \
7474
-type f \
7575
| sort \

.github/workflows/push-dev-kiloclaw.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ jobs:
4040
id: content-hash
4141
working-directory: services/kiloclaw
4242
run: |
43-
for path in Dockerfile controller container plugins/kiloclaw-customizer skills \
43+
for path in Dockerfile controller container plugins/kiloclaw-customizer plugins/kiloclaw-morning-briefing skills \
4444
openclaw-pairing-list.js openclaw-device-pairing-list.js; do
4545
if [ ! -e "$path" ]; then
4646
echo "::error::Required path not found: $path"
@@ -49,7 +49,7 @@ jobs:
4949
done
5050
5151
CONTENT_HASH=$(
52-
find Dockerfile controller/ container/ plugins/kiloclaw-customizer/ skills/ \
52+
find Dockerfile controller/ container/ plugins/kiloclaw-customizer/ plugins/kiloclaw-morning-briefing/ skills/ \
5353
openclaw-pairing-list.js openclaw-device-pairing-list.js \
5454
-type f \
5555
| sort \

services/kiloclaw/Dockerfile

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ RUN GOBIN=/usr/local/bin go install github.com/steipete/gogcli/cmd/gog@v0.12.0 \
130130
RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin sh \
131131
&& uv --version
132132

133-
# ── Builder stage: package customizer plugin ───────────────────────────
133+
# ── Builder stage: package custom plugins ───────────────────────────
134134
# Build the vendored plugin tarball in an isolated stage so dev-time
135135
# dependencies do not inflate the runtime image layers.
136136
FROM runtime AS customizer-builder
@@ -143,6 +143,16 @@ RUN cd /tmp/kiloclaw-customizer \
143143
&& cd / \
144144
&& rm -rf /tmp/kiloclaw-customizer /root/.npm
145145

146+
FROM runtime AS morning-briefing-builder
147+
148+
COPY plugins/kiloclaw-morning-briefing/ /tmp/kiloclaw-morning-briefing/
149+
RUN cd /tmp/kiloclaw-morning-briefing \
150+
&& npm install --include=dev --legacy-peer-deps --no-fund --no-audit \
151+
&& PKG_TGZ="$(npm pack --silent)" \
152+
&& mv "${PKG_TGZ}" /tmp/kiloclaw-morning-briefing.tgz \
153+
&& cd / \
154+
&& rm -rf /tmp/kiloclaw-morning-briefing /root/.npm
155+
146156
# ── Builder stage: compile the controller with Bun ────────────────────
147157
# Bun is only needed to bundle the controller TypeScript into a single JS
148158
# file. By building in a separate stage, Bun (~100MB) stays out of the
@@ -175,8 +185,10 @@ FROM runtime AS final
175185
# Also drop /etc/gitconfig (set earlier for the build's ssh→https rewrite) so
176186
# it doesn't persist in the runtime image.
177187
COPY --from=customizer-builder /tmp/kiloclaw-customizer.tgz /tmp/kiloclaw-customizer.tgz
178-
RUN npm install -g --legacy-peer-deps /tmp/kiloclaw-customizer.tgz \
188+
COPY --from=morning-briefing-builder /tmp/kiloclaw-morning-briefing.tgz /tmp/kiloclaw-morning-briefing.tgz
189+
RUN npm install -g --legacy-peer-deps /tmp/kiloclaw-customizer.tgz /tmp/kiloclaw-morning-briefing.tgz \
179190
&& rm -f /tmp/kiloclaw-customizer.tgz \
191+
&& rm -f /tmp/kiloclaw-morning-briefing.tgz \
180192
&& rm -f /etc/gitconfig \
181193
&& rm -rf /root/.npm
182194

services/kiloclaw/Dockerfile.local

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ EXPOSE 18789
134134
# feature flags) lives in the controller's bootstrap module — no shell wrapper needed.
135135
CMD ["node", "/usr/local/bin/kiloclaw-controller.js"]
136136

137-
# Build customizer plugin tarball in isolated stage so dev dependencies
137+
# Build custom plugins tarballs in isolated stages so dev dependencies
138138
# are not persisted in the runtime image.
139139
FROM runtime AS customizer-builder
140140

@@ -146,9 +146,21 @@ RUN cd /tmp/kiloclaw-customizer \
146146
&& cd / \
147147
&& rm -rf /tmp/kiloclaw-customizer /root/.npm
148148

149+
FROM runtime AS morning-briefing-builder
150+
151+
COPY plugins/kiloclaw-morning-briefing/ /tmp/kiloclaw-morning-briefing/
152+
RUN cd /tmp/kiloclaw-morning-briefing \
153+
&& npm install --include=dev --legacy-peer-deps --no-fund --no-audit \
154+
&& PKG_TGZ="$(npm pack --silent)" \
155+
&& mv "${PKG_TGZ}" /tmp/kiloclaw-morning-briefing.tgz \
156+
&& cd / \
157+
&& rm -rf /tmp/kiloclaw-morning-briefing /root/.npm
158+
149159
FROM runtime AS final
150160

151161
COPY --from=customizer-builder /tmp/kiloclaw-customizer.tgz /tmp/kiloclaw-customizer.tgz
152-
RUN npm install -g --legacy-peer-deps /tmp/kiloclaw-customizer.tgz \
162+
COPY --from=morning-briefing-builder /tmp/kiloclaw-morning-briefing.tgz /tmp/kiloclaw-morning-briefing.tgz
163+
RUN npm install -g --legacy-peer-deps /tmp/kiloclaw-customizer.tgz /tmp/kiloclaw-morning-briefing.tgz \
153164
&& rm -f /tmp/kiloclaw-customizer.tgz \
165+
&& rm -f /tmp/kiloclaw-morning-briefing.tgz \
154166
&& rm -rf /root/.npm

services/kiloclaw/controller/src/config-writer.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -615,15 +615,25 @@ describe('generateBaseConfig', () => {
615615
expect(config.plugins.load.paths).toContain(
616616
'/usr/local/lib/node_modules/@kiloclaw/kiloclaw-customizer'
617617
);
618+
expect(config.plugins.entries['kiloclaw-morning-briefing'].enabled).toBe(true);
619+
expect(config.plugins.load.paths).toContain(
620+
'/usr/local/lib/node_modules/@kiloclaw/kiloclaw-morning-briefing'
621+
);
618622
});
619623

620624
it('does not duplicate KiloClaw customizer plugin path on repeated generateBaseConfig calls', () => {
621625
const existing = JSON.stringify({
622626
plugins: {
623627
load: {
624-
paths: ['/usr/local/lib/node_modules/@kiloclaw/kiloclaw-customizer'],
628+
paths: [
629+
'/usr/local/lib/node_modules/@kiloclaw/kiloclaw-customizer',
630+
'/usr/local/lib/node_modules/@kiloclaw/kiloclaw-morning-briefing',
631+
],
632+
},
633+
entries: {
634+
'kiloclaw-customizer': { enabled: true },
635+
'kiloclaw-morning-briefing': { enabled: true },
625636
},
626-
entries: { 'kiloclaw-customizer': { enabled: true } },
627637
},
628638
});
629639
const { deps } = fakeDeps(existing);
@@ -632,6 +642,8 @@ describe('generateBaseConfig', () => {
632642
const pluginPath = '/usr/local/lib/node_modules/@kiloclaw/kiloclaw-customizer';
633643
const paths = config.plugins.load.paths as string[];
634644
expect(paths.filter(p => p === pluginPath)).toHaveLength(1);
645+
const morningPluginPath = '/usr/local/lib/node_modules/@kiloclaw/kiloclaw-morning-briefing';
646+
expect(paths.filter(p => p === morningPluginPath)).toHaveLength(1);
635647
});
636648

637649
it('adds KiloClaw customizer to an existing plugin allowlist', () => {
@@ -644,6 +656,7 @@ describe('generateBaseConfig', () => {
644656
const config = generateBaseConfig(minimalEnv(), '/tmp/openclaw.json', deps);
645657

646658
expect(config.plugins.allow).toContain('kiloclaw-customizer');
659+
expect(config.plugins.allow).toContain('kiloclaw-morning-briefing');
647660
});
648661

649662
it('configures Telegram channel', () => {

services/kiloclaw/controller/src/config-writer.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,9 @@ const ONBOARD_FLAGS = [
7575

7676
const KILOCLAW_CUSTOMIZER_PLUGIN_ID = 'kiloclaw-customizer';
7777
const KILOCLAW_CUSTOMIZER_PLUGIN_PATH = '/usr/local/lib/node_modules/@kiloclaw/kiloclaw-customizer';
78+
const KILOCLAW_MORNING_BRIEFING_PLUGIN_ID = 'kiloclaw-morning-briefing';
79+
const KILOCLAW_MORNING_BRIEFING_PLUGIN_PATH =
80+
'/usr/local/lib/node_modules/@kiloclaw/kiloclaw-morning-briefing';
7881
const KILO_EXA_PROVIDER_ID = 'kilo-exa';
7982

8083
type KiloExaSearchMode = 'kilo-proxy' | 'disabled';
@@ -402,6 +405,19 @@ export function generateBaseConfig(
402405
customizerPluginConfig.webSearch = customizerWebSearchConfig;
403406
config.plugins.entries[KILOCLAW_CUSTOMIZER_PLUGIN_ID].config = customizerPluginConfig;
404407

408+
if (!(config.plugins.load.paths as string[]).includes(KILOCLAW_MORNING_BRIEFING_PLUGIN_PATH)) {
409+
(config.plugins.load.paths as string[]).push(KILOCLAW_MORNING_BRIEFING_PLUGIN_PATH);
410+
}
411+
if (
412+
Array.isArray(config.plugins.allow) &&
413+
!config.plugins.allow.includes(KILOCLAW_MORNING_BRIEFING_PLUGIN_ID)
414+
) {
415+
config.plugins.allow.push(KILOCLAW_MORNING_BRIEFING_PLUGIN_ID);
416+
}
417+
config.plugins.entries[KILOCLAW_MORNING_BRIEFING_PLUGIN_ID] =
418+
config.plugins.entries[KILOCLAW_MORNING_BRIEFING_PLUGIN_ID] ?? {};
419+
config.plugins.entries[KILOCLAW_MORNING_BRIEFING_PLUGIN_ID].enabled = true;
420+
405421
// Telegram
406422
if (env.TELEGRAM_BOT_TOKEN) {
407423
const dmPolicy = env.TELEGRAM_DM_POLICY || 'pairing';

services/kiloclaw/controller/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { registerGmailPushRoute } from './routes/gmail-push';
2323
import { registerInboundEmailRoute } from './routes/inbound-email';
2424
import { registerFileRoutes } from './routes/files';
2525
import { registerKiloCliRunRoutes } from './routes/kilo-cli-run';
26+
import { registerMorningBriefingRoutes } from './routes/morning-briefing';
2627
import { CONTROLLER_COMMIT, CONTROLLER_VERSION } from './version';
2728
import { writeKiloCliConfig } from './kilo-cli-config';
2829
import { writeGogCredentials } from './gog-credentials';
@@ -378,6 +379,7 @@ export async function startController(env: NodeJS.ProcessEnv = process.env): Pro
378379
const honoApp = new Hono();
379380
registerHealthRoute(honoApp, supervisor, config.expectedToken, controllerState);
380381
registerGatewayRoutes(honoApp, supervisor, config.expectedToken);
382+
registerMorningBriefingRoutes(honoApp, supervisor, config.expectedToken);
381383
registerConfigRoutes(honoApp, supervisor, config.expectedToken);
382384
registerPairingRoutes(honoApp, pairingCache, config.expectedToken);
383385
registerEnvRoutes(honoApp, supervisor, config.expectedToken);
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
2+
import { Hono } from 'hono';
3+
import { registerMorningBriefingRoutes } from './morning-briefing';
4+
import type { Supervisor } from '../supervisor';
5+
6+
function createRunningSupervisor(): Supervisor {
7+
const state = 'running' as const;
8+
return {
9+
start: vi.fn(async () => true),
10+
stop: vi.fn(async () => true),
11+
restart: vi.fn(async () => true),
12+
shutdown: vi.fn(async () => undefined),
13+
signal: vi.fn(() => true),
14+
getState: vi.fn(() => state),
15+
getStats: vi.fn(() => ({
16+
state,
17+
pid: 1,
18+
uptime: 1,
19+
restarts: 0,
20+
lastExit: null,
21+
})),
22+
};
23+
}
24+
25+
afterEach(() => {
26+
vi.unstubAllGlobals();
27+
vi.restoreAllMocks();
28+
});
29+
30+
describe('morning briefing controller routes', () => {
31+
it('enforces bearer auth before proxying', async () => {
32+
const app = new Hono();
33+
registerMorningBriefingRoutes(app, createRunningSupervisor(), 'expected-token');
34+
35+
const response = await app.request('/_kilo/morning-briefing/status');
36+
expect(response.status).toBe(401);
37+
expect(await response.json()).toEqual({ error: 'Unauthorized' });
38+
});
39+
40+
it('forwards expected gateway token to proxied route', async () => {
41+
const app = new Hono();
42+
const fetchMock = vi.fn().mockResolvedValue(
43+
new Response(JSON.stringify({ ok: true }), {
44+
status: 200,
45+
headers: { 'content-type': 'application/json' },
46+
})
47+
);
48+
vi.stubGlobal('fetch', fetchMock);
49+
50+
registerMorningBriefingRoutes(app, createRunningSupervisor(), 'expected-token');
51+
52+
const response = await app.request('/_kilo/morning-briefing/enable', {
53+
method: 'POST',
54+
headers: {
55+
authorization: 'Bearer expected-token',
56+
'content-type': 'application/json',
57+
},
58+
body: JSON.stringify({ cron: '0 7 * * *', timezone: 'America/Chicago' }),
59+
});
60+
61+
expect(response.status).toBe(200);
62+
expect(fetchMock).toHaveBeenCalledWith(
63+
'http://127.0.0.1:3001/api/plugins/kiloclaw-morning-briefing/enable',
64+
expect.objectContaining({
65+
method: 'POST',
66+
headers: expect.objectContaining({
67+
authorization: 'Bearer expected-token',
68+
'content-type': 'application/json',
69+
}),
70+
})
71+
);
72+
});
73+
});
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import type { Hono } from 'hono';
2+
import { timingSafeTokenEqual } from '../auth';
3+
import { getBearerToken } from './gateway';
4+
import type { Supervisor } from '../supervisor';
5+
6+
const MORNING_BRIEFING_PREFIX = '/api/plugins/kiloclaw-morning-briefing';
7+
8+
async function readJsonBody(c: { req: { json: () => Promise<unknown> } }): Promise<unknown> {
9+
try {
10+
return await c.req.json();
11+
} catch {
12+
return {};
13+
}
14+
}
15+
16+
async function proxyMorningBriefingRoute(params: {
17+
supervisor: Supervisor;
18+
gatewayToken: string;
19+
path: string;
20+
method: 'GET' | 'POST';
21+
body?: unknown;
22+
}): Promise<Response> {
23+
if (params.supervisor.getState() !== 'running') {
24+
return new Response(JSON.stringify({ error: 'Gateway not running' }), {
25+
status: 503,
26+
headers: { 'content-type': 'application/json' },
27+
});
28+
}
29+
30+
try {
31+
return await fetch(`http://127.0.0.1:3001${MORNING_BRIEFING_PREFIX}${params.path}`, {
32+
method: params.method,
33+
headers: {
34+
authorization: `Bearer ${params.gatewayToken}`,
35+
'content-type': 'application/json',
36+
},
37+
body: params.body !== undefined ? JSON.stringify(params.body) : undefined,
38+
});
39+
} catch (error) {
40+
console.error('[controller] morning briefing proxy failed:', error);
41+
return new Response(JSON.stringify({ error: 'Failed to reach gateway' }), {
42+
status: 502,
43+
headers: { 'content-type': 'application/json' },
44+
});
45+
}
46+
}
47+
48+
export function registerMorningBriefingRoutes(
49+
app: Hono,
50+
supervisor: Supervisor,
51+
expectedToken: string
52+
): void {
53+
app.use('/_kilo/morning-briefing/*', async (c, next) => {
54+
const token = getBearerToken(c.req.header('authorization'));
55+
if (!timingSafeTokenEqual(token, expectedToken)) {
56+
return c.json({ error: 'Unauthorized' }, 401);
57+
}
58+
await next();
59+
});
60+
61+
app.get('/_kilo/morning-briefing/status', async c => {
62+
const response = await proxyMorningBriefingRoute({
63+
supervisor,
64+
gatewayToken: expectedToken,
65+
path: '/status',
66+
method: 'GET',
67+
});
68+
return response;
69+
});
70+
71+
app.post('/_kilo/morning-briefing/enable', async c => {
72+
const body = await readJsonBody(c);
73+
const response = await proxyMorningBriefingRoute({
74+
supervisor,
75+
gatewayToken: expectedToken,
76+
path: '/enable',
77+
method: 'POST',
78+
body,
79+
});
80+
return response;
81+
});
82+
83+
app.post('/_kilo/morning-briefing/disable', async c => {
84+
const response = await proxyMorningBriefingRoute({
85+
supervisor,
86+
gatewayToken: expectedToken,
87+
path: '/disable',
88+
method: 'POST',
89+
body: {},
90+
});
91+
return response;
92+
});
93+
94+
app.post('/_kilo/morning-briefing/run', async c => {
95+
const response = await proxyMorningBriefingRoute({
96+
supervisor,
97+
gatewayToken: expectedToken,
98+
path: '/run',
99+
method: 'POST',
100+
body: {},
101+
});
102+
return response;
103+
});
104+
105+
app.get('/_kilo/morning-briefing/read/today', async c => {
106+
const response = await proxyMorningBriefingRoute({
107+
supervisor,
108+
gatewayToken: expectedToken,
109+
path: '/read/today',
110+
method: 'GET',
111+
});
112+
return response;
113+
});
114+
115+
app.get('/_kilo/morning-briefing/read/yesterday', async c => {
116+
const response = await proxyMorningBriefingRoute({
117+
supervisor,
118+
gatewayToken: expectedToken,
119+
path: '/read/yesterday',
120+
method: 'GET',
121+
});
122+
return response;
123+
});
124+
}

0 commit comments

Comments
 (0)