Files
platform/src/servers/api/agents/agent-runner.ts
T
pastilhasandClaude Opus 5 336e718463 read a member's transcripts as the member
a provisioned member could chat normally and had no conversation list. every
refresh came back empty, so nothing could be resumed, and a new chat never
became a saved one.

nothing was wrong with the logic. the turn runs as them, writes its transcript
into their home, and the platform looks in exactly the right place — it just
cannot read what it finds.

confineUserTree grants the service user a named acl entry on every member home,
with d: defaults so anything created later inherits it. that entry is real and
getfacl shows it. it does not survive a file created at mode 600, because posix
derives the acl mask from the group bits of the creation mode:

    user:officer:rwx    #effective:---
    mask::---

claude writes every transcript at exactly that mode — .claude and projects/ are
775, every *.jsonl is 600. so readdir and stat worked, every read raised eacces,
and summarizeTranscript catches eacces and returns null. the sessions did not
fail, they vanished.

no acl can fix this. the creation mode ands the mask down, so d: defaults cannot
raise it, and the only way up is through `other`, which is every account on the
box. a 600 file has two readers: its owner, and root.

so read as the owner of the file, through the same runAsArgv the terminal and
the agent already use. spawnSync keeps it synchronous, which is what lets it
drop into a 914-line synchronous parser reached from five modules instead of
rippling await through all of it.

the privileged surface turned out to be seven call sites, not the file: stat
needs traverse and readdir needs read, and the 775 directories give both. only
content needed identity.

also fixes a 500. parseClaudeTranscript read the file uncaught after an
existsSync that passes, so deep-linking /chat/<id> as a member threw rather than
404ing. it returns null now, like the list path always did.

verified against a throwaway linux account provisioned the same way a member is
— 700 home, named acl, transcript written as them at 600. before: 0 sessions and
loadClaudeSession null. after: the session, its title, its messages, and a
rename that leaves the file owned by the member at 600.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 15:58:36 +00:00

222 lines
8.8 KiB
TypeScript

