From 5c3cd777df95d364f96e0c9210c6eec3117336f0 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 10 Oct 2026 15:32:22 -0500 Subject: [PATCH] fix(computers): expose persistent instructions and managed CLI skills Integrate the scoped production correction and existing config test from 2700054c6a. Full Claude and Codex execute regressions remain in the stacked acceptance PR. Co-Authored-By: Paperclip --- .../src/server/claude-config.test.ts | 32 ++++++++++++++++++- .../claude-local/src/server/claude-config.ts | 18 +++++++++-- .../claude-local/src/server/execute.ts | 16 +++++++--- .../codex-local/src/server/execute.ts | 14 ++++++-- server/src/services/heartbeat.ts | 3 ++ 5 files changed, 73 insertions(+), 10 deletions(-) diff --git a/packages/adapters/claude-local/src/server/claude-config.test.ts b/packages/adapters/claude-local/src/server/claude-config.test.ts index f1fc02d1f7..5a07f802fb 100644 --- a/packages/adapters/claude-local/src/server/claude-config.test.ts +++ b/packages/adapters/claude-local/src/server/claude-config.test.ts @@ -1,5 +1,7 @@ import * as fs from "node:fs/promises"; import os from "node:os"; +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AdapterExecutionTarget } from "@paperclipai/adapter-utils/execution-target"; @@ -22,7 +24,7 @@ vi.mock("@paperclipai/adapter-utils/execution-target", async () => { }; }); -import { prepareClaudeConfigSeed, prepareSandboxClaudeProbeRuntime } from "./claude-config.js"; +import { buildRemoteClaudeConfigMaterializationCommand, prepareClaudeConfigSeed, prepareSandboxClaudeProbeRuntime } from "./claude-config.js"; describe("prepareClaudeConfigSeed", () => { const cleanupDirs: string[] = []; @@ -267,3 +269,31 @@ describe("prepareSandboxClaudeProbeRuntime managed-config diagnostics", () => { warnSpy.mockRestore(); }); }); + + +describe("managed Claude user-scope skills", () => { + it("refreshes only its skill symlink and refuses an existing directory", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "claude-user-skills-")); + const config = path.join(root, "config"); + const seed = path.join(root, "seed"); + const first = path.join(root, "first-skills"); + const second = path.join(root, "second-skills"); + const run = (remoteSkillsDir: string) => promisify(execFile)("sh", ["-c", + buildRemoteClaudeConfigMaterializationCommand({ + remoteClaudeConfigDir: config, remoteClaudeConfigSeedDir: seed, remoteSkillsDir, + }), + ], { env: { PATH: process.env.PATH, HOME: root } }); + try { + await fs.mkdir(first); await fs.mkdir(second); + await run(first); + expect(await fs.realpath(path.join(config, "skills"))).toBe(await fs.realpath(first)); + await run(second); + expect(await fs.realpath(path.join(config, "skills"))).toBe(await fs.realpath(second)); + await fs.unlink(path.join(config, "skills")); + await fs.mkdir(path.join(config, "skills")); + await fs.writeFile(path.join(config, "skills", "keep.txt"), "user file"); + await expect(run(first)).rejects.toThrow(); + expect(await fs.readFile(path.join(config, "skills", "keep.txt"), "utf8")).toBe("user file"); + } finally { await fs.rm(root, { recursive: true, force: true }); } + }); +}); diff --git a/packages/adapters/claude-local/src/server/claude-config.ts b/packages/adapters/claude-local/src/server/claude-config.ts index dd759a8099..fd87ccb0d3 100644 --- a/packages/adapters/claude-local/src/server/claude-config.ts +++ b/packages/adapters/claude-local/src/server/claude-config.ts @@ -221,7 +221,16 @@ export async function prepareClaudeConfigSeed( export function buildRemoteClaudeConfigMaterializationCommand(input: { remoteClaudeConfigDir: string; remoteClaudeConfigSeedDir: string; + remoteSkillsDir?: string; }): string { + // --setting-sources user excludes --add-dir's project-scoped skills. Mount + // only the approved bundle into this isolated runtime's user skill scope. + const skillsLink = shellQuote(path.posix.join(input.remoteClaudeConfigDir, "skills")); + const mountSkills = input.remoteSkillsDir + ? ` && { if [ -e ${skillsLink} ] && [ ! -L ${skillsLink} ]; then ` + + `echo 'Managed Claude skills path is not a symlink' >&2; exit 1; fi; ` + + `ln -sfn ${shellQuote(input.remoteSkillsDir)} ${skillsLink}; }` + : ""; return `mkdir -p ${shellQuote(input.remoteClaudeConfigDir)} && ` + `if [ -d ${shellQuote(input.remoteClaudeConfigSeedDir)} ]; then ` + `cp -R ${shellQuote(`${input.remoteClaudeConfigSeedDir}/.`)} ${shellQuote(input.remoteClaudeConfigDir)}/; ` + @@ -230,7 +239,7 @@ export function buildRemoteClaudeConfigMaterializationCommand(input: { `if [ -n "\${HOME:-}" ] && [ -f "\${HOME}/.claude/\${file}" ] && [ ! -f ${shellQuote(input.remoteClaudeConfigDir)}/"\${file}" ]; then ` + `cp "\${HOME}/.claude/\${file}" ${shellQuote(input.remoteClaudeConfigDir)}/"\${file}"; ` + `fi; ` + - `done`; + `done` + mountSkills; } export async function materializeRemoteClaudeConfig(input: { @@ -238,17 +247,22 @@ export async function materializeRemoteClaudeConfig(input: { target: AdapterExecutionTarget | null | undefined; remoteClaudeConfigDir: string; remoteClaudeConfigSeedDir: string; + remoteSkillsDir?: string; options: AdapterExecutionTargetShellOptions; }): Promise { - await runAdapterExecutionTargetShellCommand( + const result = await runAdapterExecutionTargetShellCommand( input.runId, input.target, buildRemoteClaudeConfigMaterializationCommand({ remoteClaudeConfigDir: input.remoteClaudeConfigDir, remoteClaudeConfigSeedDir: input.remoteClaudeConfigSeedDir, + remoteSkillsDir: input.remoteSkillsDir, }), input.options, ); + if (result.exitCode !== 0 || result.timedOut) { + throw new Error("Could not materialize the managed Claude config and skills"); + } } function isNonEmptyString(value: unknown): value is string { diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 53b3baf045..40a7318b09 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -464,7 +464,13 @@ export async function execute(ctx: AdapterExecutionContext): Promise 0; const instructionsFilePath = asString(config.instructionsFilePath, "").trim(); - const instructionsFileDir = instructionsFilePath ? `${path.dirname(instructionsFilePath)}/` : ""; + // Controller scratch supplies prompt bytes; persistent computer paths come + // from the registered instruction copy, including a nested entry file. + const persistentComputer = executionTarget?.kind === "remote" && executionTarget.transport === "computer"; + const promptInstructionsFilePath = persistentComputer + ? asString(workspaceContext.instructionsFilePath, "").trim() + : instructionsFilePath; + const instructionsFileDir = promptInstructionsFilePath ? `${(persistentComputer ? path.posix : path).dirname(promptInstructionsFilePath)}/` : ""; const runtimeConfig = await buildClaudeRuntimeConfig({ agentIdentity: ctx.agentIdentity, runId, @@ -513,12 +519,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise