Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/commitperclip-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ jobs:

- name: Dependency Review
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
# Non-fatal: the action errors at startup ("Dependency review is not
# supported on this repository") whenever the Dependency graph feature
# is disabled in repo settings. Guarding the step keeps the `review`
# check honest — it goes red only on real quality-gate failures, not on
# an unavailable platform feature. Remove this guard once Dependency
# graph is enabled (Settings -> Code security & analysis). See FLO-252.
continue-on-error: true
with:
base-ref: ${{ github.event.pull_request.base.sha }}
head-ref: ${{ github.event.pull_request.head.sha }}
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,8 @@ tests/storybook-visual/playwright-report/
.superpowers/
.claude/worktrees/
.herenow

# MCP tooling working dirs (local artifacts — never commit)
.playwright-mcp/
.markdown_vault_mcp/
**/.markdown_vault_mcp/
23 changes: 23 additions & 0 deletions docs/adapters/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,29 @@ Worked examples:
`credentials.json` from the sandbox image's own `$HOME/.claude`. The
snapshot's Claude login is the credential source for the run.

### Working directory (`cwd`) on remote targets

The `cwd` in an adapter's config (`adapterConfig.cwd`) is interpreted relative
to **where the CLI actually runs**:

- **Local targets** — `adapterConfig.cwd` is the local working directory the
CLI is launched in (created if missing).
- **Remote targets (SSH / managed sandbox)** — the remote working directory
comes from the environment's `remoteWorkspacePath`, **not** from
`adapterConfig.cwd`. On every run Paperclip stages the agent's local
workspace/agent-home, syncs it into
`<remoteWorkspacePath>/.paperclip-runtime/runs/<runId>/workspace`, runs there,
and syncs changes back. For a remote target `adapterConfig.cwd` is therefore
**not** used as a local filesystem path.

Do not set `adapterConfig.cwd` to a remote-only path (e.g. `/Users/rob/aignite`)
expecting it to select the remote directory — use the environment's
`remoteWorkspacePath` for that. A remote-only `cwd` is ignored for local
filesystem purposes: it is never `mkdir`-ed on the Paperclip host and never used
as the local staging source. (Before FLO-542 a remote-only `cwd` caused the run
to `mkdir` that path on the Paperclip host and die with `EACCES` before any SSH
connection was made.)

### Hermes local vs gateway