import { mkdir } from 'node:fs/promises';
import { randomUUID } from 'node:crypto';
import { basename, dirname, join } from 'node:path';
import type { TurnMessage } from '../chat/types';
import { sendClaudeCodeStreaming } from '../../channels/send-claude-code';
import { renameClaudeSession } from '../chat/claude-sessions';
import { getAgentRunsDir, getOwnerHomeDir } from '../../data-path';
import { getAgentByDirName, DEFAULT_AGENT_MODEL, type AgentRecord } from './agent-files';
import { logger } from '../chat/logger';
// Starting an agent run is deliberately not "spawning a process". The claude binary is already there
// and the sidecar already owns a persistent-session abstraction, so a run is exactly a chat session
// with three things fixed up front: a working directory, an opening prompt loaded from AGENT.md, and
// inputs appended to it. Nothing here kills anything — claude-manager's idle GC reaps the session
// once it goes quiet, the same way it does for /chat.
export type AgentRun = {
/** Our handle for the session. NOT the claude session uuid that names the transcript file. */
sessionKey: string;
dirName: string;
cwd: string;
model: string;
startedAt: number;
finishedAt: number | null;
status: 'running' | 'finished' | 'failed';
/** Claude's own session uuid, once the sidecar reports it. Absent until then. */
claudeSessionId: string | null;
};
// In-memory only, and that is on purpose: the durable record of a run is its transcript on disk plus
// its events in chat_session_events. This map is a live view for the current server process, so
// losing it to a restart costs nothing that matters.
const runs = new Map<string, AgentRun>();
export const listAgentRuns = (dirName?: string): AgentRun[] =>
Array.from(runs.values())
.filter((run) => !dirName || run.dirName === dirName)
.sort((a, b) => b.startedAt - a.startedAt);
export const getAgentRun = (sessionKey: string): AgentRun | null => runs.get(sessionKey) ?? null;
/**
* Resolve a user-supplied path to an absolute one.
*
* The file browser autofills `entry_path` in tilde form (`~/music/foo`), and a prompt must never see
* that: an agent works in absolute paths from a cwd that is NOT the target directory, so a leading
* `~` would either be pasted into a shell that expands it against the wrong home or, worse, treated
* as a literal directory name. Expanding here is what keeps every prompt free of the convention —
* `download-media` had to reimplement this itself, and no agent should have to.
*/
export function absolutizePath(value: string, homeDir: string): string {
const trimmed = value.trim();
if (trimmed === '~') return homeDir;
if (trimmed.startsWith('~/')) return join(homeDir, trimmed.slice(2));
return trimmed;
}
const expandInputs = (inputs: Record<string, unknown>, homeDir: string): Record<string, unknown> =>
Object.fromEntries(
Object.entries(inputs).map(([key, value]) => [
key,
typeof value === 'string' && value.trimStart().startsWith('~') ? absolutizePath(value, homeDir) : value,
]),
);
/**
* The opening prompt: the AGENT.md body verbatim, then the inputs. The body is authored as a complete
* runbook, so nothing is injected ahead of it — the inputs are appended as the one thing the document
* cannot know.
*/
export function buildAgentPrompt(agent: AgentRecord, inputs: Record<string, unknown>): string {
const entries = Object.entries(inputs).filter(([, value]) => value !== undefined && value !== null && value !== '');
if (entries.length === 0) return agent.body.trim();
const lines = entries.map(
([key, value]) => `- **${key}**: ${typeof value === 'string' ? value : JSON.stringify(value)}`,
);
return `${agent.body.trim()}\n\n## Inputs\n\n${lines.join('\n')}\n`;
}
/**
* Give the run's transcript a title you can tell apart from the others.
*
* Without this every run of an agent is titled from the same opening prompt — an AGENT.md body can be
* tens of KB, and the list shows the first line of it, so twenty runs look identical. The transcript
* only becomes addressable at the end of the turn (that's when the harness reports its session uuid),
* which is fine: while a run is live you find it as the newest entry in the agent's project group.
*/
function titleRun(
who: { email: string; home: string; osUser: string | null },
cwd: string,
claudeSessionId: string,
agentName: string,
inputs: Record<string, unknown>,
): void {
const firstPath = Object.values(inputs).find((v): v is string => typeof v === 'string' && v.startsWith('/'));
const subject = firstPath ? basename(firstPath) : null;
const when = new Date().toLocaleString('sv', { dateStyle: 'short', timeStyle: 'short' });
const title = [agentName, subject, when].filter(Boolean).join(' · ');
try {
if (!renameClaudeSession(who, cwd, claudeSessionId, title)) {
logger.warn('Could not title agent run — transcript not found', { claudeSessionId, cwd });
}
} catch (err) {
// Cosmetic. A run that completed successfully must not be reported as failed because of a title.
logger.warn('Failed to title agent run', { claudeSessionId, error: String(err) });
}
}
type StartAgentRunParams = {
dirName: string;
inputs?: Record<string, unknown>;
user: { id: number; email: string; username: string };
};
export type StartAgentRunResult = {
sessionKey: string;
dirName: string;
/**
* The directory the run executes in — which is also its project group in /chat, so the caller can
* build the link to it. This used to also return a ready-made `chatUrl`, which meant the server held
* an opinion about frontend URL shape and drifted the moment that shape changed. `cwd` is the fact;
* the URL is the frontend's business (`chatListPath`).
*/
cwd: string;
model: string;
};
export async function startAgentRun(params: StartAgentRunParams): Promise<StartAgentRunResult> {
const agent = await getAgentByDirName(params.dirName);
if (!agent) throw new Error('Agent not found');
const homeDir = getOwnerHomeDir(params.user.email);
const inputs = expandInputs(params.inputs ?? {}, homeDir);
for (const [key, value] of Object.entries(inputs)) {
if (typeof value === 'string' && value.startsWith('~')) {
throw new Error(`Input "${key}" could not be resolved to an absolute path`);
}
}
// Shared per agent, not per run — this is the project-group pin (see getAgentRunsDir).
const cwd = getAgentRunsDir(agent.dirName);
await mkdir(cwd, { recursive: true });
const sessionKey = randomUUID();
const model = agent.model || DEFAULT_AGENT_MODEL;
// The agent's own directory, so a prompt can refer to its sibling scripts. It has to be injected:
// cwd is the shared runs dir (the project-group pin), NOT the item directory, so nothing the agent
// can see would otherwise tell it where its own files are. Appended last so a real input still wins
// when we pick a subject for the run's title.
const prompt = buildAgentPrompt(agent, { ...inputs, agent_dir: dirname(agent.filePath) });
const run: AgentRun = {
sessionKey,
dirName: agent.dirName,
cwd,
model,
startedAt: Date.now(),
finishedAt: null,
status: 'running',
claudeSessionId: null,
};
runs.set(sessionKey, run);
const onMessage = (msg: TurnMessage) => {
if (msg.type === 'result') {
run.status = 'finished';
run.finishedAt = Date.now();
if (msg.claudeSessionId) {
run.claudeSessionId = msg.claudeSessionId;
titleRun(
// `osUser: null` tracks `homeDir` above: it is `getOwnerHomeDir`, which discards the email it is
// given, so an agent run is always the owner's — its transcript is theirs and readable directly.
// If agent runs ever reach members, this and line 134 have to move together.
{ email: params.user.email, home: homeDir, osUser: null },
cwd,
msg.claudeSessionId,
agent.name || agent.dirName,
inputs,
);
}
} else if (msg.type === 'error') {
run.status = 'failed';
run.finishedAt = Date.now();
logger.error('Agent run failed', { sessionKey, dirName: agent.dirName });
}
};
try {
// durable: true — the events land in chat_session_events, so an officer restart mid-run costs a
// replay rather than the output. That matters more here than in a pipeline step, because an agent
// run is long and, before long, unattended.
await sendClaudeCodeStreaming({
userId: params.user.id,
email: params.user.email,
username: params.user.username,
prompt,
sessionKey,
cwd,
model,
durable: true,
onMessage,
});
} catch (err) {
run.status = 'failed';
run.finishedAt = Date.now();
throw err;
}
logger.info('Agent run started', { sessionKey, dirName: agent.dirName, cwd, model });
return {
sessionKey,
dirName: agent.dirName,
cwd,
model,
};
}