diff --git a/packages/agent/package.json b/packages/agent/package.json index b6f4326c64..78f3334b0e 100644 --- a/packages/agent/package.json +++ b/packages/agent/package.json @@ -139,6 +139,7 @@ "@opentelemetry/semantic-conventions": "^1.28.0", "@types/jsonwebtoken": "^9.0.10", "commander": "^14.0.2", + "fflate": "^0.8.2", "hono": "^4.11.7", "jsonwebtoken": "^9.0.2", "minimatch": "^10.0.3", diff --git a/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts b/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts index 0dc8536af8..850002be2c 100644 --- a/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts +++ b/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts @@ -119,6 +119,22 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => { expectsUnsupportedChunk: false, commandInMessage: null, }, + { + label: + "newly installed skill command is refreshed before unsupported check", + sessionId: "s-new-skill", + prompt: "/local-test-skill", + knownCommands: undefined, + supportedCommandsAfterReload: [ + { + name: "local-test-skill", + description: "Local test skill", + argumentHint: "", + }, + ], + expectsUnsupportedChunk: false, + commandInMessage: null, + }, { label: "known plugin/skill command with early idle is not flagged as unsupported", @@ -137,6 +153,11 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => { tc.sessionId, tc.knownCommands as Set | undefined, ); + if ("supportedCommandsAfterReload" in tc) { + vi.mocked(query.supportedCommands).mockResolvedValue([ + ...tc.supportedCommandsAfterReload, + ]); + } const promptPromise = agent.prompt({ sessionId: tc.sessionId, @@ -171,6 +192,10 @@ describe("ClaudeAcpAgent.prompt — early idle handling", () => { expect( findUnsupportedChunkText(client.sessionUpdate.mock.calls), ).toBeUndefined(); + if ("supportedCommandsAfterReload" in tc) { + expect(query.reloadSkills).toHaveBeenCalled(); + expect(query.supportedCommands).toHaveBeenCalled(); + } } }); }); diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 9d1456b398..e0d5c744a0 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -459,6 +459,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent { promptReplayed = true; } + if (commandMatch && !isLocalOnlyCommand) { + await this.refreshSlashCommandsForPrompt(commandMatch[1]); + } + if (this.session.promptRunning) { const isSteer = isSteerMeta(params._meta); if (isSteer) { @@ -2164,6 +2168,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { private async sendAvailableCommandsUpdate(): Promise { const commands = await this.session.query.supportedCommands(); + this.session.knownSlashCommands = collectKnownSlashCommands(commands); const available = getAvailableSlashCommands(commands); await this.client.sessionUpdate({ sessionId: this.sessionId, @@ -2175,6 +2180,27 @@ export class ClaudeAcpAgent extends BaseAcpAgent { this.updateBreakdownCategory("skills", estimateSkillsTokens(available)); } + private async refreshSlashCommandsForPrompt(command: string): Promise { + const commandName = command.slice(1); + if (this.session.knownSlashCommands?.has(commandName)) { + return; + } + if (commandName.includes(":") || commandName.includes("__")) { + return; + } + + try { + await this.session.query.reloadSkills(); + await this.sendAvailableCommandsUpdate(); + } catch (error) { + this.logger.warn("Failed to refresh slash commands before prompt", { + sessionId: this.sessionId, + command, + error: error instanceof Error ? error.message : String(error), + }); + } + } + /** Update one category of the context-breakdown baseline so the next * `_posthog/usage_update` carries fresher numbers. No-op when the baseline * hasn't been initialized yet (e.g. in a unit-test session). */ diff --git a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts index a8b8ffe3e3..6af7b5dfa3 100644 --- a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts +++ b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.test.ts @@ -79,6 +79,24 @@ describe("promptToClaude", () => { expect(result.priority).toBe("next"); }); + it("adds local skill context before the visible slash command", () => { + const result = promptToClaude({ + sessionId: "session-1", + prompt: [{ type: "text", text: "/mimi" }], + _meta: { + localSkillContext: + "Apply the local skill instructions and include LOCAL_SKILL_MARKER.", + localSkillName: "mimi", + }, + }); + + expect(result.message.content).toHaveLength(1); + expect(result.message.content[0]).toMatchObject({ + type: "text", + text: expect.stringContaining("LOCAL_SKILL_MARKER"), + }); + }); + it("leaves priority and shouldQuery unset for a normal message", () => { const result = promptToClaude({ sessionId: "session-1", diff --git a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts index 37d9a87a29..9c73b080f8 100644 --- a/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts +++ b/packages/agent/src/adapters/claude/conversion/acp-to-sdk.ts @@ -80,6 +80,18 @@ function transformMcpCommand(text: string): string { return text; } +function isLocalSkillCommandChunk( + chunk: PromptRequest["prompt"][number], + skillName: string, +): boolean { + if (chunk.type !== "text") { + return false; + } + + const match = chunk.text.trim().match(/^\/([^\s]+)(?:\s+[\s\S]*)?$/); + return match?.[1] === skillName; +} + function processPromptChunk( chunk: PromptRequest["prompt"][number], content: ContentBlockParam[], @@ -168,8 +180,24 @@ export function promptToClaude(prompt: PromptRequest): SDKUserMessage { if (typeof prContext === "string") { content.push(sdkText(prContext)); } + const localSkillContext = meta?.localSkillContext; + if (typeof localSkillContext === "string") { + content.push(sdkText(localSkillContext)); + } + const localSkillName = + typeof meta?.localSkillName === "string" ? meta.localSkillName : null; + let skippedLocalSkillCommand = false; for (const chunk of prompt.prompt) { + if ( + localSkillContext && + localSkillName && + !skippedLocalSkillCommand && + isLocalSkillCommandChunk(chunk, localSkillName) + ) { + skippedLocalSkillCommand = true; + continue; + } processPromptChunk(chunk, content, context); } diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index 9a05f3be5f..e330ef32b8 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -1,3 +1,6 @@ +import { createHash } from "node:crypto"; +import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { zipSync } from "fflate"; import jwt from "jsonwebtoken"; import { type SetupServerApi, setupServer } from "msw/node"; import { @@ -227,6 +230,12 @@ function getNextTestPort(): number { return port; } +function exactArrayBuffer(bytes: Uint8Array): ArrayBuffer { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy.buffer; +} + // The Claude Agent SDK has an internal readMessages() loop that rejects with // "Query closed before response received" during cleanup. The SDK starts this // promise in the constructor without a .catch() handler, so the rejection is @@ -946,6 +955,100 @@ describe("AgentServer HTTP Mode", () => { const body = await response.json(); expect(body.error).toBe("No active session for this run"); }, 20000); + + it("rewrites a bundled local skill slash command before sending the prompt", async () => { + const skillDefinition = [ + "---", + "name: local-test-skill", + "description: Test skill", + "---", + "", + "Reply with LOCAL_SKILL_MARKER from the bundled skill.", + ].join("\n"); + const bundle = zipSync({ + "SKILL.md": new TextEncoder().encode(skillDefinition), + }); + const checksum = createHash("sha256") + .update(Buffer.from(bundle)) + .digest("hex"); + + const s = createServer(); + await s.start(); + const prompt = vi.fn( + async (_params: { + prompt: ContentBlock[]; + _meta?: Record; + }) => ({ stopReason: "cancelled" }) as { stopReason: string }, + ); + const downloadArtifact = vi.fn(async () => exactArrayBuffer(bundle)); + const serverInternals = s as unknown as { + session: { clientConnection: { prompt: typeof prompt } }; + posthogAPI: { downloadArtifact: typeof downloadArtifact }; + }; + serverInternals.session.clientConnection.prompt = prompt; + serverInternals.posthogAPI.downloadArtifact = downloadArtifact; + + const token = createToken(); + const response = await fetch(`http://localhost:${port}/command`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "skill-command", + method: "user_message", + params: { + content: "/local-test-skill with context", + artifacts: [ + { + id: "skill-artifact-1", + name: "local-test-skill.zip", + type: "skill_bundle", + source: "posthog_code_skill", + storage_path: "tasks/artifacts/local-test-skill.zip", + content_type: "application/zip", + metadata: { + skill_name: "local-test-skill", + skill_source: "user", + content_sha256: checksum, + bundle_format: "zip", + schema_version: 1, + }, + }, + ], + }, + }), + }); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + result?: { stopReason?: string }; + }; + expect(body.result?.stopReason).toBe("cancelled"); + expect(downloadArtifact).toHaveBeenCalledWith( + "test-task-id", + "test-run-id", + "tasks/artifacts/local-test-skill.zip", + ); + expect(prompt).toHaveBeenCalledOnce(); + + const sentPrompt = prompt.mock.calls[0]?.[0].prompt; + const sentMeta = prompt.mock.calls[0]?.[0]._meta; + const sentText = sentPrompt?.find( + (block): block is Extract => + block.type === "text", + )?.text; + + expect(sentText).toBe("/local-test-skill with context"); + expect(sentMeta?.localSkillContext).toContain( + 'local skill "/local-test-skill"', + ); + expect(sentMeta?.localSkillContext).toContain("LOCAL_SKILL_MARKER"); + expect(sentMeta?.localSkillContext).toContain("with context"); + expect(sentMeta?.localSkillName).toBe("local-test-skill"); + }, 20000); }); describe("404 handling", () => { diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 1767cece60..f6aaa5ec25 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -1,5 +1,6 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { basename, join } from "node:path"; +import { createHash } from "node:crypto"; +import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { basename, dirname, isAbsolute, join, relative } from "node:path"; import { pathToFileURL } from "node:url"; import type { ContentBlock, @@ -14,6 +15,7 @@ import { import { type ServerType, serve } from "@hono/node-server"; import { execGh } from "@posthog/git/gh"; import { getCurrentBranch } from "@posthog/git/queries"; +import { unzipSync } from "fflate"; import { Hono } from "hono"; import { z } from "zod"; import packageJson from "../../package.json" with { type: "json" }; @@ -229,6 +231,23 @@ interface ActiveSession { pendingHandoffGitState?: HandoffLocalGitState; } +interface InstalledSkillBundle { + skillName: string; + skillDefinition: string; + contentSha256: string; + skillRoot: string; +} + +interface BuiltPrompt { + prompt: ContentBlock[]; + meta?: Record; +} + +interface LocalSkillPromptContext { + skillName: string; + context: string; +} + function getTaskRunStateString( taskRun: TaskRun | null, key: string, @@ -260,6 +279,9 @@ export class AgentServer { private prAttributionChain: Promise = Promise.resolve(); private lastReportedBranch: string | null = null; private resumeState: ResumeState | null = null; + private installedSkillBundles = new Set(); + private installedSkillBundleInfo = new Map(); + private installingSkillBundles = new Map>(); // Guards against concurrent session initialization. autoInitializeSession() and // the GET /events SSE handler can both call initializeSession() — the SSE connection // often arrives while newSession() is still awaited (this.session is still null), @@ -689,7 +711,7 @@ export class AgentServer { ? params.artifacts.length : 0, }); - const prompt = await this.buildPromptFromContentAndArtifacts({ + const builtPrompt = await this.buildPromptFromContentAndArtifacts({ content: params.content as string | ContentBlock[] | undefined, artifacts: Array.isArray(params.artifacts) ? (params.artifacts as TaskRunArtifact[]) @@ -697,6 +719,7 @@ export class AgentServer { taskId: this.session.payload.task_id, runId: this.session.payload.run_id, }); + const prompt = builtPrompt.prompt; if (prompt.length === 0) { throw new Error("User message cannot be empty"); } @@ -711,16 +734,19 @@ export class AgentServer { this.session.logWriter.resetTurnMessages(this.session.payload.run_id); + const promptMeta: Record = { + ...(builtPrompt.meta ?? {}), + ...(this.detectedPrUrl + ? { + prContext: this.buildDetectedPrContext(this.detectedPrUrl), + } + : {}), + }; + const result = await this.session.clientConnection.prompt({ sessionId: this.session.acpSessionId, prompt, - ...(this.detectedPrUrl && { - _meta: { - // Keep the live-session PR override aligned with the startup - // prompt policy so non-Slack runs remain review-first. - prContext: this.buildDetectedPrContext(this.detectedPrUrl), - }, - }), + ...(Object.keys(promptMeta).length > 0 ? { _meta: promptMeta } : {}), }); this.logger.debug("User message completed", { @@ -1069,6 +1095,18 @@ export class AgentServer { : runtimeAdapter === "codex" ? "auto" : "bypassPermissions"; + const pendingUserArtifactIds = Array.isArray( + runState?.pending_user_artifact_ids, + ) + ? runState.pending_user_artifact_ids.filter( + (artifactId): artifactId is string => typeof artifactId === "string", + ) + : []; + await this.installSkillBundleArtifacts( + payload.task_id, + payload.run_id, + this.getArtifactsById(preTaskRun?.artifacts, pendingUserArtifactIds), + ); const sessionResponse = await clientConnection.newSession({ cwd: this.config.repositoryPath ?? "/tmp/workspace", mcpServers: this.config.mcpServers ?? [], @@ -1242,8 +1280,10 @@ export class AgentServer { : null; const pendingUserPrompt = await this.getPendingUserPrompt(taskRun); let initialPrompt: ContentBlock[] = []; - if (pendingUserPrompt?.length) { - initialPrompt = pendingUserPrompt; + let initialPromptMeta: Record | undefined; + if (pendingUserPrompt?.prompt.length) { + initialPrompt = pendingUserPrompt.prompt; + initialPromptMeta = pendingUserPrompt.meta; } else if (initialPromptOverride) { initialPrompt = [{ type: "text", text: initialPromptOverride }]; } else if (task.description) { @@ -1259,7 +1299,7 @@ export class AgentServer { taskId: payload.task_id, descriptionLength: promptBlocksToText(initialPrompt).length, usedInitialPromptOverride: !!initialPromptOverride, - usedPendingUserMessage: !!pendingUserPrompt?.length, + usedPendingUserMessage: !!pendingUserPrompt?.prompt.length, }); this.session.logWriter.resetTurnMessages(payload.run_id); @@ -1267,6 +1307,7 @@ export class AgentServer { const result = await this.session.clientConnection.prompt({ sessionId: this.session.acpSessionId, prompt: initialPrompt, + ...(initialPromptMeta ? { _meta: initialPromptMeta } : {}), }); this.logger.debug("Initial task message completed", { @@ -1344,7 +1385,9 @@ export class AgentServer { : `The workspace from the previous session was not restored from a checkpoint, so you are starting with a fresh environment. Your conversation history is fully preserved below.`; let resumePromptBlocks: ContentBlock[]; - if (pendingUserPrompt?.length) { + let resumePromptMeta: Record | undefined; + if (pendingUserPrompt?.prompt.length) { + resumePromptMeta = pendingUserPrompt.meta; resumePromptBlocks = [ { type: "text", @@ -1354,7 +1397,7 @@ export class AgentServer { `${conversationSummary}\n\n` + `The user has sent a new message:\n\n`, }, - ...pendingUserPrompt, + ...pendingUserPrompt.prompt, { type: "text", text: "\n\nRespond to the user's new message above. You have full context from the previous session.", @@ -1377,7 +1420,7 @@ export class AgentServer { taskId: payload.task_id, conversationTurns: this.resumeState.conversation.length, promptLength: promptBlocksToText(resumePromptBlocks).length, - hasPendingUserMessage: !!pendingUserPrompt?.length, + hasPendingUserMessage: !!pendingUserPrompt?.prompt.length, checkpointApplied, hasGitCheckpoint: !!this.resumeState.latestGitCheckpoint, gitCheckpointBranch: @@ -1392,6 +1435,7 @@ export class AgentServer { const result = await this.session.clientConnection.prompt({ sessionId: this.session.acpSessionId, prompt: resumePromptBlocks, + ...(resumePromptMeta ? { _meta: resumePromptMeta } : {}), }); this.logger.debug("Resume message completed", { @@ -1429,7 +1473,7 @@ export class AgentServer { private async getPendingUserPrompt( taskRun: TaskRun | null, - ): Promise { + ): Promise { if (!taskRun) return null; const state = taskRun.state as Record | undefined; const message = state?.pending_user_message; @@ -1448,9 +1492,9 @@ export class AgentServer { this.logger.debug("Built pending user prompt", { hasMessage: typeof message === "string" && message.trim().length > 0, requestedArtifactCount: artifactIds.length, - blockTypes: prompt.map((block) => block.type), + blockTypes: prompt.prompt.map((block) => block.type), }); - return prompt.length > 0 ? prompt : null; + return prompt.prompt.length > 0 ? prompt : null; } private getClearedPendingUserState(taskRun: TaskRun | null): string[] | null { @@ -1495,15 +1539,31 @@ export class AgentServer { artifacts?: TaskRunArtifact[]; taskId: string; runId: string; - }): Promise { + }): Promise { const contentBlocks = content ? normalizeCloudPromptContent(content) : []; + await this.installSkillBundleArtifacts(taskId, runId, artifacts ?? []); + const localSkillContext = this.buildInstalledSkillPromptContext( + contentBlocks, + runId, + artifacts ?? [], + ); const artifactBlocks = await this.hydrateArtifactsToPrompt( taskId, runId, - artifacts ?? [], + (artifacts ?? []).filter((artifact) => artifact.type !== "skill_bundle"), ); - return [...contentBlocks, ...artifactBlocks]; + return { + prompt: [...contentBlocks, ...artifactBlocks], + ...(localSkillContext + ? { + meta: { + localSkillContext: localSkillContext.context, + localSkillName: localSkillContext.skillName, + } satisfies Record, + } + : {}), + }; } private getArtifactsById( @@ -1561,6 +1621,293 @@ export class AgentServer { ).flatMap((artifactBlock) => (artifactBlock ? [artifactBlock] : [])); } + private async installSkillBundleArtifacts( + taskId: string, + runId: string, + artifacts: TaskRunArtifact[], + ): Promise { + const skillBundleArtifacts = artifacts.filter( + (artifact) => artifact.type === "skill_bundle", + ); + if (skillBundleArtifacts.length === 0) { + return; + } + + this.logger.debug("Installing skill bundle artifacts", { + taskId, + runId, + artifactCount: skillBundleArtifacts.length, + artifactNames: skillBundleArtifacts.map((artifact) => artifact.name), + }); + + for (const artifact of skillBundleArtifacts) { + await this.installSkillBundleArtifact(taskId, runId, artifact); + } + } + + private buildInstalledSkillPromptContext( + contentBlocks: ContentBlock[], + runId: string, + artifacts: TaskRunArtifact[], + ): LocalSkillPromptContext | null { + if (contentBlocks.length === 0) { + return null; + } + + const textBlockIndex = contentBlocks.findIndex( + (block): block is Extract => + block.type === "text" && block.text.trim().length > 0, + ); + if (textBlockIndex === -1) { + return null; + } + + const textBlock = contentBlocks[textBlockIndex]; + if (textBlock.type !== "text") { + return null; + } + + const invocation = this.parseLocalSkillInvocation(textBlock.text); + if (!invocation) { + return null; + } + + const hasMatchingArtifact = artifacts.some( + (artifact) => + artifact.type === "skill_bundle" && + artifact.metadata?.skill_name === invocation.skillName, + ); + if (!hasMatchingArtifact) { + return null; + } + + const installedSkill = this.installedSkillBundleInfo.get( + this.getInstalledSkillBundleInfoKey(runId, invocation.skillName), + ); + if (!installedSkill) { + return null; + } + + return { + skillName: invocation.skillName, + context: this.buildInstalledSkillPrompt(installedSkill, invocation.args), + }; + } + + private parseLocalSkillInvocation( + textValue: string, + ): { skillName: string; args?: string } | null { + const trimmed = textValue.trim(); + const match = trimmed.match(/^\/([^\s]+)(?:\s+([\s\S]*))?$/); + if (!match?.[1]) { + return null; + } + + return { + skillName: match[1], + ...(match[2]?.trim() ? { args: match[2].trim() } : {}), + }; + } + + private buildInstalledSkillPrompt( + skill: InstalledSkillBundle, + args: string | undefined, + ): string { + return [ + `The user invoked the local skill "/${skill.skillName}". Apply these skill instructions for this turn.`, + "", + `--- BEGIN LOCAL SKILL ${skill.skillName} ---`, + skill.skillDefinition.trim(), + `--- END LOCAL SKILL ${skill.skillName} ---`, + "", + `Installed skill path: ${skill.skillRoot}`, + "", + "User request:", + args?.trim() || `Run /${skill.skillName}.`, + ].join("\n"); + } + + private getInstalledSkillBundleInfoKey( + runId: string, + skillName: string, + ): string { + return `${runId}:${skillName}`; + } + + private async installSkillBundleArtifact( + taskId: string, + runId: string, + artifact: TaskRunArtifact, + ): Promise { + const metadata = artifact.metadata; + const skillName = metadata?.skill_name; + const expectedSha256 = metadata?.content_sha256; + + if (!artifact.storage_path || !skillName || !expectedSha256) { + throw new Error( + `Skill bundle artifact ${artifact.name} is missing metadata`, + ); + } + + const installKey = `${runId}:${expectedSha256}:${skillName}`; + if ( + this.installedSkillBundles.has(installKey) && + this.installedSkillBundleInfo.has( + this.getInstalledSkillBundleInfoKey(runId, skillName), + ) + ) { + return; + } + + const inFlight = this.installingSkillBundles.get(installKey); + if (inFlight) { + return inFlight; + } + + const installPromise = this.performSkillBundleInstall( + taskId, + runId, + artifact, + artifact.storage_path, + installKey, + skillName, + expectedSha256, + ); + this.installingSkillBundles.set(installKey, installPromise); + try { + await installPromise; + } finally { + this.installingSkillBundles.delete(installKey); + } + } + + private async performSkillBundleInstall( + taskId: string, + runId: string, + artifact: TaskRunArtifact, + storagePath: string, + installKey: string, + skillName: string, + expectedSha256: string, + ): Promise { + const data = await this.posthogAPI.downloadArtifact( + taskId, + runId, + storagePath, + ); + if (!data) { + throw new Error(`Failed to download skill bundle ${artifact.name}`); + } + + const buffer = Buffer.from(data); + const actualSha256 = createHash("sha256").update(buffer).digest("hex"); + if (actualSha256 !== expectedSha256) { + throw new Error(`Skill bundle ${skillName} failed checksum validation`); + } + + const safeSkillName = this.getSafeArtifactName(skillName); + const skillRoot = join( + this.config.repositoryPath ?? "/tmp/workspace", + ".posthog", + "skills", + runId, + actualSha256, + safeSkillName, + ); + + await rm(skillRoot, { recursive: true, force: true }); + await mkdir(skillRoot, { recursive: true }); + await this.extractSkillBundle(buffer, skillRoot); + + const skillDefinition = await readFile( + join(skillRoot, "SKILL.md"), + "utf-8", + ).catch(() => null); + if (!skillDefinition?.trim()) { + throw new Error(`Skill bundle ${skillName} does not contain SKILL.md`); + } + + const copyFailures: Array<{ destination: string; error: unknown }> = []; + await Promise.all( + this.getSkillInstallDirectories(safeSkillName).map( + async (destination) => { + try { + await rm(destination, { recursive: true, force: true }); + await mkdir(dirname(destination), { recursive: true }); + await cp(skillRoot, destination, { recursive: true }); + } catch (error) { + copyFailures.push({ destination, error }); + } + }, + ), + ); + if (copyFailures.length > 0) { + this.logger.warn("Failed to copy skill bundle to some skill roots", { + taskId, + runId, + skillName, + failedDestinations: copyFailures.map((failure) => failure.destination), + }); + } + + this.installedSkillBundles.add(installKey); + this.installedSkillBundleInfo.set( + this.getInstalledSkillBundleInfoKey(runId, skillName), + { + skillName, + skillDefinition, + contentSha256: actualSha256, + skillRoot, + }, + ); + this.logger.debug("Installed skill bundle artifact", { + taskId, + runId, + skillName, + contentSha256: actualSha256, + }); + } + + private async extractSkillBundle( + archive: Uint8Array, + destinationRoot: string, + ): Promise { + const entries = unzipSync(archive); + for (const [entryName, content] of Object.entries(entries)) { + const normalizedEntryName = entryName.replaceAll("\\", "/"); + if ( + !normalizedEntryName || + normalizedEntryName.endsWith("/") || + normalizedEntryName.startsWith("/") || + normalizedEntryName.split("/").includes("..") + ) { + continue; + } + + const destinationPath = join(destinationRoot, normalizedEntryName); + const relativeDestination = relative(destinationRoot, destinationPath); + if ( + !relativeDestination || + relativeDestination.startsWith("..") || + isAbsolute(relativeDestination) + ) { + continue; + } + + await mkdir(dirname(destinationPath), { recursive: true }); + await writeFile(destinationPath, Buffer.from(content)); + } + } + + private getSkillInstallDirectories(skillName: string): string[] { + const home = process.env.HOME ?? "/tmp"; + return [ + join("/scripts", "plugins", "posthog", "skills", skillName), + join(home, ".agents", "skills", skillName), + join(home, ".claude", "skills", skillName), + ]; + } + private async hydrateArtifactToPromptBlock( taskId: string, runId: string, @@ -1606,7 +1953,10 @@ export class AgentServer { private getSafeArtifactName(name: string): string { const baseName = basename(name).trim(); const normalizedName = baseName.replace(/[^\w.-]/g, "_"); - return normalizedName.length > 0 ? normalizedName : "attachment"; + if (normalizedName.length === 0 || /^\.+$/.test(normalizedName)) { + return "attachment"; + } + return normalizedName; } private async autoInitializeSession(): Promise { diff --git a/packages/agent/tsup.config.ts b/packages/agent/tsup.config.ts index 19269bb8d4..203ee1d91a 100644 --- a/packages/agent/tsup.config.ts +++ b/packages/agent/tsup.config.ts @@ -83,7 +83,12 @@ const sharedOptions = { splitting: false, outDir: "dist", target: "node20", - noExternal: ["@posthog/shared", "@posthog/git", "@posthog/enricher"], + noExternal: [ + "@posthog/shared", + "@posthog/git", + "@posthog/enricher", + "fflate", + ], external: [ ...builtinModules, ...builtinModules.map((m) => `node:${m}`), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c7194a670..276f32ce31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -746,6 +746,9 @@ importers: commander: specifier: ^14.0.2 version: 14.0.3 + fflate: + specifier: ^0.8.2 + version: 0.8.2 hono: specifier: ^4.11.7 version: 4.11.7