Use `hermes_local` when Paperclip should start the local `hermes` CLI on the
Expand Down
19 changes: 13 additions & 6 deletions packages/adapter-utils/src/acpx-engine/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -980,27 +980,34 @@ async function buildRuntime(input: {
const workspaceBranch = asString(workspaceContext.branchName, "");
const workspaceWorktreePath = asString(workspaceContext.worktreePath, "");
const agentHome = asString(workspaceContext.agentHome, "");
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
const executionTarget = readAdapterExecutionTarget({
executionTarget: input.ctx.executionTarget,
legacyRemoteExecution: input.ctx.executionTransport?.remoteExecution,
});
const remoteExecutionIdentity = adapterExecutionTargetSessionIdentity(executionTarget);
const executionTargetIsRemote = remoteExecutionIdentity !== null;
const configuredCwd = asString(config.cwd, "");
// For remote targets adapterConfig.cwd designates the REMOTE workspace (env.remoteWorkspacePath) and
// must not hijack the LOCAL staging dir; only let it override agent-home for local targets (FLO-542).
const useConfiguredInsteadOfAgentHome =
!executionTargetIsRemote && workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
const effectiveExecutionCwd =
remoteExecutionIdentity && typeof remoteExecutionIdentity.remoteCwd === "string"
? remoteExecutionIdentity.remoteCwd
: cwd;
const executionTargetIsRemote = remoteExecutionIdentity !== null;
const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({
workspaceCwd: effectiveWorkspaceCwd,
workspaceWorktreePath,
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
// Only ensure the LOCAL cwd for local targets; for remote targets `cwd` may be a
// remote-only path and the execution cwd is ensured on the remote host (FLO-542).
if (!executionTargetIsRemote) {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
}

const acpxAgent = normalizeAgent(config);
const mode = normalizeMode(config);
Expand Down
72 changes: 72 additions & 0 deletions packages/adapters/claude-local/src/server/execute.remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const {
runChildProcess,
ensureCommandResolvable,
ensureAbsoluteDirectory,
resolveCommandForLogs,
prepareWorkspaceForSshExecution,
restoreWorkspaceFromSshExecution,
Expand All @@ -26,6 +27,7 @@ const {
startedAt: new Date().toISOString(),
})),
ensureCommandResolvable: vi.fn(async () => undefined),
ensureAbsoluteDirectory: vi.fn(async () => undefined),
resolveCommandForLogs: vi.fn(async () => "ssh://fixture@127.0.0.1:2222/remote/workspace :: claude"),
prepareWorkspaceForSshExecution: vi.fn(async () => ({ gitBacked: false })),
restoreWorkspaceFromSshExecution: vi.fn(async () => undefined),
Expand All @@ -47,6 +49,7 @@ vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
return {
...actual,
ensureCommandResolvable,
ensureAbsoluteDirectory,
resolveCommandForLogs,
runChildProcess,
};
Expand Down Expand Up @@ -333,4 +336,73 @@ describe("claude remote execution", () => {
expect(call?.[2]).toContain("12345678-1234-4abc-9def-123456789012");
});

it("treats a remote-only adapterConfig.cwd as remote-only and never touches the local FS for it (FLO-542)", async () => {
// Robert's scenario: an agent bound to an SSH environment whose adapterConfig.cwd is a
// remote-only path ("/Users/rob/aignite"). For a remote target that path designates the
// REMOTE workspace; it must never (a) be created/stat'd on the Pi (pre-fix this ran
// `mkdir -p /Users/rob/aignite` locally and threw EACCES before any ssh) nor (b) be used
// as the LOCAL staging dir that gets synced up (pre-fix a remote agent_home agent would
// sync an empty/wrong dir because the remote-only path does not exist locally). The local
// staging source must be the agent's local workspace/agent-home cwd instead.
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-claude-remote-only-cwd-"));
cleanupDirs.push(rootDir);
const localAgentHome = path.join(rootDir, "agent-home");
await mkdir(localAgentHome, { recursive: true });
const remoteOnlyCwd = "/Users/rob/aignite";

await execute({
runId: "run-remote-only-cwd",
agent: {
id: "agent-1",
companyId: "company-1",
name: "Claude Coder",
adapterType: "claude_local",
adapterConfig: {},
},
runtime: {
sessionId: null,
sessionParams: null,
sessionDisplayId: null,
taskKey: null,
},
config: {
command: "claude",
cwd: remoteOnlyCwd,
},
context: {
paperclipWorkspace: {
// agent_home source: the local staging dir is the agent's own home directory.
source: "agent_home",
cwd: localAgentHome,
},
},
executionTransport: {
remoteExecution: {
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteWorkspacePath: remoteOnlyCwd,
remoteCwd: remoteOnlyCwd,
privateKey: "PRIVATE KEY",
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
strictHostKeyChecking: true,
},
},
onLog: async () => {},
});

// (a) The local directory helper must never be invoked for a remote target.
expect(ensureAbsoluteDirectory).not.toHaveBeenCalled();
// (b) The workspace synced to the remote is the LOCAL agent-home, never the remote-only cwd.
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1);
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledWith(
expect.objectContaining({ localDir: localAgentHome }),
);
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledWith(
expect.objectContaining({ localDir: localAgentHome }),
);
// And the run still reaches the (mocked) remote CLI spawn.
expect(runChildProcess).toHaveBeenCalledTimes(1);
});

});
22 changes: 18 additions & 4 deletions packages/adapters/claude-local/src/server/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,11 +188,14 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
)
: [];
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
// For remote targets adapterConfig.cwd designates the REMOTE workspace (env.remoteWorkspacePath) and
// must not hijack the LOCAL staging dir; only let it override agent-home for local targets (FLO-542).
const useConfiguredInsteadOfAgentHome =
!executionTargetIsRemote && workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
const shapedWorkspaceEnv = shapePaperclipWorkspaceEnvForExecution({
workspaceCwd: effectiveWorkspaceCwd,
Expand All @@ -201,7 +204,15 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise<Cl
executionTargetIsRemote,
executionCwd: effectiveExecutionCwd,
});
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
// Only ensure the LOCAL cwd for local execution targets. For remote (SSH/sandbox)
// targets `cwd` may be a remote-only path (e.g. a configured adapterConfig.cwd like
// "/Users/rob/aignite"); the execution cwd lives on the remote host and is ensured by
// the remote runtime path (see ensureAdapterExecutionTargetDirectory /
// prepareRemoteManagedRuntime). Doing a local mkdir here fails with EACCES on the Pi
// and kills the run before any ssh happens (FLO-542).
if (!executionTargetIsRemote) {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
}

