From c17aab32c24610168cebc973aa6a2f4ede6a4ab8 Mon Sep 17 00:00:00 2001 From: vivekchand Date: Wed, 6 May 2026 10:38:18 +0200 Subject: [PATCH 01/12] feat(kiloclaw): pre-install ClawMetry observability with auto-sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every KiloClaw instance now ships with the ClawMetry sync daemon pre-installed. When the operator sets CLAWMETRY_PARTNER_KEY on the instance, bootstrap auto-provisions a free ClawMetry account for the user (using KILOCLAW_USER_EMAIL or GITHUB_EMAIL) and starts the sync daemon — agent activity flows to app.clawmetry.com so users get a real-time dashboard of their KiloClaw runtime. Until the partner key is set, the integration is a silent no-op: zero behavior change, the package is installed but inert. The cloud- side prerequisite (ClawMetry's /api/partner/kiloclaw/provision route + partner key issuance) is documented in services/kiloclaw/docs/clawmetry-integration.md. - Dockerfile: install python3-pip + clawmetry==0.12.161 - bootstrap.ts: new provisionClawMetrySync step (gated, fail-soft) - bootstrap.test.ts: 7 new tests covering disabled/missing-key/missing- email/success/email-fallback/network-error/server-error/api-base- override paths - docs/clawmetry-integration.md: activation, env vars, verification, cloud-side prerequisites --- services/kiloclaw/Dockerfile | 23 ++- .../kiloclaw/controller/src/bootstrap.test.ts | 181 +++++++++++++++++- services/kiloclaw/controller/src/bootstrap.ts | 107 +++++++++++ .../kiloclaw/docs/clawmetry-integration.md | 83 ++++++++ 4 files changed, 392 insertions(+), 2 deletions(-) create mode 100644 services/kiloclaw/docs/clawmetry-integration.md diff --git a/services/kiloclaw/Dockerfile b/services/kiloclaw/Dockerfile index 415249e289..d22ab88acb 100644 --- a/services/kiloclaw/Dockerfile +++ b/services/kiloclaw/Dockerfile @@ -9,7 +9,7 @@ ENV NODE_VERSION=24.15.0 RUN apt-get update \ && apt-get install -y --no-install-recommends \ ca-certificates curl gnupg git xz-utils unzip jq ripgrep rsync zstd \ - build-essential python3 ffmpeg tmux chromium \ + build-essential python3 python3-venv ffmpeg tmux chromium \ && ARCH="$(dpkg --print-architecture)" \ && case "${ARCH}" in \ amd64) NODE_ARCH="x64" ;; \ @@ -74,6 +74,27 @@ RUN npm install -g openclaw@2026.4.23 \ && echo "OK: patched actionRequiresTarget in $(basename "$CT_FILE")" \ && openclaw --version +# Install ClawMetry — observability dashboard for the OpenClaw runtime. +# Pre-installed so every KiloClaw instance has it available; the controller's +# bootstrap step `provisionClawMetrySync` activates it (one-time account +# auto-provisioning + sync daemon) when CLAWMETRY_PARTNER_KEY is set on the +# instance env. +# +# Uses the upstream one-line installer instead of pinning a PyPI version. +# This delegates version selection + bootstrap (venv layout, symlink, future +# install steps) to ClawMetry's install.sh so it can evolve without a PR +# back to this repo. Trade-off: builds are not bit-reproducible across +# rebuilds — the image picks up whatever version PyPI has at build time. +# That's intentional; the integration is best-effort observability and the +# sync daemon is fail-soft (see provisionClawMetrySync). +# +# install.sh creates a venv at /root/.clawmetry and symlinks the binary at +# /root/.local/bin/clawmetry. We add that to PATH so subsequent build steps +# and runtime callers can invoke `clawmetry` by name. +ENV PATH="/root/.local/bin:${PATH}" +RUN curl -fsSL https://clawmetry.com/install.sh | bash \ + && clawmetry --version + # Bake bundled plugin runtime deps into the image so first boot is pure # startup — no npm install from `openclaw doctor` on shared-cpu Fly machines. # Each step writes to a temp file and is chained with && so failures break diff --git a/services/kiloclaw/controller/src/bootstrap.test.ts b/services/kiloclaw/controller/src/bootstrap.test.ts index 1d68684756..3a73c80036 100644 --- a/services/kiloclaw/controller/src/bootstrap.test.ts +++ b/services/kiloclaw/controller/src/bootstrap.test.ts @@ -7,6 +7,7 @@ import { generateHooksToken, configureGitHub, configureLinear, + provisionClawMetrySync, runOnboardOrDoctor, formatBotIdentityMarkdown, writeBotIdentityFile, @@ -566,6 +567,174 @@ describe('configureLinear', () => { }); }); +// ---- provisionClawMetrySync ---- + +describe('provisionClawMetrySync', () => { + it('skips silently when CLAWMETRY_PARTNER_KEY is missing', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { deps, execCalls, writeCalls } = fakeDeps(); + + provisionClawMetrySync({ KILOCLAW_USER_EMAIL: 'a@b.c' }, deps); + + expect(execCalls).toEqual([]); + expect(writeCalls).toEqual([]); + expect(logSpy).toHaveBeenCalledWith( + 'ClawMetry: not configured (CLAWMETRY_PARTNER_KEY missing)' + ); + logSpy.mockRestore(); + }); + + it('skips when KILOCLAW_CLAWMETRY_DISABLED=true even with key + email', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { deps, execCalls } = fakeDeps(); + + provisionClawMetrySync( + { + KILOCLAW_CLAWMETRY_DISABLED: 'true', + CLAWMETRY_PARTNER_KEY: 'pk_test', + KILOCLAW_USER_EMAIL: 'a@b.c', + }, + deps + ); + + expect(execCalls).toEqual([]); + expect(logSpy).toHaveBeenCalledWith('ClawMetry: disabled via KILOCLAW_CLAWMETRY_DISABLED'); + logSpy.mockRestore(); + }); + + it('warns and exits when no email is available', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { deps, execCalls } = fakeDeps(); + + provisionClawMetrySync({ CLAWMETRY_PARTNER_KEY: 'pk_test' }, deps); + + expect(execCalls).toEqual([]); + expect(warnSpy).toHaveBeenCalledWith( + 'ClawMetry: skipping — neither KILOCLAW_USER_EMAIL nor GITHUB_EMAIL is set' + ); + warnSpy.mockRestore(); + }); + + it('provisions a token, writes it to disk with 600, and starts the daemon', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + const { deps, execCalls, writeCalls, chmodCalls, mkdirCalls } = fakeDeps(); + deps.execFileSync = vi.fn((cmd: string, args: string[]) => { + execCalls.push({ cmd, args }); + if (cmd === 'curl') { + return JSON.stringify({ ok: true, cm_token: 'cm_abc123' }); + } + return ''; + }); + + provisionClawMetrySync( + { + CLAWMETRY_PARTNER_KEY: 'pk_test', + KILOCLAW_USER_EMAIL: 'user@kilocode.ai', + }, + deps + ); + + const curlCall = execCalls.find(c => c.cmd === 'curl'); + expect(curlCall).toBeDefined(); + expect(curlCall?.args).toContain('https://app.clawmetry.com/api/partner/kiloclaw/provision'); + expect(curlCall?.args).toContain('X-Partner-Key: pk_test'); + expect(curlCall?.args.some(a => a.includes('user@kilocode.ai'))).toBe(true); + + expect(mkdirCalls).toContain('/root/.clawmetry'); + expect(writeCalls).toEqual([{ path: '/root/.clawmetry/token', data: 'cm_abc123' }]); + expect(chmodCalls).toEqual([{ path: '/root/.clawmetry/token', mode: 0o600 }]); + + const daemonCall = execCalls.find(c => c.cmd === 'sh'); + expect(daemonCall).toBeDefined(); + expect(daemonCall?.args[1]).toContain('clawmetry sync'); + expect(daemonCall?.args[1]).toContain('nohup'); + + expect(logSpy).toHaveBeenCalledWith('ClawMetry: sync daemon started for user@kilocode.ai'); + logSpy.mockRestore(); + }); + + it('falls back to GITHUB_EMAIL when KILOCLAW_USER_EMAIL is absent', () => { + const { deps, execCalls } = fakeDeps(); + deps.execFileSync = vi.fn((cmd: string, args: string[]) => { + execCalls.push({ cmd, args }); + if (cmd === 'curl') return JSON.stringify({ ok: true, cm_token: 'cm_x' }); + return ''; + }); + + provisionClawMetrySync( + { CLAWMETRY_PARTNER_KEY: 'pk_test', GITHUB_EMAIL: 'fallback@x.com' }, + deps + ); + + const curlCall = execCalls.find(c => c.cmd === 'curl'); + expect(curlCall?.args.some(a => a.includes('fallback@x.com'))).toBe(true); + }); + + it('warns but does not throw when provisioning request errors', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { deps, execCalls, writeCalls } = fakeDeps(); + deps.execFileSync = vi.fn((cmd: string, args: string[]) => { + execCalls.push({ cmd, args }); + if (cmd === 'curl') throw new Error('network down'); + return ''; + }); + + expect(() => + provisionClawMetrySync( + { CLAWMETRY_PARTNER_KEY: 'pk_test', KILOCLAW_USER_EMAIL: 'a@b.c' }, + deps + ) + ).not.toThrow(); + expect(writeCalls).toEqual([]); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('ClawMetry: provisioning request failed') + ); + warnSpy.mockRestore(); + }); + + it('warns and skips daemon spawn when partner endpoint returns ok:false', () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { deps, execCalls, writeCalls } = fakeDeps(); + deps.execFileSync = vi.fn((cmd: string, args: string[]) => { + execCalls.push({ cmd, args }); + if (cmd === 'curl') { + return JSON.stringify({ ok: false, error: 'partner key invalid' }); + } + return ''; + }); + + provisionClawMetrySync({ CLAWMETRY_PARTNER_KEY: 'pk_bad', KILOCLAW_USER_EMAIL: 'a@b.c' }, deps); + + expect(writeCalls).toEqual([]); + expect(execCalls.find(c => c.cmd === 'sh')).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith('ClawMetry: provisioning failed — partner key invalid'); + warnSpy.mockRestore(); + }); + + it('honors CLAWMETRY_API_BASE override (for staging / self-hosted)', () => { + const { deps, execCalls } = fakeDeps(); + deps.execFileSync = vi.fn((cmd: string, args: string[]) => { + execCalls.push({ cmd, args }); + if (cmd === 'curl') return JSON.stringify({ ok: true, cm_token: 'cm_x' }); + return ''; + }); + + provisionClawMetrySync( + { + CLAWMETRY_PARTNER_KEY: 'pk_test', + KILOCLAW_USER_EMAIL: 'a@b.c', + CLAWMETRY_API_BASE: 'https://staging.clawmetry.com', + }, + deps + ); + + const curlCall = execCalls.find(c => c.cmd === 'curl'); + expect(curlCall?.args).toContain( + 'https://staging.clawmetry.com/api/partner/kiloclaw/provision' + ); + }); +}); + // ---- bot identity file ---- describe('formatBotIdentityMarkdown', () => { @@ -1736,6 +1905,7 @@ describe('bootstrapNonCritical', () => { expect(phases).toEqual([ 'github', 'linear', + 'clawmetry-sync', 'gateway-client-device-scopes', 'onboard', 'tools-md', @@ -1772,6 +1942,7 @@ describe('bootstrapNonCritical', () => { expect(phases).toEqual([ 'github', 'linear', + 'clawmetry-sync', 'gateway-client-device-scopes', 'onboard', 'tools-md', @@ -1799,6 +1970,7 @@ describe('bootstrapNonCritical', () => { expect(phases).toEqual([ 'github', 'linear', + 'clawmetry-sync', 'gateway-client-device-scopes', 'onboard', 'tools-md', @@ -1833,7 +2005,13 @@ describe('bootstrapNonCritical', () => { ); expect(result).toEqual({ ok: false, phase: 'doctor', error: 'doctor exited 1' }); - expect(phases).toEqual(['github', 'linear', 'gateway-client-device-scopes', 'doctor']); + expect(phases).toEqual([ + 'github', + 'linear', + 'clawmetry-sync', + 'gateway-client-device-scopes', + 'doctor', + ]); }); }); @@ -1865,6 +2043,7 @@ describe('bootstrap', () => { 'feature-flags', 'github', 'linear', + 'clawmetry-sync', 'gateway-client-device-scopes', 'onboard', 'tools-md', diff --git a/services/kiloclaw/controller/src/bootstrap.ts b/services/kiloclaw/controller/src/bootstrap.ts index 48f1bf1ed7..3710b14677 100644 --- a/services/kiloclaw/controller/src/bootstrap.ts +++ b/services/kiloclaw/controller/src/bootstrap.ts @@ -669,6 +669,112 @@ export function configureLinear(env: EnvLike): void { } } +// ---- Step 6b: ClawMetry observability sync ---- + +const CLAWMETRY_DIR = '/root/.clawmetry'; +const CLAWMETRY_TOKEN_PATH = '/root/.clawmetry/token'; +const CLAWMETRY_PROVISION_TIMEOUT_MS = 8_000; + +type ClawMetryProvisionResponse = { + ok?: boolean; + cm_token?: string; + error?: string; +}; + +/** + * Provision ClawMetry sync for this instance and start the daemon. + * + * ClawMetry is a free observability dashboard for OpenClaw runtimes + * (https://clawmetry.com). The Python package is pre-installed in the image; + * this step (a) auto-creates a free ClawMetry account for the user via the + * partner provisioning endpoint, (b) writes the returned token to + * `~/.clawmetry/token`, and (c) spawns the sync daemon (detached) so events + * flow to app.clawmetry.com. + * + * Gated on CLAWMETRY_PARTNER_KEY — until the operator sets that env var on + * the instance, this is a silent no-op (zero behavior change). KILOCLAW_USER_EMAIL + * (or GITHUB_EMAIL as fallback) identifies the account to provision. + * + * Failures here NEVER block boot — observability is nice-to-have. We log, + * skip, and let the gateway start normally. + */ +export function provisionClawMetrySync(env: EnvLike, deps: BootstrapDeps = defaultDeps): void { + if (env.KILOCLAW_CLAWMETRY_DISABLED === 'true') { + console.log('ClawMetry: disabled via KILOCLAW_CLAWMETRY_DISABLED'); + return; + } + const partnerKey = env.CLAWMETRY_PARTNER_KEY; + if (!partnerKey) { + console.log('ClawMetry: not configured (CLAWMETRY_PARTNER_KEY missing)'); + return; + } + const userEmail = env.KILOCLAW_USER_EMAIL ?? env.GITHUB_EMAIL; + if (!userEmail) { + console.warn('ClawMetry: skipping — neither KILOCLAW_USER_EMAIL nor GITHUB_EMAIL is set'); + return; + } + + const apiBase = env.CLAWMETRY_API_BASE ?? 'https://app.clawmetry.com'; + const provisionUrl = `${apiBase}/api/partner/kiloclaw/provision`; + + let token: string; + try { + const result = deps.execFileSync( + 'curl', + [ + '-sS', + '--max-time', + String(Math.ceil(CLAWMETRY_PROVISION_TIMEOUT_MS / 1000)), + '-X', + 'POST', + '-H', + 'Content-Type: application/json', + '-H', + `X-Partner-Key: ${partnerKey}`, + '-d', + JSON.stringify({ email: userEmail, source: 'kiloclaw' }), + provisionUrl, + ], + { stdio: 'pipe' } + ); + const parsed = JSON.parse(String(result)) as ClawMetryProvisionResponse; + if (!parsed.ok || !parsed.cm_token) { + console.warn(`ClawMetry: provisioning failed — ${parsed.error ?? 'no token in response'}`); + return; + } + token = parsed.cm_token; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`ClawMetry: provisioning request failed — ${message}`); + return; + } + + if (!deps.existsSync(CLAWMETRY_DIR)) { + deps.mkdirSync(CLAWMETRY_DIR, { recursive: true, mode: 0o700 }); + } + deps.writeFileSync(CLAWMETRY_TOKEN_PATH, token); + deps.chmodSync(CLAWMETRY_TOKEN_PATH, 0o600); + + // Spawn `clawmetry sync` detached, fire-and-forget. ClawMetry's own + // process supervisor handles its lifecycle from here. Failure to start + // does not block boot — gateway is the critical path, observability is + // best-effort. + try { + deps.execFileSync( + 'sh', + [ + '-c', + `nohup clawmetry sync --token "$(cat ${CLAWMETRY_TOKEN_PATH})" >/var/log/clawmetry-sync.log 2>&1 &`, + ], + { stdio: 'pipe' } + ); + console.log(`ClawMetry: sync daemon started for ${userEmail}`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn(`ClawMetry: failed to start sync daemon — ${message}`); + } +} + // ---- Step 7: Onboard / doctor + config patching ---- /** @@ -1258,6 +1364,7 @@ export async function bootstrapNonCritical( const steps: BootstrapStep[] = [ { phase: 'github', run: () => configureGitHub(env, deps) }, { phase: 'linear', run: () => configureLinear(env) }, + { phase: 'clawmetry-sync', run: () => provisionClawMetrySync(env, deps) }, { phase: 'gateway-client-device-scopes', run: () => runGatewayClientDeviceScopeRemediation(deps), diff --git a/services/kiloclaw/docs/clawmetry-integration.md b/services/kiloclaw/docs/clawmetry-integration.md new file mode 100644 index 0000000000..acfb2af0e8 --- /dev/null +++ b/services/kiloclaw/docs/clawmetry-integration.md @@ -0,0 +1,83 @@ +# ClawMetry observability integration + +[ClawMetry](https://clawmetry.com) is a real-time observability dashboard for OpenClaw agents (sessions, token usage, cost, tool timeline, channels, alerts). Every KiloClaw instance ships with the ClawMetry sync daemon pre-installed — when enabled, agent activity flows to a per-user free ClawMetry account at `app.clawmetry.com`. + +## What this gives the user + +- A real-time dashboard of their agent's sessions, token spend, and tool activity +- Free tier: one node (their KiloClaw instance), 90-day session retention +- Optional Pro upgrade ($5/mo, redeemable via KiloCredits) — multi-node, unlimited retention, alerts, approvals + +The integration is **opt-in by env var** — it ships dormant. When `CLAWMETRY_PARTNER_KEY` is set on the instance, bootstrap auto-provisions a free account and starts the sync daemon. Until then, the package is installed but inert. + +## Versioning + +ClawMetry is installed via the upstream one-line installer (`curl -fsSL https://clawmetry.com/install.sh | bash`), not a PyPI version pin. This means: + +- Every fresh image build picks up the **latest** ClawMetry release at build time. +- ClawMetry can ship updates (new features, install-script changes) without requiring a PR back to this repo. Trigger a KiloClaw image rebuild to pick them up. +- Builds are **not bit-reproducible** across rebuilds — different builds may install different ClawMetry versions. That's intentional: the integration is best-effort observability, and the sync daemon is fail-soft (see `provisionClawMetrySync` in `controller/src/bootstrap.ts`). + +If a specific version is ever needed for an incident or hotfix, override at build time: + +```bash +docker build --build-arg CLAWMETRY_INSTALL_OVERRIDE='pip install --break-system-packages clawmetry==' ... +``` + +(That build arg doesn't exist today — file an issue if you need it.) + +## Activation + +Set these env vars on the KiloClaw instance (typically via the controller's normal env-var injection path; both can be `KILOCLAW_ENC_*` encrypted): + +| Variable | Required | Purpose | +| ----------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------ | +| `CLAWMETRY_PARTNER_KEY` | yes | Service-to-service key issued by ClawMetry to KiloClaw. Without it, the integration is a no-op. | +| `KILOCLAW_USER_EMAIL` | yes (or `GITHUB_EMAIL`) | Email used to provision the free ClawMetry account. Falls back to `GITHUB_EMAIL` if unset. | +| `CLAWMETRY_API_BASE` | no | Override the ClawMetry endpoint (default `https://app.clawmetry.com`). Useful for staging / self-hosted ClawMetry. | +| `KILOCLAW_CLAWMETRY_DISABLED` | no | Set to `'true'` to disable even when partner key is present (escape hatch). | + +## What bootstrap does + +The `clawmetry-sync` phase in `bootstrapNonCritical` (controller, [bootstrap.ts](../controller/src/bootstrap.ts)): + +1. POSTs `{email, source: 'kiloclaw'}` to `${CLAWMETRY_API_BASE}/api/partner/kiloclaw/provision` with `X-Partner-Key` header +2. Receives `{ok: true, cm_token: 'cm_...'}` (existing account if email matches; new free account otherwise) +3. Writes the token to `/root/.clawmetry/token` (mode 0600) +4. Spawns `clawmetry sync` as a detached background process — events flow to ClawMetry cloud from then on + +Failures are warned and skipped — they NEVER block boot. Observability is best-effort; the gateway is the critical path. + +## Cloud-side prerequisite + +This integration depends on a ClawMetry endpoint that does not exist at the time of writing: + +``` +POST /api/partner/kiloclaw/provision +Headers: X-Partner-Key: +Body: {"email": "", "source": "kiloclaw"} + +Response (201): {"ok": true, "cm_token": "cm_..."} +Response (400): {"ok": false, "error": "..."} +``` + +Until ClawMetry ships this route + issues a partner key, leave `CLAWMETRY_PARTNER_KEY` unset on KiloClaw instances. The integration is dormant — there is zero behavior change. + +Tracking: contact `vivek@clawmetry.com` to coordinate partner-key issuance. + +## Verifying + +After enabling on a test instance: + +```bash +fly ssh console -s --app +cat /root/.clawmetry/token # cm_<32 hex chars> +ps -ef | grep 'clawmetry sync' # daemon should be running +tail -f /var/log/clawmetry-sync.log +``` + +Then visit `https://app.clawmetry.com/cloud` and sign in with the same email — the user's KiloClaw instance shows up as a node within ~1 minute. + +## Disabling + +Set `KILOCLAW_CLAWMETRY_DISABLED=true` on the instance env. On next boot the bootstrap step skips silently. Existing tokens at `/root/.clawmetry/token` remain — to fully clean up, also stop the sync daemon and delete the token file. From 7cf4a8420d3c487abf0f1786af25ffc88ac1bdba Mon Sep 17 00:00:00 2001 From: vivekchand Date: Thu, 7 May 2026 08:59:30 +0200 Subject: [PATCH 02/12] refactor(kiloclaw): use public /api/register, drop partner-key indirection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The original design had bootstrap call a custom /api/partner/kiloclaw/ provision endpoint gated by CLAWMETRY_PARTNER_KEY. That was overdesigned — the partner key was only there to gate an account-takeover oracle the custom endpoint introduced by returning existing tokens by email lookup. The existing public /api/register endpoint already does the right thing: tokens are scoped to machine_id (not email), email is metadata for recovery only, idempotent on machine_id, no secrets to manage. Same flow any user installing clawmetry on a fresh OpenClaw box would use. Changes: - bootstrap.ts: provisionClawMetrySync calls /api/register with {hostname, machine_id (FLY_MACHINE_ID || HOSTNAME), platform, email?} - Drop CLAWMETRY_PARTNER_KEY env var entirely; integration enabled by default (KILOCLAW_CLAWMETRY_DISABLED=true is the only opt-out) - Email is now optional — account is keyed on machine_id either way - bootstrap.test.ts: 8 tests updated to cover the simpler shape (disabled, no-machine-id, register success, no-email, GITHUB_EMAIL fallback, HOSTNAME fallback, network error, no-api_key, API base override) - docs/clawmetry-integration.md: rewrite to reflect "no special endpoint, no partner key, no secrets" model Pairs with cloud-side revert at vivekchand/clawmetry-cloud#629 (merged). --- .../kiloclaw/controller/src/bootstrap.test.ts | 123 ++++++++++-------- services/kiloclaw/controller/src/bootstrap.ts | 77 ++++++----- .../kiloclaw/docs/clawmetry-integration.md | 71 ++++------ 3 files changed, 136 insertions(+), 135 deletions(-) diff --git a/services/kiloclaw/controller/src/bootstrap.test.ts b/services/kiloclaw/controller/src/bootstrap.test.ts index 3a73c80036..2441c72fe0 100644 --- a/services/kiloclaw/controller/src/bootstrap.test.ts +++ b/services/kiloclaw/controller/src/bootstrap.test.ts @@ -570,65 +570,45 @@ describe('configureLinear', () => { // ---- provisionClawMetrySync ---- describe('provisionClawMetrySync', () => { - it('skips silently when CLAWMETRY_PARTNER_KEY is missing', () => { - const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); - const { deps, execCalls, writeCalls } = fakeDeps(); - - provisionClawMetrySync({ KILOCLAW_USER_EMAIL: 'a@b.c' }, deps); - - expect(execCalls).toEqual([]); - expect(writeCalls).toEqual([]); - expect(logSpy).toHaveBeenCalledWith( - 'ClawMetry: not configured (CLAWMETRY_PARTNER_KEY missing)' - ); - logSpy.mockRestore(); - }); - - it('skips when KILOCLAW_CLAWMETRY_DISABLED=true even with key + email', () => { + it('skips when KILOCLAW_CLAWMETRY_DISABLED=true', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const { deps, execCalls } = fakeDeps(); - provisionClawMetrySync( - { - KILOCLAW_CLAWMETRY_DISABLED: 'true', - CLAWMETRY_PARTNER_KEY: 'pk_test', - KILOCLAW_USER_EMAIL: 'a@b.c', - }, - deps - ); + provisionClawMetrySync({ KILOCLAW_CLAWMETRY_DISABLED: 'true', FLY_MACHINE_ID: 'm1' }, deps); expect(execCalls).toEqual([]); expect(logSpy).toHaveBeenCalledWith('ClawMetry: disabled via KILOCLAW_CLAWMETRY_DISABLED'); logSpy.mockRestore(); }); - it('warns and exits when no email is available', () => { + it('warns and exits when no machine_id can be derived', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const { deps, execCalls } = fakeDeps(); - provisionClawMetrySync({ CLAWMETRY_PARTNER_KEY: 'pk_test' }, deps); + provisionClawMetrySync({}, deps); expect(execCalls).toEqual([]); expect(warnSpy).toHaveBeenCalledWith( - 'ClawMetry: skipping — neither KILOCLAW_USER_EMAIL nor GITHUB_EMAIL is set' + 'ClawMetry: skipping — no FLY_MACHINE_ID or HOSTNAME to identify the machine' ); warnSpy.mockRestore(); }); - it('provisions a token, writes it to disk with 600, and starts the daemon', () => { + it('registers via /api/register, writes token with 600, and starts the daemon', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const { deps, execCalls, writeCalls, chmodCalls, mkdirCalls } = fakeDeps(); deps.execFileSync = vi.fn((cmd: string, args: string[]) => { execCalls.push({ cmd, args }); if (cmd === 'curl') { - return JSON.stringify({ ok: true, cm_token: 'cm_abc123' }); + return JSON.stringify({ ok: true, api_key: 'cm_abc123' }); } return ''; }); provisionClawMetrySync( { - CLAWMETRY_PARTNER_KEY: 'pk_test', + FLY_MACHINE_ID: 'fly-machine-abc', + HOSTNAME: 'agent-vivek-fly', KILOCLAW_USER_EMAIL: 'user@kilocode.ai', }, deps @@ -636,9 +616,18 @@ describe('provisionClawMetrySync', () => { const curlCall = execCalls.find(c => c.cmd === 'curl'); expect(curlCall).toBeDefined(); - expect(curlCall?.args).toContain('https://app.clawmetry.com/api/partner/kiloclaw/provision'); - expect(curlCall?.args).toContain('X-Partner-Key: pk_test'); - expect(curlCall?.args.some(a => a.includes('user@kilocode.ai'))).toBe(true); + expect(curlCall?.args).toContain('https://app.clawmetry.com/api/register'); + // Public endpoint — no X-Partner-Key header anywhere + expect(curlCall?.args.every(a => !a.includes('X-Partner-Key'))).toBe(true); + // Payload carries machine_id, hostname, platform, email + const payload = curlCall?.args.find(a => a.includes('machine_id')) ?? ''; + const parsed = JSON.parse(payload); + expect(parsed).toEqual({ + hostname: 'agent-vivek-fly', + machine_id: 'fly-machine-abc', + platform: 'Linux', + email: 'user@kilocode.ai', + }); expect(mkdirCalls).toContain('/root/.clawmetry'); expect(writeCalls).toEqual([{ path: '/root/.clawmetry/token', data: 'cm_abc123' }]); @@ -649,28 +638,61 @@ describe('provisionClawMetrySync', () => { expect(daemonCall?.args[1]).toContain('clawmetry sync'); expect(daemonCall?.args[1]).toContain('nohup'); - expect(logSpy).toHaveBeenCalledWith('ClawMetry: sync daemon started for user@kilocode.ai'); + expect(logSpy).toHaveBeenCalledWith('ClawMetry: sync daemon started (machine=fly-machine-abc)'); logSpy.mockRestore(); }); + it('omits email from payload when neither KILOCLAW_USER_EMAIL nor GITHUB_EMAIL is set', () => { + const { deps, execCalls } = fakeDeps(); + deps.execFileSync = vi.fn((cmd: string, args: string[]) => { + execCalls.push({ cmd, args }); + if (cmd === 'curl') return JSON.stringify({ api_key: 'cm_x' }); + return ''; + }); + + provisionClawMetrySync({ FLY_MACHINE_ID: 'm1', HOSTNAME: 'h1' }, deps); + + const curlCall = execCalls.find(c => c.cmd === 'curl'); + const payload = JSON.parse(curlCall?.args.find(a => a.includes('machine_id')) ?? '{}'); + expect(payload.email).toBeUndefined(); + expect(payload.machine_id).toBe('m1'); + }); + it('falls back to GITHUB_EMAIL when KILOCLAW_USER_EMAIL is absent', () => { const { deps, execCalls } = fakeDeps(); deps.execFileSync = vi.fn((cmd: string, args: string[]) => { execCalls.push({ cmd, args }); - if (cmd === 'curl') return JSON.stringify({ ok: true, cm_token: 'cm_x' }); + if (cmd === 'curl') return JSON.stringify({ api_key: 'cm_x' }); return ''; }); provisionClawMetrySync( - { CLAWMETRY_PARTNER_KEY: 'pk_test', GITHUB_EMAIL: 'fallback@x.com' }, + { FLY_MACHINE_ID: 'm1', HOSTNAME: 'h1', GITHUB_EMAIL: 'fallback@x.com' }, deps ); const curlCall = execCalls.find(c => c.cmd === 'curl'); - expect(curlCall?.args.some(a => a.includes('fallback@x.com'))).toBe(true); + const payload = JSON.parse(curlCall?.args.find(a => a.includes('machine_id')) ?? '{}'); + expect(payload.email).toBe('fallback@x.com'); + }); + + it('falls back to HOSTNAME when FLY_MACHINE_ID is absent', () => { + const { deps, execCalls } = fakeDeps(); + deps.execFileSync = vi.fn((cmd: string, args: string[]) => { + execCalls.push({ cmd, args }); + if (cmd === 'curl') return JSON.stringify({ api_key: 'cm_x' }); + return ''; + }); + + provisionClawMetrySync({ HOSTNAME: 'standalone-host' }, deps); + + const curlCall = execCalls.find(c => c.cmd === 'curl'); + const payload = JSON.parse(curlCall?.args.find(a => a.includes('machine_id')) ?? '{}'); + expect(payload.machine_id).toBe('standalone-host'); + expect(payload.hostname).toBe('standalone-host'); }); - it('warns but does not throw when provisioning request errors', () => { + it('warns but does not throw when register request errors', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const { deps, execCalls, writeCalls } = fakeDeps(); deps.execFileSync = vi.fn((cmd: string, args: string[]) => { @@ -680,34 +702,33 @@ describe('provisionClawMetrySync', () => { }); expect(() => - provisionClawMetrySync( - { CLAWMETRY_PARTNER_KEY: 'pk_test', KILOCLAW_USER_EMAIL: 'a@b.c' }, - deps - ) + provisionClawMetrySync({ FLY_MACHINE_ID: 'm1', HOSTNAME: 'h1' }, deps) ).not.toThrow(); expect(writeCalls).toEqual([]); expect(warnSpy).toHaveBeenCalledWith( - expect.stringContaining('ClawMetry: provisioning request failed') + expect.stringContaining('ClawMetry: register request failed') ); warnSpy.mockRestore(); }); - it('warns and skips daemon spawn when partner endpoint returns ok:false', () => { + it('warns and skips daemon spawn when register returns no api_key', () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); const { deps, execCalls, writeCalls } = fakeDeps(); deps.execFileSync = vi.fn((cmd: string, args: string[]) => { execCalls.push({ cmd, args }); if (cmd === 'curl') { - return JSON.stringify({ ok: false, error: 'partner key invalid' }); + return JSON.stringify({ ok: false, error: 'invalid payload' }); } return ''; }); - provisionClawMetrySync({ CLAWMETRY_PARTNER_KEY: 'pk_bad', KILOCLAW_USER_EMAIL: 'a@b.c' }, deps); + provisionClawMetrySync({ FLY_MACHINE_ID: 'm1', HOSTNAME: 'h1' }, deps); expect(writeCalls).toEqual([]); expect(execCalls.find(c => c.cmd === 'sh')).toBeUndefined(); - expect(warnSpy).toHaveBeenCalledWith('ClawMetry: provisioning failed — partner key invalid'); + expect(warnSpy).toHaveBeenCalledWith( + 'ClawMetry: register returned no api_key — invalid payload' + ); warnSpy.mockRestore(); }); @@ -715,23 +736,21 @@ describe('provisionClawMetrySync', () => { const { deps, execCalls } = fakeDeps(); deps.execFileSync = vi.fn((cmd: string, args: string[]) => { execCalls.push({ cmd, args }); - if (cmd === 'curl') return JSON.stringify({ ok: true, cm_token: 'cm_x' }); + if (cmd === 'curl') return JSON.stringify({ api_key: 'cm_x' }); return ''; }); provisionClawMetrySync( { - CLAWMETRY_PARTNER_KEY: 'pk_test', - KILOCLAW_USER_EMAIL: 'a@b.c', + FLY_MACHINE_ID: 'm1', + HOSTNAME: 'h1', CLAWMETRY_API_BASE: 'https://staging.clawmetry.com', }, deps ); const curlCall = execCalls.find(c => c.cmd === 'curl'); - expect(curlCall?.args).toContain( - 'https://staging.clawmetry.com/api/partner/kiloclaw/provision' - ); + expect(curlCall?.args).toContain('https://staging.clawmetry.com/api/register'); }); }); diff --git a/services/kiloclaw/controller/src/bootstrap.ts b/services/kiloclaw/controller/src/bootstrap.ts index 3710b14677..712c5ad206 100644 --- a/services/kiloclaw/controller/src/bootstrap.ts +++ b/services/kiloclaw/controller/src/bootstrap.ts @@ -673,49 +673,58 @@ export function configureLinear(env: EnvLike): void { const CLAWMETRY_DIR = '/root/.clawmetry'; const CLAWMETRY_TOKEN_PATH = '/root/.clawmetry/token'; -const CLAWMETRY_PROVISION_TIMEOUT_MS = 8_000; +const CLAWMETRY_REGISTER_TIMEOUT_MS = 8_000; -type ClawMetryProvisionResponse = { +type ClawMetryRegisterResponse = { ok?: boolean; - cm_token?: string; + api_key?: string; error?: string; }; /** - * Provision ClawMetry sync for this instance and start the daemon. + * Register this instance with ClawMetry and start the sync daemon. * - * ClawMetry is a free observability dashboard for OpenClaw runtimes - * (https://clawmetry.com). The Python package is pre-installed in the image; - * this step (a) auto-creates a free ClawMetry account for the user via the - * partner provisioning endpoint, (b) writes the returned token to - * `~/.clawmetry/token`, and (c) spawns the sync daemon (detached) so events - * flow to app.clawmetry.com. + * ClawMetry (https://clawmetry.com) is a free observability dashboard for + * OpenClaw runtimes. The Python package is pre-installed in the image; this + * step (a) calls the public `/api/register` endpoint to get a free per- + * machine cm_token, (b) writes it to `~/.clawmetry/token`, (c) spawns the + * sync daemon (detached) so events flow to app.clawmetry.com. * - * Gated on CLAWMETRY_PARTNER_KEY — until the operator sets that env var on - * the instance, this is a silent no-op (zero behavior change). KILOCLAW_USER_EMAIL - * (or GITHUB_EMAIL as fallback) identifies the account to provision. + * Same flow any user installing clawmetry on a fresh OpenClaw box would + * use — no special endpoint, no partner key, no secrets to manage. Tokens + * are scoped to the Fly machine ID (or HOSTNAME fallback) so each instance + * gets its own ClawMetry account; users with multiple instances can link + * them later via the standard OTP flow. * - * Failures here NEVER block boot — observability is nice-to-have. We log, - * skip, and let the gateway start normally. + * Failures NEVER block boot — observability is best-effort. Operators can + * set `KILOCLAW_CLAWMETRY_DISABLED=true` to skip entirely. */ export function provisionClawMetrySync(env: EnvLike, deps: BootstrapDeps = defaultDeps): void { if (env.KILOCLAW_CLAWMETRY_DISABLED === 'true') { console.log('ClawMetry: disabled via KILOCLAW_CLAWMETRY_DISABLED'); return; } - const partnerKey = env.CLAWMETRY_PARTNER_KEY; - if (!partnerKey) { - console.log('ClawMetry: not configured (CLAWMETRY_PARTNER_KEY missing)'); + + const machineId = env.FLY_MACHINE_ID || env.HOSTNAME; + if (!machineId) { + console.warn('ClawMetry: skipping — no FLY_MACHINE_ID or HOSTNAME to identify the machine'); return; } + const hostname = env.HOSTNAME || machineId; const userEmail = env.KILOCLAW_USER_EMAIL ?? env.GITHUB_EMAIL; - if (!userEmail) { - console.warn('ClawMetry: skipping — neither KILOCLAW_USER_EMAIL nor GITHUB_EMAIL is set'); - return; - } - const apiBase = env.CLAWMETRY_API_BASE ?? 'https://app.clawmetry.com'; - const provisionUrl = `${apiBase}/api/partner/kiloclaw/provision`; + const registerUrl = `${apiBase}/api/register`; + + // Build the same payload the standard `clawmetry connect` flow would. + // Email is optional; account is keyed on machine_id either way. + const payload: { hostname: string; machine_id: string; platform: string; email?: string } = { + hostname, + machine_id: machineId, + platform: 'Linux', + }; + if (userEmail) { + payload.email = userEmail; + } let token: string; try { @@ -724,28 +733,26 @@ export function provisionClawMetrySync(env: EnvLike, deps: BootstrapDeps = defau [ '-sS', '--max-time', - String(Math.ceil(CLAWMETRY_PROVISION_TIMEOUT_MS / 1000)), + String(Math.ceil(CLAWMETRY_REGISTER_TIMEOUT_MS / 1000)), '-X', 'POST', '-H', 'Content-Type: application/json', - '-H', - `X-Partner-Key: ${partnerKey}`, '-d', - JSON.stringify({ email: userEmail, source: 'kiloclaw' }), - provisionUrl, + JSON.stringify(payload), + registerUrl, ], { stdio: 'pipe' } ); - const parsed = JSON.parse(String(result)) as ClawMetryProvisionResponse; - if (!parsed.ok || !parsed.cm_token) { - console.warn(`ClawMetry: provisioning failed — ${parsed.error ?? 'no token in response'}`); + const parsed = JSON.parse(String(result)) as ClawMetryRegisterResponse; + if (!parsed.api_key) { + console.warn(`ClawMetry: register returned no api_key — ${parsed.error ?? 'unknown error'}`); return; } - token = parsed.cm_token; + token = parsed.api_key; } catch (error) { const message = error instanceof Error ? error.message : String(error); - console.warn(`ClawMetry: provisioning request failed — ${message}`); + console.warn(`ClawMetry: register request failed — ${message}`); return; } @@ -768,7 +775,7 @@ export function provisionClawMetrySync(env: EnvLike, deps: BootstrapDeps = defau ], { stdio: 'pipe' } ); - console.log(`ClawMetry: sync daemon started for ${userEmail}`); + console.log(`ClawMetry: sync daemon started (machine=${machineId})`); } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn(`ClawMetry: failed to start sync daemon — ${message}`); diff --git a/services/kiloclaw/docs/clawmetry-integration.md b/services/kiloclaw/docs/clawmetry-integration.md index acfb2af0e8..eb19c8b8be 100644 --- a/services/kiloclaw/docs/clawmetry-integration.md +++ b/services/kiloclaw/docs/clawmetry-integration.md @@ -1,73 +1,48 @@ # ClawMetry observability integration -[ClawMetry](https://clawmetry.com) is a real-time observability dashboard for OpenClaw agents (sessions, token usage, cost, tool timeline, channels, alerts). Every KiloClaw instance ships with the ClawMetry sync daemon pre-installed — when enabled, agent activity flows to a per-user free ClawMetry account at `app.clawmetry.com`. +[ClawMetry](https://clawmetry.com) is a real-time observability dashboard for OpenClaw agents (sessions, token usage, cost, tool timeline, channels, alerts). Every KiloClaw instance ships with the ClawMetry sync daemon pre-installed and auto-registers on first boot — agent activity flows to a per-instance free ClawMetry account at `app.clawmetry.com`. ## What this gives the user - A real-time dashboard of their agent's sessions, token spend, and tool activity - Free tier: one node (their KiloClaw instance), 90-day session retention -- Optional Pro upgrade ($5/mo, redeemable via KiloCredits) — multi-node, unlimited retention, alerts, approvals +- No setup required — works out of the box on every new instance -The integration is **opt-in by env var** — it ships dormant. When `CLAWMETRY_PARTNER_KEY` is set on the instance, bootstrap auto-provisions a free account and starts the sync daemon. Until then, the package is installed but inert. +## How it works -## Versioning - -ClawMetry is installed via the upstream one-line installer (`curl -fsSL https://clawmetry.com/install.sh | bash`), not a PyPI version pin. This means: - -- Every fresh image build picks up the **latest** ClawMetry release at build time. -- ClawMetry can ship updates (new features, install-script changes) without requiring a PR back to this repo. Trigger a KiloClaw image rebuild to pick them up. -- Builds are **not bit-reproducible** across rebuilds — different builds may install different ClawMetry versions. That's intentional: the integration is best-effort observability, and the sync daemon is fail-soft (see `provisionClawMetrySync` in `controller/src/bootstrap.ts`). - -If a specific version is ever needed for an incident or hotfix, override at build time: - -```bash -docker build --build-arg CLAWMETRY_INSTALL_OVERRIDE='pip install --break-system-packages clawmetry==' ... -``` - -(That build arg doesn't exist today — file an issue if you need it.) - -## Activation +The bootstrap step `provisionClawMetrySync` (controller, [bootstrap.ts](../controller/src/bootstrap.ts)) does the same thing any user installing ClawMetry on a fresh OpenClaw box would do: -Set these env vars on the KiloClaw instance (typically via the controller's normal env-var injection path; both can be `KILOCLAW_ENC_*` encrypted): - -| Variable | Required | Purpose | -| ----------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------ | -| `CLAWMETRY_PARTNER_KEY` | yes | Service-to-service key issued by ClawMetry to KiloClaw. Without it, the integration is a no-op. | -| `KILOCLAW_USER_EMAIL` | yes (or `GITHUB_EMAIL`) | Email used to provision the free ClawMetry account. Falls back to `GITHUB_EMAIL` if unset. | -| `CLAWMETRY_API_BASE` | no | Override the ClawMetry endpoint (default `https://app.clawmetry.com`). Useful for staging / self-hosted ClawMetry. | -| `KILOCLAW_CLAWMETRY_DISABLED` | no | Set to `'true'` to disable even when partner key is present (escape hatch). | - -## What bootstrap does - -The `clawmetry-sync` phase in `bootstrapNonCritical` (controller, [bootstrap.ts](../controller/src/bootstrap.ts)): - -1. POSTs `{email, source: 'kiloclaw'}` to `${CLAWMETRY_API_BASE}/api/partner/kiloclaw/provision` with `X-Partner-Key` header -2. Receives `{ok: true, cm_token: 'cm_...'}` (existing account if email matches; new free account otherwise) +1. POSTs `{hostname, machine_id, platform: 'Linux', email?}` to `${CLAWMETRY_API_BASE}/api/register` (a public, idempotent endpoint) +2. Receives `{api_key: 'cm_...'}` back — a per-machine token 3. Writes the token to `/root/.clawmetry/token` (mode 0600) 4. Spawns `clawmetry sync` as a detached background process — events flow to ClawMetry cloud from then on +No special endpoint. No partner key. No secrets to manage. Tokens are scoped to `machine_id` (Fly machine ID, falling back to HOSTNAME), so each instance gets its own ClawMetry account. Users with multiple KiloClaw instances can link them later via the standard `clawmetry connect` OTP flow. + Failures are warned and skipped — they NEVER block boot. Observability is best-effort; the gateway is the critical path. -## Cloud-side prerequisite +## Versioning -This integration depends on a ClawMetry endpoint that does not exist at the time of writing: +ClawMetry is installed via the upstream one-line installer (`curl -fsSL https://clawmetry.com/install.sh | bash`), not a PyPI version pin. Every fresh image build picks up the latest ClawMetry release at build time. ClawMetry can ship updates without requiring a PR back to this repo — trigger a KiloClaw image rebuild to pick them up. -``` -POST /api/partner/kiloclaw/provision -Headers: X-Partner-Key: -Body: {"email": "", "source": "kiloclaw"} +Trade-off: builds are not bit-reproducible across rebuilds. That's intentional; the integration is best-effort observability. -Response (201): {"ok": true, "cm_token": "cm_..."} -Response (400): {"ok": false, "error": "..."} -``` +## Env vars + +All optional: -Until ClawMetry ships this route + issues a partner key, leave `CLAWMETRY_PARTNER_KEY` unset on KiloClaw instances. The integration is dormant — there is zero behavior change. +| Variable | Default | Purpose | +| ----------------------------- | --------------------------- | -------------------------------------------------------------------------------- | +| `CLAWMETRY_API_BASE` | `https://app.clawmetry.com` | Override for staging / self-hosted ClawMetry | +| `KILOCLAW_USER_EMAIL` | unset | Email attached to the account for recovery (optional — account works without it) | +| `GITHUB_EMAIL` | unset | Fallback when `KILOCLAW_USER_EMAIL` is absent | +| `KILOCLAW_CLAWMETRY_DISABLED` | unset | Set to `'true'` to disable the integration entirely (escape hatch) | -Tracking: contact `vivek@clawmetry.com` to coordinate partner-key issuance. +`FLY_MACHINE_ID` and `HOSTNAME` are read directly from the runtime env (Fly auto-injects them); they don't need to be set. ## Verifying -After enabling on a test instance: +After deploying a new KiloClaw image: ```bash fly ssh console -s --app @@ -76,7 +51,7 @@ ps -ef | grep 'clawmetry sync' # daemon should be running tail -f /var/log/clawmetry-sync.log ``` -Then visit `https://app.clawmetry.com/cloud` and sign in with the same email — the user's KiloClaw instance shows up as a node within ~1 minute. +Then visit `https://app.clawmetry.com/cloud` and sign in with the email that was attached (via `clawmetry connect` OTP flow) — the user's KiloClaw instance shows up as a node within ~1 minute. ## Disabling From eef0ef60b229acc28468ca2dc525eb43459965b2 Mon Sep 17 00:00:00 2001 From: vivekchand Date: Thu, 7 May 2026 12:02:59 +0200 Subject: [PATCH 03/12] feat(kiloclaw): add E2E enc_key + defer sync until user opens dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related changes that fix gaps in the previous commit: 1. E2E encryption key (was missing entirely) ClawMetry encrypts events client-side with AES-256-GCM before publishing. The previous commit only wrote a token to disk — the daemon would have either crashed (no encryption_key in config) or, worse, published plaintext. Now bootstrap: - Generates a 32-byte enc_key client-side (never sent to any server) - Writes the full schema to /root/.clawmetry/config.json ({api_key, encryption_key, node_id, platform, connected_at}) - Writes a self-decrypting dashboard URL to dashboard-url.txt: https://app.clawmetry.com/cloud#key=&node= The #fragment is never sent to any server (browsers strip it from outgoing requests) — the dashboard JS reads it client-side and stashes the key in localStorage to decrypt event blobs. E2E story: enc_key flows instance → URL fragment → user's browser. NEVER through any server. Even KiloClaw's own controller only sees the URL string; the fragment is meaningful only to the browser. 2. Deferred sync (only fire when user actually wants to look) No more daemon spawn at bootstrap. Bootstrap just pre-wires: install + config + dashboard URL. The daemon stays dormant. When the user clicks "View Observability" in KiloClaw's web UI: POST /_kilo/clawmetry-start-sync → spawns nohup clawmetry sync & GET /_kilo/clawmetry-dashboard-url → returns the self-decrypting URL Browser opens the URL → dashboard shows "Syncing your data..." until first events arrive (catch-up batch from local OpenClaw session files). Saves compute / bandwidth / cloud storage for users who never look at the dashboard. Daemon, once started, persists for that boot session. Tests: 89 files / 1752 pass. Adds 1 test (config + URL writing path) + adjusts the original "starts daemon" assertions to "does NOT start daemon" since deferred-sync moves that to UI-button-click time. The KiloClaw web UI follow-up (the actual button + controller endpoints) is intentionally out of scope here — flagged in docs/ for Suhail's team to take or for me to follow up on if they want. --- .../kiloclaw/controller/src/bootstrap.test.ts | 68 +++++++++-- services/kiloclaw/controller/src/bootstrap.ts | 113 ++++++++++++------ .../kiloclaw/docs/clawmetry-integration.md | 91 ++++++++++---- 3 files changed, 203 insertions(+), 69 deletions(-) diff --git a/services/kiloclaw/controller/src/bootstrap.test.ts b/services/kiloclaw/controller/src/bootstrap.test.ts index 2441c72fe0..f178ae3508 100644 --- a/services/kiloclaw/controller/src/bootstrap.test.ts +++ b/services/kiloclaw/controller/src/bootstrap.test.ts @@ -594,13 +594,18 @@ describe('provisionClawMetrySync', () => { warnSpy.mockRestore(); }); - it('registers via /api/register, writes token with 600, and starts the daemon', () => { + it('registers via /api/register, writes config + dashboard URL, does NOT start daemon', () => { const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); const { deps, execCalls, writeCalls, chmodCalls, mkdirCalls } = fakeDeps(); deps.execFileSync = vi.fn((cmd: string, args: string[]) => { execCalls.push({ cmd, args }); if (cmd === 'curl') { - return JSON.stringify({ ok: true, api_key: 'cm_abc123' }); + return JSON.stringify({ + ok: true, + api_key: 'cm_abc123', + dashboard_id: 'dash-uuid-1', + node_id: 'agent-vivek-fly', + }); } return ''; }); @@ -619,10 +624,8 @@ describe('provisionClawMetrySync', () => { expect(curlCall?.args).toContain('https://app.clawmetry.com/api/register'); // Public endpoint — no X-Partner-Key header anywhere expect(curlCall?.args.every(a => !a.includes('X-Partner-Key'))).toBe(true); - // Payload carries machine_id, hostname, platform, email - const payload = curlCall?.args.find(a => a.includes('machine_id')) ?? ''; - const parsed = JSON.parse(payload); - expect(parsed).toEqual({ + const payload = JSON.parse(curlCall?.args.find(a => a.includes('machine_id')) ?? '{}'); + expect(payload).toEqual({ hostname: 'agent-vivek-fly', machine_id: 'fly-machine-abc', platform: 'Linux', @@ -630,18 +633,57 @@ describe('provisionClawMetrySync', () => { }); expect(mkdirCalls).toContain('/root/.clawmetry'); - expect(writeCalls).toEqual([{ path: '/root/.clawmetry/token', data: 'cm_abc123' }]); - expect(chmodCalls).toEqual([{ path: '/root/.clawmetry/token', mode: 0o600 }]); - const daemonCall = execCalls.find(c => c.cmd === 'sh'); - expect(daemonCall).toBeDefined(); - expect(daemonCall?.args[1]).toContain('clawmetry sync'); - expect(daemonCall?.args[1]).toContain('nohup'); + // config.json: full schema with api_key + encryption_key + node_id + const configWrite = writeCalls.find(w => w.path === '/root/.clawmetry/config.json'); + expect(configWrite).toBeDefined(); + const config = JSON.parse(configWrite!.data); + expect(config.api_key).toBe('cm_abc123'); + expect(config.node_id).toBe('agent-vivek-fly'); + expect(config.platform).toBe('Linux'); + // encryption_key is 32 random bytes base64-encoded → 44 chars + expect(config.encryption_key).toHaveLength(44); + expect(typeof config.connected_at).toBe('string'); + + // dashboard URL embeds enc_key + node_id as URL fragment (#...) + const urlWrite = writeCalls.find(w => w.path === '/root/.clawmetry/dashboard-url.txt'); + expect(urlWrite).toBeDefined(); + expect(urlWrite!.data).toMatch( + /^https:\/\/app\.clawmetry\.com\/cloud#key=[^&]+&node=agent-vivek-fly\n$/ + ); + + // Both files written 0600 + expect(chmodCalls).toContainEqual({ path: '/root/.clawmetry/config.json', mode: 0o600 }); + expect(chmodCalls).toContainEqual({ path: '/root/.clawmetry/dashboard-url.txt', mode: 0o600 }); - expect(logSpy).toHaveBeenCalledWith('ClawMetry: sync daemon started (machine=fly-machine-abc)'); + // Deferred-sync: NO daemon spawn at bootstrap time + expect(execCalls.find(c => c.cmd === 'sh')).toBeUndefined(); + + expect(logSpy).toHaveBeenCalledWith( + 'ClawMetry: provisioned (node=agent-vivek-fly, E2E enabled) — sync deferred until user opens dashboard' + ); logSpy.mockRestore(); }); + it('uses machine_id as node_id fallback when /api/register omits node_id', () => { + const { deps, execCalls, writeCalls } = fakeDeps(); + deps.execFileSync = vi.fn((cmd: string, args: string[]) => { + execCalls.push({ cmd, args }); + if (cmd === 'curl') return JSON.stringify({ api_key: 'cm_x' }); + return ''; + }); + + provisionClawMetrySync({ FLY_MACHINE_ID: 'fallback-mid', HOSTNAME: 'h1' }, deps); + + const config = JSON.parse( + writeCalls.find(w => w.path === '/root/.clawmetry/config.json')!.data + ); + expect(config.node_id).toBe('fallback-mid'); + + const urlContents = writeCalls.find(w => w.path === '/root/.clawmetry/dashboard-url.txt')!.data; + expect(urlContents).toContain('node=fallback-mid'); + }); + it('omits email from payload when neither KILOCLAW_USER_EMAIL nor GITHUB_EMAIL is set', () => { const { deps, execCalls } = fakeDeps(); deps.execFileSync = vi.fn((cmd: string, args: string[]) => { diff --git a/services/kiloclaw/controller/src/bootstrap.ts b/services/kiloclaw/controller/src/bootstrap.ts index 712c5ad206..20f8b13fac 100644 --- a/services/kiloclaw/controller/src/bootstrap.ts +++ b/services/kiloclaw/controller/src/bootstrap.ts @@ -672,29 +672,54 @@ export function configureLinear(env: EnvLike): void { // ---- Step 6b: ClawMetry observability sync ---- const CLAWMETRY_DIR = '/root/.clawmetry'; -const CLAWMETRY_TOKEN_PATH = '/root/.clawmetry/token'; +const CLAWMETRY_CONFIG_PATH = '/root/.clawmetry/config.json'; +const CLAWMETRY_DASHBOARD_URL_PATH = '/root/.clawmetry/dashboard-url.txt'; const CLAWMETRY_REGISTER_TIMEOUT_MS = 8_000; type ClawMetryRegisterResponse = { ok?: boolean; api_key?: string; + dashboard_id?: string; + node_id?: string; error?: string; }; /** * Register this instance with ClawMetry and start the sync daemon. * - * ClawMetry (https://clawmetry.com) is a free observability dashboard for - * OpenClaw runtimes. The Python package is pre-installed in the image; this - * step (a) calls the public `/api/register` endpoint to get a free per- - * machine cm_token, (b) writes it to `~/.clawmetry/token`, (c) spawns the - * sync daemon (detached) so events flow to app.clawmetry.com. + * ClawMetry (https://clawmetry.com) is a free, end-to-end-encrypted + * observability dashboard for OpenClaw runtimes. The Python package is + * pre-installed in the image; this step (a) calls the public + * `/api/register` endpoint to get a per-machine cm_token, (b) generates a + * client-side AES-256-GCM encryption key (never sent to the server), (c) + * writes both to `~/.clawmetry/config.json` in the schema the sync daemon + * expects, (d) writes a self-decrypting dashboard URL to + * `~/.clawmetry/dashboard-url.txt` so KiloClaw's UI can surface a "View + * Observability" link, and (e) spawns `clawmetry sync` detached. * - * Same flow any user installing clawmetry on a fresh OpenClaw box would - * use — no special endpoint, no partner key, no secrets to manage. Tokens - * are scoped to the Fly machine ID (or HOSTNAME fallback) so each instance - * gets its own ClawMetry account; users with multiple instances can link - * them later via the standard OTP flow. + * E2E model: the encryption_key is generated on this box and never leaves + * it via any HTTP request. The cloud only ever sees ciphertext blobs. The + * dashboard URL embeds the key as a `#fragment` (which browsers never send + * to servers), so opening the URL in a browser primes localStorage with the + * key and decrypts events on the fly. + * + * Deferred sync: this step does NOT spawn the sync daemon. We only PRE-WIRE + * the integration (install + config + decryptable URL). The daemon should + * be started on demand — when the user clicks "View Observability" in + * KiloClaw's web UI, KiloClaw's controller exec's `clawmetry sync` in the + * background and opens the dashboard URL in a new tab. This avoids burning + * compute / bandwidth / cloud storage syncing for users who never look at + * the dashboard. The daemon, once started, can read the persisted local + * OpenClaw session files and publish a catch-up batch. + * + * KiloClaw's web UI should expose two controller endpoints: + * GET /_kilo/clawmetry-dashboard-url → returns the contents of + * dashboard-url.txt + * POST /_kilo/clawmetry-start-sync → spawns `nohup clawmetry sync &` + * (idempotent — checks pgrep first) + * + * Same provisioning flow any user installing clawmetry on a fresh OpenClaw + * box would use — no special endpoint, no partner key, no secrets to manage. * * Failures NEVER block boot — observability is best-effort. Operators can * set `KILOCLAW_CLAWMETRY_DISABLED=true` to skip entirely. @@ -726,7 +751,7 @@ export function provisionClawMetrySync(env: EnvLike, deps: BootstrapDeps = defau payload.email = userEmail; } - let token: string; + let registered: ClawMetryRegisterResponse; try { const result = deps.execFileSync( 'curl', @@ -744,42 +769,58 @@ export function provisionClawMetrySync(env: EnvLike, deps: BootstrapDeps = defau ], { stdio: 'pipe' } ); - const parsed = JSON.parse(String(result)) as ClawMetryRegisterResponse; - if (!parsed.api_key) { - console.warn(`ClawMetry: register returned no api_key — ${parsed.error ?? 'unknown error'}`); + registered = JSON.parse(String(result)) as ClawMetryRegisterResponse; + if (!registered.api_key) { + console.warn( + `ClawMetry: register returned no api_key — ${registered.error ?? 'unknown error'}` + ); return; } - token = parsed.api_key; } catch (error) { const message = error instanceof Error ? error.message : String(error); console.warn(`ClawMetry: register request failed — ${message}`); return; } + // Generate the E2E encryption key client-side. The cloud will never see + // this. AES-256-GCM uses a 32-byte key — base64 encoding gives 44 chars. + const encryptionKey = crypto.randomBytes(32).toString('base64'); + const nodeId = registered.node_id || machineId; + if (!deps.existsSync(CLAWMETRY_DIR)) { deps.mkdirSync(CLAWMETRY_DIR, { recursive: true, mode: 0o700 }); } - deps.writeFileSync(CLAWMETRY_TOKEN_PATH, token); - deps.chmodSync(CLAWMETRY_TOKEN_PATH, 0o600); - // Spawn `clawmetry sync` detached, fire-and-forget. ClawMetry's own - // process supervisor handles its lifecycle from here. Failure to start - // does not block boot — gateway is the critical path, observability is - // best-effort. - try { - deps.execFileSync( - 'sh', - [ - '-c', - `nohup clawmetry sync --token "$(cat ${CLAWMETRY_TOKEN_PATH})" >/var/log/clawmetry-sync.log 2>&1 &`, - ], - { stdio: 'pipe' } - ); - console.log(`ClawMetry: sync daemon started (machine=${machineId})`); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.warn(`ClawMetry: failed to start sync daemon — ${message}`); - } + // Schema matches what `clawmetry connect` writes (clawmetry/cli.py:495). + // `clawmetry sync` reads from this file via clawmetry/sync.py:924. + const config = { + api_key: registered.api_key, + node_id: nodeId, + platform: 'Linux', + connected_at: new Date().toISOString(), + encryption_key: encryptionKey, + }; + deps.writeFileSync(CLAWMETRY_CONFIG_PATH, JSON.stringify(config, null, 2)); + deps.chmodSync(CLAWMETRY_CONFIG_PATH, 0o600); + + // Self-decrypting dashboard URL. The `#fragment` is never sent to any + // server (browsers strip it from outgoing requests) — the dashboard JS + // reads it client-side and stashes the enc_key in localStorage. KiloClaw's + // web UI should fetch this file via a controller endpoint and render it + // as a "View Observability Dashboard" link. + const dashboardUrl = `${apiBase}/cloud#key=${encodeURIComponent(encryptionKey)}&node=${encodeURIComponent(nodeId)}`; + deps.writeFileSync(CLAWMETRY_DASHBOARD_URL_PATH, dashboardUrl + '\n'); + deps.chmodSync(CLAWMETRY_DASHBOARD_URL_PATH, 0o600); + + // Deliberately DO NOT spawn `clawmetry sync` here — see the deferred-sync + // note in the function docstring. KiloClaw's web UI starts the daemon on + // demand when the user clicks "View Observability" so we don't burn + // resources syncing data nobody is going to look at. The persisted local + // OpenClaw session files give the daemon a complete catch-up log to + // publish whenever it does start. + console.log( + `ClawMetry: provisioned (node=${nodeId}, E2E enabled) — sync deferred until user opens dashboard` + ); } // ---- Step 7: Onboard / doctor + config patching ---- diff --git a/services/kiloclaw/docs/clawmetry-integration.md b/services/kiloclaw/docs/clawmetry-integration.md index eb19c8b8be..c18d776605 100644 --- a/services/kiloclaw/docs/clawmetry-integration.md +++ b/services/kiloclaw/docs/clawmetry-integration.md @@ -1,25 +1,61 @@ # ClawMetry observability integration -[ClawMetry](https://clawmetry.com) is a real-time observability dashboard for OpenClaw agents (sessions, token usage, cost, tool timeline, channels, alerts). Every KiloClaw instance ships with the ClawMetry sync daemon pre-installed and auto-registers on first boot — agent activity flows to a per-instance free ClawMetry account at `app.clawmetry.com`. +[ClawMetry](https://clawmetry.com) is a free, end-to-end-encrypted observability dashboard for OpenClaw agents (sessions, token usage, cost, tool timeline, channels, alerts). Every KiloClaw instance ships with ClawMetry pre-installed and pre-wired — but the sync daemon stays dormant until the user clicks "View Observability" in KiloClaw's web UI. No wasted compute / bandwidth / storage for users who never look. ## What this gives the user -- A real-time dashboard of their agent's sessions, token spend, and tool activity +- Real-time dashboard of agent sessions, token spend, and tool activity (once they open it) - Free tier: one node (their KiloClaw instance), 90-day session retention -- No setup required — works out of the box on every new instance +- Zero setup — works out of the box; one click to view +- E2E encrypted: cloud only ever sees ciphertext; encryption key never leaves their KiloClaw instance ## How it works -The bootstrap step `provisionClawMetrySync` (controller, [bootstrap.ts](../controller/src/bootstrap.ts)) does the same thing any user installing ClawMetry on a fresh OpenClaw box would do: +### At bootstrap (every new instance) + +The `provisionClawMetrySync` step in [`controller/src/bootstrap.ts`](../controller/src/bootstrap.ts) does the same thing any user installing ClawMetry on a fresh OpenClaw box would do — minus starting the daemon: 1. POSTs `{hostname, machine_id, platform: 'Linux', email?}` to `${CLAWMETRY_API_BASE}/api/register` (a public, idempotent endpoint) -2. Receives `{api_key: 'cm_...'}` back — a per-machine token -3. Writes the token to `/root/.clawmetry/token` (mode 0600) -4. Spawns `clawmetry sync` as a detached background process — events flow to ClawMetry cloud from then on +2. Receives `{api_key, dashboard_id, node_id}` back — a per-machine token +3. Generates a 32-byte AES-256-GCM `encryption_key` **client-side** — never sent to any server +4. Writes `/root/.clawmetry/config.json` (mode 0600) with the schema `clawmetry sync` expects: `{api_key, encryption_key, node_id, platform, connected_at}` +5. Writes `/root/.clawmetry/dashboard-url.txt` (mode 0600) with a self-decrypting URL: `https://app.clawmetry.com/cloud#key=&node=` — the `#fragment` is never sent to any server (browsers strip it from outgoing requests) + +**The sync daemon is NOT spawned here.** Deferred until the user actually wants to view the dashboard. + +### When the user clicks "View Observability" in KiloClaw's web UI + +KiloClaw's web UI should expose two controller endpoints: + +``` +GET /_kilo/clawmetry-dashboard-url → returns contents of dashboard-url.txt +POST /_kilo/clawmetry-start-sync → spawns `nohup clawmetry sync &` (idempotent — pgrep first) +``` + +The button click handler: + +```ts +async function viewClawmetryDashboard() { + // Start the sync daemon on demand (idempotent — controller checks pgrep) + await fetch('/_kilo/clawmetry-start-sync', { method: 'POST' }); + // Open the self-decrypting dashboard URL in a new tab + const url = await fetch('/_kilo/clawmetry-dashboard-url').then(r => r.text()); + window.open(url, '_blank'); +} +``` -No special endpoint. No partner key. No secrets to manage. Tokens are scoped to `machine_id` (Fly machine ID, falling back to HOSTNAME), so each instance gets its own ClawMetry account. Users with multiple KiloClaw instances can link them later via the standard `clawmetry connect` OTP flow. +Once the daemon starts, it reads `config.json`, processes any persisted local OpenClaw session files (catch-up batch), and streams live events from then on. The dashboard shows "Syncing your data..." until the first events arrive. -Failures are warned and skipped — they NEVER block boot. Observability is best-effort; the gateway is the critical path. +## E2E encryption guarantee + +| Where | Has plaintext events? | Has encryption_key? | +| -------------------------------------------- | ----------------------------- | ------------------------------------------ | +| User's KiloClaw instance (Fly machine) | ✅ yes | ✅ yes (in `/root/.clawmetry/config.json`) | +| ClawMetry cloud (`app.clawmetry.com`) | ❌ no — only ciphertext blobs | ❌ no | +| KiloClaw's cloud (`Kilo-Org/cloud`) | ❌ no | ❌ no | +| User's browser (after opening dashboard URL) | ✅ decrypted client-side | ✅ yes (from URL fragment → localStorage) | + +The encryption key flows: instance → URL fragment → user's browser. **Never** through any server. Even KiloClaw's controller endpoint that returns the URL only sees the URL string itself; the fragment is meaningful only to the user's browser. ## Versioning @@ -31,12 +67,12 @@ Trade-off: builds are not bit-reproducible across rebuilds. That's intentional; All optional: -| Variable | Default | Purpose | -| ----------------------------- | --------------------------- | -------------------------------------------------------------------------------- | -| `CLAWMETRY_API_BASE` | `https://app.clawmetry.com` | Override for staging / self-hosted ClawMetry | -| `KILOCLAW_USER_EMAIL` | unset | Email attached to the account for recovery (optional — account works without it) | -| `GITHUB_EMAIL` | unset | Fallback when `KILOCLAW_USER_EMAIL` is absent | -| `KILOCLAW_CLAWMETRY_DISABLED` | unset | Set to `'true'` to disable the integration entirely (escape hatch) | +| Variable | Default | Purpose | +| ----------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `CLAWMETRY_API_BASE` | `https://app.clawmetry.com` | Override for staging / self-hosted ClawMetry | +| `KILOCLAW_USER_EMAIL` | unset | Email attached to the account so dashboard OTP-login surfaces this node under the user's existing ClawMetry account | +| `GITHUB_EMAIL` | unset | Fallback when `KILOCLAW_USER_EMAIL` is absent | +| `KILOCLAW_CLAWMETRY_DISABLED` | unset | Set to `'true'` to disable the integration entirely (escape hatch) | `FLY_MACHINE_ID` and `HOSTNAME` are read directly from the runtime env (Fly auto-injects them); they don't need to be set. @@ -46,13 +82,28 @@ After deploying a new KiloClaw image: ```bash fly ssh console -s --app -cat /root/.clawmetry/token # cm_<32 hex chars> -ps -ef | grep 'clawmetry sync' # daemon should be running -tail -f /var/log/clawmetry-sync.log +ls -la /root/.clawmetry/ # config.json + dashboard-url.txt, both 0600 +cat /root/.clawmetry/dashboard-url.txt # https://app.clawmetry.com/cloud#key=...&node=... +ps -ef | grep 'clawmetry sync' # should show NOTHING (deferred — daemon not running yet) ``` -Then visit `https://app.clawmetry.com/cloud` and sign in with the email that was attached (via `clawmetry connect` OTP flow) — the user's KiloClaw instance shows up as a node within ~1 minute. +Then in browser: paste the dashboard URL → see "Syncing your data..." overlay → KiloClaw web UI's "View Observability" button (when it's added) will start the daemon + open the URL in one click. + +Once sync starts: + +```bash +ps -ef | grep 'clawmetry sync' # daemon now running +tail -20 /var/log/clawmetry-sync.log # confirms events publishing +``` ## Disabling -Set `KILOCLAW_CLAWMETRY_DISABLED=true` on the instance env. On next boot the bootstrap step skips silently. Existing tokens at `/root/.clawmetry/token` remain — to fully clean up, also stop the sync daemon and delete the token file. +Set `KILOCLAW_CLAWMETRY_DISABLED=true` on the instance env. On next boot the bootstrap step skips silently. Existing files at `/root/.clawmetry/` remain — to fully clean up, also stop any running sync daemon and delete the directory. + +## Follow-up work + +This PR pre-wires the integration but doesn't add the UI surface. Tracking issue for KiloClaw web UI work: + +- Add "View Observability Dashboard" button (likely in instance detail or settings page) +- Add controller endpoints `/_kilo/clawmetry-dashboard-url` (GET) and `/_kilo/clawmetry-start-sync` (POST, idempotent) +- Optional: show sync status (running / stopped) so users can see whether their data is up-to-date From 296a2335f076081f1b3262981ada13b07236548a Mon Sep 17 00:00:00 2001 From: vivekchand Date: Thu, 7 May 2026 12:43:01 +0200 Subject: [PATCH 04/12] feat(kiloclaw): wire up "View Observability" UI end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the ClawMetry integration so it works user-visibly out of the box, not just at the bootstrap layer. Three small additions: 1. Controller endpoints (services/kiloclaw/controller/src/routes/clawmetry.ts) - GET /_kilo/clawmetry-dashboard-url → returns the self-decrypting URL written at bootstrap (404 if provisioning didn't run / disabled) - POST /_kilo/clawmetry-start-sync → spawns `clawmetry sync` detached, idempotent (returns alreadyRunning:true when daemon exists) - Same bearer-token gate as the rest of /_kilo/* routes - 10 unit tests covering auth, success, missing-file, idempotency, spawn-failure paths 2. Web app proxy route (apps/web/src/app/api/kiloclaw/clawmetry/[instanceId]) - Single POST that does both: triggers start-sync (best-effort, logged but non-fatal) then fetches dashboard URL, returns it to browser - Uses the existing `/i/{instanceId}/*` worker proxy → no new platform routes, no new DO methods, no new internal client surface - JWT-authenticated via standard getUserFromAuth + generateApiToken 3. UI button (KiloClawDetail.tsx) - "View Observability" in the existing action button row - Toast feedback: "Starting ClawMetry sync…" → "Opening ClawMetry…" or error - window.open(url, '_blank', 'noopener,noreferrer') — fragment never reaches our servers; browser stashes enc_key in localStorage and decrypts events client-side E2E flow: KiloClaw bootstrap (existing) → installs ClawMetry, writes config.json + dashboard-url.txt → sync daemon dormant User clicks "View Observability" → POST /api/kiloclaw/clawmetry/{instanceId} → POST {worker}/i/{id}/_kilo/clawmetry-start-sync (controller spawns daemon) → GET {worker}/i/{id}/_kilo/clawmetry-dashboard-url → window.open(url) → dashboard JS reads #fragment → decrypts events → "Syncing your data…" until first events arrive Tests: 90 files / 1762 pass (kiloclaw service). Web typecheck + lint clean. Format clean. Closes the gap between bootstrap-side provisioning and user-visible observability — no follow-up PR needed. --- .../kiloclaw/clawmetry/[instanceId]/route.ts | 88 +++++++++++ .../subscriptions/kiloclaw/KiloClawDetail.tsx | 26 ++++ services/kiloclaw/controller/src/index.ts | 2 + .../controller/src/routes/clawmetry.test.ts | 141 ++++++++++++++++++ .../controller/src/routes/clawmetry.ts | 128 ++++++++++++++++ 5 files changed, 385 insertions(+) create mode 100644 apps/web/src/app/api/kiloclaw/clawmetry/[instanceId]/route.ts create mode 100644 services/kiloclaw/controller/src/routes/clawmetry.test.ts create mode 100644 services/kiloclaw/controller/src/routes/clawmetry.ts diff --git a/apps/web/src/app/api/kiloclaw/clawmetry/[instanceId]/route.ts b/apps/web/src/app/api/kiloclaw/clawmetry/[instanceId]/route.ts new file mode 100644 index 0000000000..d17c6f7da1 --- /dev/null +++ b/apps/web/src/app/api/kiloclaw/clawmetry/[instanceId]/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from 'next/server'; +import { getUserFromAuth } from '@/lib/user.server'; +import { KILOCLAW_API_URL } from '@/lib/config.server'; +import { generateApiToken, TOKEN_EXPIRY } from '@/lib/tokens'; + +/** + * POST /api/kiloclaw/clawmetry/[instanceId] + * + * One-shot endpoint backing the "View Observability Dashboard" button. Does + * two things on the user's KiloClaw instance and returns the dashboard URL + * the browser should open in a new tab: + * + * 1. POST `/_kilo/clawmetry-start-sync` — spawns the sync daemon (idempotent) + * 2. GET `/_kilo/clawmetry-dashboard-url` — reads the self-decrypting URL + * + * Both calls go through the existing per-instance worker proxy + * (`{KILOCLAW_API_URL}/i/{instanceId}/...`) which already handles JWT auth, + * access control, and the gateway-token signing for the controller. + * + * The returned URL contains the AES-256-GCM enc_key in its `#fragment` — + * that fragment is meaningful only to the browser (servers never see it). + * The browser stashes the key in localStorage and decrypts ClawMetry + * events client-side. + */ +export async function POST(_req: Request, { params }: { params: Promise<{ instanceId: string }> }) { + const { instanceId } = await params; + if (!instanceId || !/^[A-Za-z0-9_-]+$/.test(instanceId)) { + return NextResponse.json({ error: 'Invalid instance ID' }, { status: 400 }); + } + + const { user, authFailedResponse } = await getUserFromAuth({ adminOnly: false }); + if (authFailedResponse) return authFailedResponse; + + if (!KILOCLAW_API_URL) { + return NextResponse.json({ error: 'KiloClaw not configured' }, { status: 503 }); + } + + const token = generateApiToken(user, undefined, { expiresIn: TOKEN_EXPIRY.fiveMinutes }); + const proxyBase = `${KILOCLAW_API_URL}/i/${encodeURIComponent(instanceId)}`; + const headers = { Authorization: `Bearer ${token}` }; + + // 1. Start the sync daemon — idempotent. Failure here is non-fatal; we + // still try to return the dashboard URL so the user can at least see + // historical data (or an empty dashboard with a clear error state). + try { + const startRes = await fetch(`${proxyBase}/_kilo/clawmetry-start-sync`, { + method: 'POST', + headers, + }); + if (!startRes.ok && startRes.status !== 404) { + console.warn(`[clawmetry] start-sync returned ${startRes.status} for instance ${instanceId}`); + } + } catch (err) { + console.warn(`[clawmetry] start-sync error for instance ${instanceId}:`, err); + } + + // 2. Fetch the self-decrypting dashboard URL. + let urlRes: Response; + try { + urlRes = await fetch(`${proxyBase}/_kilo/clawmetry-dashboard-url`, { headers }); + } catch (err) { + console.error(`[clawmetry] dashboard-url fetch failed for instance ${instanceId}:`, err); + return NextResponse.json({ error: 'Failed to reach instance' }, { status: 502 }); + } + + if (urlRes.status === 404) { + return NextResponse.json( + { + error: + 'ClawMetry not provisioned on this instance — try redeploying or check KILOCLAW_CLAWMETRY_DISABLED env var', + }, + { status: 404 } + ); + } + if (!urlRes.ok) { + return NextResponse.json( + { error: `Instance returned ${urlRes.status}` }, + { status: urlRes.status } + ); + } + + const body = (await urlRes.json()) as { url?: string }; + if (!body.url) { + return NextResponse.json({ error: 'Instance returned no dashboard URL' }, { status: 502 }); + } + + return NextResponse.json({ url: body.url }); +} diff --git a/apps/web/src/components/subscriptions/kiloclaw/KiloClawDetail.tsx b/apps/web/src/components/subscriptions/kiloclaw/KiloClawDetail.tsx index 6c14c33e98..49c6d9f2ff 100644 --- a/apps/web/src/components/subscriptions/kiloclaw/KiloClawDetail.tsx +++ b/apps/web/src/components/subscriptions/kiloclaw/KiloClawDetail.tsx @@ -342,6 +342,32 @@ export function KiloClawDetail({ instanceId }: { instanceId: string }) { )} + + {subscription.showConversionPrompt ? (