Seven variables out of .env. DATA_PATH, OFFICER_ITEMS_DIR and HOME_DIR are gone from the code entirely; PUBLIC_URL, PUBLIC_BUILD_ENV, JWT_SECRET and VAULT_STORE_KEY are no longer written by the setup script. data-path.ts now derives OFFICER_ROOT as dirname(process.cwd()), with data/, capabilities/ and dockers/ as fixed names under it. The direction used to run the other way — DATA_PATH from env, then OFFICER_ROOT = dirname(DATA_PATH) in app-store/paths.ts — which meant three environment variables that had to agree with each other and with the tree on disk. Eight files re-read process.env.DATA_PATH independently, each with its own `?? cwd()/data` fallback. They import the one value now, which is what made removing it safe: otherwise each would have derived its own and drifted. Three things this turned up. The cwd pin in ecosystem.profile.cjs was broken. It set `cwd: __dirname` under a comment asserting "__dirname is the repo root — this file sits beside ecosystem.config.cjs", which stopped being true when these files moved into ecosystem-files/. It walks up to the platform's package.json now, which holds wherever the file lives. That was a live bug before this change and a load-bearing one after it, since cwd now decides where the install is. assertInstallLayout joins the other two boot assertions. A wrong cwd does not error — it computes a plausible root somewhere else and writes managed homes and agent runs into it, so the install looks empty and the data looks lost with nothing naming the cause. It throws before serve(), first of the three, because a wrong answer there makes the other two check the wrong files. getOwnerHomeDir captures homedir() once at module load rather than per call. Measured on bun 1.3.10: both os.homedir() and os.userInfo().homedir return $HOME when set rather than reading passwd, and user-instance.ts assigns process.env.HOME on its way to spawning an agent. A lazy read would have returned the owner's home on the first call and a member's afterwards. data-path.ts imports only node builtins, so it is evaluated before any of that runs. JWT_SECRET and VAULT_STORE_KEY leaving .env means an install made by this script does not boot — jwt.ts throws at module load without one. That is the agreed sequencing: they move to the SQLite store (docs/secret-store.md), and writing them here meanwhile would create a second origin for a secret the store then has to be reconciled with. Said plainly in .env.example and in lib/env.sh rather than left to be discovered. Not typechecked: node_modules is empty here and installs are frozen. Every edited file parses under `bun build --no-bundle`; the profile loads and pins the right cwd; assertInstallLayout was exercised from both the repo and /tmp; the setup section was run and writes five variables. Prettier was NOT run — 3.9.6 via bunx is not the pinned resolution and reformatted unrelated unions and line wraps in six files, so those were reverted and the edits re-applied by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
193 lines
6.8 KiB
TypeScript
193 lines
6.8 KiB
TypeScript
import { readdir, stat, readFile, writeFile, mkdir, realpath, open } from 'node:fs/promises';
|
|
import { join, dirname, resolve as resolvePath } from 'node:path';
|
|
import { createRouter } from '../../create-router';
|
|
import { parseTailLine } from './progress';
|
|
|
|
// "Activity" — follow the agent's background work live. Two producers, one primitive (tail a file):
|
|
// - Harness `run_in_background` tasks write /tmp/claude-<uid>/<encoded-cwd>/tasks/<task_id>.output
|
|
// (append-only, live). Discovered by scanning; the FILE is the source of truth.
|
|
// - Detached (setsid) jobs write an arbitrary log; they self-register via POST /announce with the log
|
|
// path, so they're followable too (the setsid case is on the critical path, not a footnote).
|
|
// Owner-only (the account gate confines non-owners to /auth+/music). Paths are traversal-guarded.
|
|
|
|
export const activityRouter = createRouter();
|
|
|
|
import { DATA_PATH } from '../../data-path';
|
|
|
|
const HOME_DIR = process.env.HOME_DIR ?? process.env.HOME ?? '';
|
|
const ANNOUNCED_PATH = join(DATA_PATH, 'activity', 'announced.json');
|
|
const ALLOWED_ROOTS = ['/tmp', DATA_PATH, HOME_DIR].filter(Boolean);
|
|
const ACTIVE_WINDOW_MS = 120_000; // a task file touched within this is considered "active"
|
|
|
|
type TaskFile = { taskId: string; path: string; cwdLabel: string; sizeBytes: number; mtimeMs: number };
|
|
type Announced = { name: string; path: string; ts: number };
|
|
|
|
// Scan the claude tmp tree for harness background-task output files (…/tasks/<task_id>.output).
|
|
async function listTaskFiles(): Promise<TaskFile[]> {
|
|
const out: TaskFile[] = [];
|
|
let tmp: string[];
|
|
try {
|
|
tmp = await readdir('/tmp');
|
|
} catch {
|
|
return out;
|
|
}
|
|
for (const name of tmp) {
|
|
if (!name.startsWith('claude-')) continue;
|
|
const base = join('/tmp', name);
|
|
let cwdDirs: import('node:fs').Dirent[];
|
|
try {
|
|
cwdDirs = await readdir(base, { withFileTypes: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const cwd of cwdDirs) {
|
|
if (!cwd.isDirectory()) continue;
|
|
const tasksDir = join(base, cwd.name, 'tasks');
|
|
let files: string[];
|
|
try {
|
|
files = await readdir(tasksDir);
|
|
} catch {
|
|
continue;
|
|
}
|
|
for (const f of files) {
|
|
if (!f.endsWith('.output')) continue;
|
|
const p = join(tasksDir, f);
|
|
const st = await stat(p).catch(() => null);
|
|
if (!st) continue;
|
|
out.push({ taskId: f.slice(0, -'.output'.length), path: p, cwdLabel: cwd.name, sizeBytes: st.size, mtimeMs: st.mtimeMs });
|
|
}
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
async function readAnnounced(): Promise<Announced[]> {
|
|
try {
|
|
return JSON.parse(await readFile(ANNOUNCED_PATH, 'utf8')) as Announced[];
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/** Resolve a path only if it lands under an allowed root (guards traversal); the file need not exist yet. */
|
|
async function resolveAllowed(p: string): Promise<string | null> {
|
|
const under = (abs: string) => ALLOWED_ROOTS.some((root) => abs === root || abs.startsWith(root + '/'));
|
|
try {
|
|
const real = await realpath(p);
|
|
return under(real) ? real : null;
|
|
} catch {
|
|
const abs = resolvePath(p);
|
|
return under(abs) ? abs : null;
|
|
}
|
|
}
|
|
|
|
// GET /tasks — registry of followable background work.
|
|
activityRouter.get('/tasks', async (ctx) => {
|
|
const [harness, announced] = await Promise.all([listTaskFiles(), readAnnounced()]);
|
|
const now = Date.now();
|
|
return ctx.json({
|
|
tasks: harness
|
|
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
.map((t) => ({
|
|
id: t.taskId,
|
|
source: 'harness' as const,
|
|
cwd: t.cwdLabel,
|
|
sizeBytes: t.sizeBytes,
|
|
updatedAt: Math.round(t.mtimeMs),
|
|
active: now - t.mtimeMs < ACTIVE_WINDOW_MS,
|
|
})),
|
|
detached: announced.map((a) => ({ id: a.name, source: 'detached' as const, path: a.path, announcedAt: a.ts })),
|
|
});
|
|
});
|
|
|
|
// POST /announce { name, path } — register a detached (setsid) job's log so it's followable too.
|
|
activityRouter.post('/announce', async (ctx) => {
|
|
const body = ctx.get('body') as { name?: string; path?: string } | undefined;
|
|
if (!body?.name || !body?.path) return ctx.text('name and path are required', 400);
|
|
const safe = await resolveAllowed(body.path);
|
|
if (!safe) return ctx.text('path is not under an allowed root', 403);
|
|
await mkdir(dirname(ANNOUNCED_PATH), { recursive: true });
|
|
const list = await readAnnounced();
|
|
const next = [{ name: body.name, path: safe, ts: Date.now() }, ...list.filter((a) => a.name !== body.name)].slice(0, 100);
|
|
await writeFile(ANNOUNCED_PATH, JSON.stringify(next));
|
|
return ctx.json({ ok: true, name: body.name, path: safe });
|
|
});
|
|
|
|
// GET /stream?task=<id> | ?path=<abs> — SSE that tails the output file, emitting {kind:'line'|'progress'}.
|
|
activityRouter.get('/stream', async (ctx) => {
|
|
const task = ctx.req.query('task');
|
|
const pathQ = ctx.req.query('path');
|
|
|
|
let target: string | null = null;
|
|
if (task) {
|
|
target = (await listTaskFiles()).find((f) => f.taskId === task)?.path ?? null;
|
|
} else if (pathQ) {
|
|
target = await resolveAllowed(pathQ);
|
|
}
|
|
if (!target) return ctx.text('a valid task id or path is required', 400);
|
|
|
|
const filePath = target;
|
|
const enc = new TextEncoder();
|
|
|
|
const stream = new ReadableStream<Uint8Array>({
|
|
async start(controller) {
|
|
let offset = 0;
|
|
let closed = false;
|
|
const send = (obj: unknown) => {
|
|
try {
|
|
controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
|
|
} catch {
|
|
/* controller closed */
|
|
}
|
|
};
|
|
|
|
const drain = async () => {
|
|
try {
|
|
const st = await stat(filePath);
|
|
if (st.size < offset) offset = 0; // truncated / rotated
|
|
if (st.size > offset) {
|
|
const fh = await open(filePath, 'r');
|
|
const buf = Buffer.alloc(st.size - offset);
|
|
await fh.read(buf, 0, buf.length, offset);
|
|
await fh.close();
|
|
offset = st.size;
|
|
for (const line of buf.toString('utf8').split('\n')) {
|
|
if (line !== '') send(parseTailLine(line));
|
|
}
|
|
}
|
|
} catch {
|
|
/* file may not exist yet — keep polling */
|
|
}
|
|
};
|
|
|
|
await drain();
|
|
const poll = setInterval(drain, 800);
|
|
const heartbeat = setInterval(() => {
|
|
try {
|
|
controller.enqueue(enc.encode(': hb\n\n'));
|
|
} catch {
|
|
/* closed */
|
|
}
|
|
}, 15_000);
|
|
|
|
const cleanup = () => {
|
|
if (closed) return;
|
|
closed = true;
|
|
clearInterval(poll);
|
|
clearInterval(heartbeat);
|
|
try {
|
|
controller.close();
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
};
|
|
ctx.req.raw.signal.addEventListener('abort', cleanup);
|
|
setTimeout(cleanup, 60 * 60 * 1000); // safety cap
|
|
},
|
|
});
|
|
|
|
return new Response(stream, {
|
|
headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' },
|
|
});
|
|
});
|