From 729cd8d2a65bee36d066cb60e7b3dff7eb7f50d2 Mon Sep 17 00:00:00 2001 From: gavrielc Date: Sat, 30 May 2026 10:35:31 +0300 Subject: [PATCH] feat: add /upload-trace command to upload session trace to Hugging Face MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a runner-handled /upload-trace slash command (admin-gated, like /clear) that uploads the current session's Claude Code transcript to the user's own private {hf_user}/nanoclaw-traces dataset, browsable in the HF Agent Trace Viewer. The transcript is already in the format the viewer auto-detects, so the command just locates the newest one and pushes it via the Hub commit API. Auth is handled by the OneCLI gateway: curl goes out through the injected HTTPS_PROXY, which adds the user's HF token — no credential ever touches agent code. A missing/unassigned token yields a clear setup message. - container/agent-runner/src/upload-trace.ts: isUploadTraceCommand() + uploadTrace() - poll-loop.ts: recognize and handle /upload-trace in the runner - command-gate.ts: admin-gate /upload-trace on the host - upload-trace.test.ts: unit + integration coverage for the command Co-Authored-By: Claude Opus 4.8 (1M context) --- container/agent-runner/src/formatter.ts | 2 +- container/agent-runner/src/poll-loop.ts | 14 ++ .../agent-runner/src/upload-trace.test.ts | 84 +++++++++++ container/agent-runner/src/upload-trace.ts | 142 ++++++++++++++++++ src/command-gate.ts | 2 +- 5 files changed, 242 insertions(+), 2 deletions(-) create mode 100644 container/agent-runner/src/upload-trace.test.ts create mode 100644 container/agent-runner/src/upload-trace.ts diff --git a/container/agent-runner/src/formatter.ts b/container/agent-runner/src/formatter.ts index 590875c9e..f525447a5 100644 --- a/container/agent-runner/src/formatter.ts +++ b/container/agent-runner/src/formatter.ts @@ -11,7 +11,7 @@ import { TIMEZONE, formatLocalTime } from './timezone.js'; */ export type CommandCategory = 'admin' | 'filtered' | 'passthrough' | 'none'; -const ADMIN_COMMANDS = new Set(['/remote-control', '/clear', '/compact', '/context', '/cost', '/files']); +const ADMIN_COMMANDS = new Set(['/remote-control', '/clear', '/compact', '/context', '/cost', '/files', '/upload-trace']); const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/start']); export interface CommandInfo { diff --git a/container/agent-runner/src/poll-loop.ts b/container/agent-runner/src/poll-loop.ts index 1b7d181a3..51d7af352 100644 --- a/container/agent-runner/src/poll-loop.ts +++ b/container/agent-runner/src/poll-loop.ts @@ -13,6 +13,7 @@ import { stripInternalTags, type RoutingContext, } from './formatter.js'; +import { isUploadTraceCommand, uploadTrace } from './upload-trace.js'; import type { AgentProvider, AgentQuery, ProviderEvent } from './providers/types.js'; const POLL_INTERVAL_MS = 1000; @@ -161,6 +162,19 @@ export async function runPollLoop(config: PollLoopConfig): Promise { commandIds.push(msg.id); continue; } + if ((msg.kind === 'chat' || msg.kind === 'chat-sdk') && isUploadTraceCommand(msg)) { + log('Uploading session trace to Hugging Face'); + writeMessageOut({ + id: generateId(), + kind: 'chat', + platform_id: routing.platformId, + channel_type: routing.channelType, + thread_id: routing.threadId, + content: JSON.stringify({ text: uploadTrace() }), + }); + commandIds.push(msg.id); + continue; + } normalMessages.push(msg); } diff --git a/container/agent-runner/src/upload-trace.test.ts b/container/agent-runner/src/upload-trace.test.ts new file mode 100644 index 000000000..4a20ac68f --- /dev/null +++ b/container/agent-runner/src/upload-trace.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; + +import { initTestSessionDb, closeSessionDb, getInboundDb } from './db/connection.js'; +import { getUndeliveredMessages } from './db/messages-out.js'; +import { getPendingMessages } from './db/messages-in.js'; +import type { MessageInRow } from './db/messages-in.js'; +import { MockProvider } from './providers/mock.js'; +import { runPollLoop } from './poll-loop.js'; +import { isUploadTraceCommand } from './upload-trace.js'; + +beforeEach(() => { + initTestSessionDb(); +}); + +afterEach(() => { + closeSessionDb(); +}); + +describe('isUploadTraceCommand', () => { + const make = (text: unknown) => ({ content: JSON.stringify({ text }) }) as MessageInRow; + + it('matches /upload-trace (case-insensitive, with args)', () => { + expect(isUploadTraceCommand(make('/upload-trace'))).toBe(true); + expect(isUploadTraceCommand(make('/UPLOAD-TRACE'))).toBe(true); + expect(isUploadTraceCommand(make(' /upload-trace now '))).toBe(true); + }); + + it('does not match other text or commands', () => { + expect(isUploadTraceCommand(make('hello'))).toBe(false); + expect(isUploadTraceCommand(make('/upload'))).toBe(false); + expect(isUploadTraceCommand(make('/clear'))).toBe(false); + expect(isUploadTraceCommand({ content: 'not json' } as MessageInRow)).toBe(false); + }); +}); + +describe('poll loop — /upload-trace command', () => { + it('handles the command in the runner, writes a status, skips query', async () => { + getInboundDb() + .prepare( + `INSERT INTO messages_in (id, kind, timestamp, status, platform_id, channel_type, content) + VALUES ('m-upload-trace', 'chat', datetime('now'), 'pending', 'chan-1', 'discord', ?)`, + ) + .run(JSON.stringify({ text: '/upload-trace' })); + + // If the provider were ever queried it would emit this — asserting its + // absence proves the runner intercepted /upload-trace instead of the LLM. + const provider = new MockProvider({}, () => 'should not run'); + const controller = new AbortController(); + const loopPromise = runPollLoopWithTimeout(provider, controller.signal, 5000); + + await waitFor(() => getUndeliveredMessages().length > 0, 5000); + controller.abort(); + + const out = getUndeliveredMessages(); + expect(out).toHaveLength(1); + // A status line from uploadTrace() — never the provider's reply. + const text = JSON.parse(out[0].content).text as string; + expect(text.length).toBeGreaterThan(0); + expect(text).not.toBe('should not run'); + + // Command message was completed (not left pending). + expect(getPendingMessages()).toHaveLength(0); + + await loopPromise.catch(() => {}); + }); +}); + +async function runPollLoopWithTimeout(provider: MockProvider, signal: AbortSignal, timeoutMs: number): Promise { + return Promise.race([ + runPollLoop({ provider, providerName: 'mock', cwd: '/tmp' }), + new Promise((_, reject) => { + signal.addEventListener('abort', () => reject(new Error('aborted'))); + }), + new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), timeoutMs)), + ]); +} + +async function waitFor(condition: () => boolean, timeoutMs: number): Promise { + const start = Date.now(); + while (!condition()) { + if (Date.now() - start > timeoutMs) throw new Error('waitFor timeout'); + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} diff --git a/container/agent-runner/src/upload-trace.ts b/container/agent-runner/src/upload-trace.ts new file mode 100644 index 000000000..e7d9a702b --- /dev/null +++ b/container/agent-runner/src/upload-trace.ts @@ -0,0 +1,142 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +import type { MessageInRow } from './db/messages-in.js'; + +/** + * `/upload-trace` command: upload this session's Claude Code transcript to the user's + * own private `{hf_user}/nanoclaw-traces` dataset, browsable in the HF Agent + * Trace Viewer. The transcript the Claude provider keeps under + * `~/.claude/projects//.jsonl` is already in the format the + * viewer auto-detects, so this just locates the newest one and pushes it. + * + * Auth is the OneCLI gateway's job: curl goes out through the injected + * HTTPS_PROXY, which adds the user's HF token. We never see the raw token, and + * a 401 from `whoami` is our "not signed in" signal. + */ + +/** + * Narrow check for /upload-trace — the runner handles this command directly + * (no LLM turn). Admin-gated by the host router before it reaches the container. + */ +export function isUploadTraceCommand(msg: MessageInRow): boolean { + let text = ''; + try { + text = (JSON.parse(msg.content)?.text ?? '').trim(); + } catch { + return false; // non-JSON content is never a command + } + return text.toLowerCase().startsWith('/upload-trace'); +} + +/** Newest Claude Code transcript jsonl (the current session). */ +function newestTranscript(): string | null { + const projects = path.join(os.homedir(), '.claude', 'projects'); + let best: { p: string; m: number } | null = null; + let dirs: string[]; + try { + dirs = fs.readdirSync(projects); + } catch { + return null; + } + for (const dir of dirs) { + let files: string[]; + try { + files = fs.readdirSync(path.join(projects, dir)); + } catch { + continue; + } + for (const f of files) { + if (!f.endsWith('.jsonl')) continue; + const p = path.join(projects, dir, f); + const m = fs.statSync(p).mtimeMs; + if (!best || m > best.m) best = { p, m }; + } + } + return best?.p ?? null; +} + +function curl(args: string[], input?: string): { ok: boolean; out: string } { + const r = spawnSync('curl', args, { input, encoding: 'utf-8' }); + return { ok: r.status === 0, out: (r.stdout ?? '') + (r.stderr ?? '') }; +} + +/** Returns a user-facing status line. Never throws. */ +export function uploadTrace(): string { + const file = newestTranscript(); + if (!file) return 'No transcript to upload for this session yet.'; + + const who = curl(['-sf', 'https://huggingface.co/api/whoami-v2']); + if (!who.ok) { + return [ + "Can't upload — no Hugging Face token is available to this agent. To set it up:", + '', + '1. Create a token with WRITE access at https://huggingface.co/settings/tokens', + ' (New token → type "Write" → copy it).', + '', + '2. Add it to the OneCLI vault. Open the dashboard — remotely at https://app.onecli.sh/', + ' or on the host at http://127.0.0.1:10254 — then Secrets → New secret,', + ' paste the token, and set the host pattern to huggingface.co', + '', + '3. Assign it to this agent — new agents start with no secrets attached.', + ' In the same dashboard, open this agent and set its secret mode to "all"; or from the host run:', + ' onecli agents list # find this agent\'s id', + ' onecli agents set-secret-mode --id --mode all', + '', + 'Then run /upload-trace again — no restart needed.', + ].join('\n'); + } + let user: string | undefined; + try { + user = JSON.parse(who.out)?.name; + } catch { + /* fall through */ + } + if (!user) return 'Could not resolve your Hugging Face username.'; + + const repo = `${user}/nanoclaw-traces`; + // Idempotent create — ignore failure (already exists / no-op). The + // Content-Type header is required: without it curl sends form-encoding and + // the Hub rejects the body with 400 (expected string at "name"). + curl([ + '-sf', + '-X', + 'POST', + 'https://huggingface.co/api/repos/create', + '-H', + 'Content-Type: application/json', + '-d', + JSON.stringify({ type: 'dataset', name: 'nanoclaw-traces', private: true }), + ]); + + const content = fs.readFileSync(file).toString('base64'); + const repoPath = `sessions/${path.basename(file)}`; + const ndjson = + JSON.stringify({ key: 'header', value: { summary: 'add session trace' } }) + + '\n' + + JSON.stringify({ + key: 'file', + value: { path: repoPath, encoding: 'base64', content }, + }) + + '\n'; + + const commit = curl( + [ + '-sf', + '-X', + 'POST', + `https://huggingface.co/api/datasets/${repo}/commit/main`, + '-H', + 'Content-Type: application/x-ndjson', + '--data-binary', + '@-', + ], + ndjson, + ); + if (!commit.ok) { + return 'Upload to Hugging Face failed (the transcript may be too large for an inline commit).'; + } + return `Uploaded → https://huggingface.co/datasets/${repo}/blob/main/${repoPath}`; +} diff --git a/src/command-gate.ts b/src/command-gate.ts index a0c1979a4..4f600eb12 100644 --- a/src/command-gate.ts +++ b/src/command-gate.ts @@ -12,7 +12,7 @@ import { getDb, hasTable } from './db/connection.js'; export type GateResult = { action: 'pass' } | { action: 'filter' } | { action: 'deny'; command: string }; const FILTERED_COMMANDS = new Set(['/help', '/login', '/logout', '/doctor', '/config', '/remote-control']); -const ADMIN_COMMANDS = new Set(['/clear', '/compact', '/context', '/cost', '/files']); +const ADMIN_COMMANDS = new Set(['/clear', '/compact', '/context', '/cost', '/files', '/upload-trace']); /** * Classify a message and decide whether it should reach the container.