const envConfig = parseObject(config.env);
const env: Record<string, string> = { ...buildPaperclipEnv(agent) };
Expand Down Expand Up @@ -439,7 +450,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
)
: [];
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
// See buildClaudeRuntimeConfig: for remote targets adapterConfig.cwd is the REMOTE workspace, not a
// local override, so only honor it over agent-home for local targets (FLO-542).
const useConfiguredInsteadOfAgentHome =
!executionTargetIsRemote && workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const hasExplicitClaudeConfigDir =
typeof configEnv.CLAUDE_CONFIG_DIR === "string" && configEnv.CLAUDE_CONFIG_DIR.trim().length > 0;
Expand Down
17 changes: 12 additions & 5 deletions packages/adapters/codex-local/src/server/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,23 +489,30 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
)
: [];
const runtimePrimaryUrl = asString(context.paperclipRuntimePrimaryUrl, "");
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
const envConfig = parseObject(config.env);
const executionTarget = readAdapterExecutionTarget({
executionTarget: ctx.executionTarget,
legacyRemoteExecution: ctx.executionTransport?.remoteExecution,
});
const executionTargetIsRemote = adapterExecutionTargetIsRemote(executionTarget);
const configuredCwd = asString(config.cwd, "");
// For remote targets adapterConfig.cwd designates the REMOTE workspace (env.remoteWorkspacePath) and
// must not hijack the LOCAL staging dir; only let it override agent-home for local targets (FLO-542).
const useConfiguredInsteadOfAgentHome =
!executionTargetIsRemote && workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
const configuredCodexHome =
typeof envConfig.CODEX_HOME === "string" && envConfig.CODEX_HOME.trim().length > 0
? path.resolve(envConfig.CODEX_HOME.trim())
: null;
const codexSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredSkillNames = resolveCodexDesiredSkillNames(config, codexSkillEntries);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
// Only ensure the LOCAL cwd for local targets; for remote targets `cwd` may be a
// remote-only path and the execution cwd is ensured on the remote host (FLO-542).
if (!executionTargetIsRemote) {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
}
const configuredOpenAiApiKey =
typeof envConfig.OPENAI_API_KEY === "string" && envConfig.OPENAI_API_KEY.trim().length > 0
? envConfig.OPENAI_API_KEY.trim()
Expand Down
11 changes: 9 additions & 2 deletions packages/adapters/cursor-local/src/server/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,11 +223,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
)
: [];
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
// For remote targets adapterConfig.cwd designates the REMOTE workspace (env.remoteWorkspacePath) and
// must not hijack the LOCAL staging dir; only let it override agent-home for local targets (FLO-542).
const useConfiguredInsteadOfAgentHome =
!executionTargetIsRemote && workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
// Only ensure the LOCAL cwd for local targets; for remote targets `cwd` may be a
// remote-only path and the execution cwd is ensured on the remote host (FLO-542).
if (!executionTargetIsRemote) {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
}
const cursorSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredCursorSkillNames = resolvePaperclipDesiredSkillNames(config, cursorSkillEntries);
if (!executionTargetIsRemote) {
Expand Down
11 changes: 9 additions & 2 deletions packages/adapters/gemini-local/src/server/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,11 +247,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
)
: [];
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
// For remote targets adapterConfig.cwd designates the REMOTE workspace (env.remoteWorkspacePath) and
// must not hijack the LOCAL staging dir; only let it override agent-home for local targets (FLO-542).
const useConfiguredInsteadOfAgentHome =
!executionTargetIsRemote && workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
// Only ensure the LOCAL cwd for local targets; for remote targets `cwd` may be a
// remote-only path and the execution cwd is ensured on the remote host (FLO-542).
if (!executionTargetIsRemote) {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
}
const geminiSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredGeminiSkillNames = resolvePaperclipDesiredSkillNames(config, geminiSkillEntries);
if (!executionTargetIsRemote) {
Expand Down
11 changes: 9 additions & 2 deletions packages/adapters/grok-local/src/server/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,11 +222,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
)
: [];
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
// For remote targets adapterConfig.cwd designates the REMOTE workspace (env.remoteWorkspacePath) and
// must not hijack the LOCAL staging dir; only let it override agent-home for local targets (FLO-542).
const useConfiguredInsteadOfAgentHome =
!executionTargetIsRemote && workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
// Only ensure the LOCAL cwd for local targets; for remote targets `cwd` may be a
// remote-only path and the execution cwd is ensured on the remote host (FLO-542).
if (!executionTargetIsRemote) {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
}

const grokSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredGrokSkillNames = resolvePaperclipDesiredSkillNames(config, grokSkillEntries);
Expand Down
11 changes: 9 additions & 2 deletions packages/adapters/opencode-local/src/server/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,11 +236,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise<AdapterExec
)
: [];
const configuredCwd = asString(config.cwd, "");
const useConfiguredInsteadOfAgentHome = workspaceSource === "agent_home" && configuredCwd.length > 0;
// For remote targets adapterConfig.cwd designates the REMOTE workspace (env.remoteWorkspacePath) and
// must not hijack the LOCAL staging dir; only let it override agent-home for local targets (FLO-542).
const useConfiguredInsteadOfAgentHome =
!executionTargetIsRemote && workspaceSource === "agent_home" && configuredCwd.length > 0;
const effectiveWorkspaceCwd = useConfiguredInsteadOfAgentHome ? "" : workspaceCwd;
const cwd = effectiveWorkspaceCwd || configuredCwd || process.cwd();
let effectiveExecutionCwd = adapterExecutionTargetRemoteCwd(executionTarget, cwd);
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
// Only ensure the LOCAL cwd for local targets; for remote targets `cwd` may be a
// remote-only path and the execution cwd is ensured on the remote host (FLO-542).
if (!executionTargetIsRemote) {
await ensureAbsoluteDirectory(cwd, { createIfMissing: true });
}
const openCodeSkillEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredOpenCodeSkillNames = resolvePaperclipDesiredSkillNames(config, openCodeSkillEntries);
if (!executionTargetIsRemote) {
Expand Down
Loading
Loading