diff --git a/.github/workflows/commitperclip-review.yml b/.github/workflows/commitperclip-review.yml index 6b7122772941..45bb92759fbb 100644 --- a/.github/workflows/commitperclip-review.yml +++ b/.github/workflows/commitperclip-review.yml @@ -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 }} diff --git a/.gitignore b/.gitignore index 63de6b12829b..df4f57838ee1 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/docs/adapters/overview.md b/docs/adapters/overview.md index 83c14e10efb6..c3b1394aa34c 100644 --- a/docs/adapters/overview.md +++ b/docs/adapters/overview.md @@ -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 + `/.paperclip-runtime/runs//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 diff --git a/packages/adapter-utils/src/acpx-engine/execute.ts b/packages/adapter-utils/src/acpx-engine/execute.ts index 243c7adffea2..e9aa1f034f44 100644 --- a/packages/adapter-utils/src/acpx-engine/execute.ts +++ b/packages/adapter-utils/src/acpx-engine/execute.ts @@ -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); diff --git a/packages/adapters/claude-local/src/server/execute.remote.test.ts b/packages/adapters/claude-local/src/server/execute.remote.test.ts index fad8dc2bb4ee..7ccdc7d2addf 100644 --- a/packages/adapters/claude-local/src/server/execute.remote.test.ts +++ b/packages/adapters/claude-local/src/server/execute.remote.test.ts @@ -6,6 +6,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; const { runChildProcess, ensureCommandResolvable, + ensureAbsoluteDirectory, resolveCommandForLogs, prepareWorkspaceForSshExecution, restoreWorkspaceFromSshExecution, @@ -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), @@ -47,6 +49,7 @@ vi.mock("@paperclipai/adapter-utils/server-utils", async () => { return { ...actual, ensureCommandResolvable, + ensureAbsoluteDirectory, resolveCommandForLogs, runChildProcess, }; @@ -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); + }); + }); diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index fa02bfb7e2cb..c7486fe175f3 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -188,11 +188,14 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise 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, @@ -201,7 +204,15 @@ async function buildClaudeRuntimeConfig(input: ClaudeExecutionInput): Promise = { ...buildPaperclipEnv(agent) }; @@ -439,7 +450,10 @@ export async function execute(ctx: AdapterExecutionContext): Promise 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; diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index fec5e6bfb940..418e3bcc612a 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -489,23 +489,30 @@ export async function execute(ctx: AdapterExecutionContext): Promise 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() diff --git a/packages/adapters/cursor-local/src/server/execute.ts b/packages/adapters/cursor-local/src/server/execute.ts index 587f3f45fd95..9b3f042a2cf9 100644 --- a/packages/adapters/cursor-local/src/server/execute.ts +++ b/packages/adapters/cursor-local/src/server/execute.ts @@ -223,11 +223,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise 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) { diff --git a/packages/adapters/gemini-local/src/server/execute.ts b/packages/adapters/gemini-local/src/server/execute.ts index 5ba4b46fc5a3..6ded93251b54 100644 --- a/packages/adapters/gemini-local/src/server/execute.ts +++ b/packages/adapters/gemini-local/src/server/execute.ts @@ -247,11 +247,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise 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) { diff --git a/packages/adapters/grok-local/src/server/execute.ts b/packages/adapters/grok-local/src/server/execute.ts index 45ae407bc789..23fc8457575b 100644 --- a/packages/adapters/grok-local/src/server/execute.ts +++ b/packages/adapters/grok-local/src/server/execute.ts @@ -222,11 +222,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise 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); diff --git a/packages/adapters/opencode-local/src/server/execute.ts b/packages/adapters/opencode-local/src/server/execute.ts index 91f953f2c38e..678d2adf8c7e 100644 --- a/packages/adapters/opencode-local/src/server/execute.ts +++ b/packages/adapters/opencode-local/src/server/execute.ts @@ -236,11 +236,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise 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) { diff --git a/packages/adapters/pi-local/src/server/execute.ts b/packages/adapters/pi-local/src/server/execute.ts index 636fa59a1491..5f1c567072f9 100644 --- a/packages/adapters/pi-local/src/server/execute.ts +++ b/packages/adapters/pi-local/src/server/execute.ts @@ -246,11 +246,18 @@ export async function execute(ctx: AdapterExecutionContext): Promise 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 }); + } if (!executionTargetIsRemote) { await ensureSessionsDir(); diff --git a/server/src/__tests__/runtime-api.test.ts b/server/src/__tests__/runtime-api.test.ts index 5e46bbb06faf..62482647bb37 100644 --- a/server/src/__tests__/runtime-api.test.ts +++ b/server/src/__tests__/runtime-api.test.ts @@ -3,6 +3,7 @@ import { buildRuntimeApiCandidateUrls, choosePrimaryRuntimeApiUrl, collectReachableInterfaceHosts, + resolveRuntimeApiUrl, } from "../runtime-api.js"; describe("runtime API discovery", () => { @@ -155,4 +156,42 @@ describe("runtime API discovery", () => { "fd7a:115c:a1e0::8a3a:a11d", ]); }); + + describe("resolveRuntimeApiUrl", () => { + it("honors a pre-set runtime API URL over the derived one", () => { + expect( + resolveRuntimeApiUrl({ + presetRuntimeApiUrl: "http://127.0.0.1:3100", + derivedRuntimeApiUrl: "http://pc.example.com:3100", + }), + ).toBe("http://127.0.0.1:3100"); + }); + + it("trims a pre-set runtime API URL before honoring it", () => { + expect( + resolveRuntimeApiUrl({ + presetRuntimeApiUrl: " http://127.0.0.1:3100 ", + derivedRuntimeApiUrl: "http://pc.example.com:3100", + }), + ).toBe("http://127.0.0.1:3100"); + }); + + it("falls back to the derived URL when the pre-set value is unset", () => { + expect( + resolveRuntimeApiUrl({ + presetRuntimeApiUrl: undefined, + derivedRuntimeApiUrl: "http://pc.example.com:3100", + }), + ).toBe("http://pc.example.com:3100"); + }); + + it("falls back to the derived URL when the pre-set value is blank", () => { + expect( + resolveRuntimeApiUrl({ + presetRuntimeApiUrl: " ", + derivedRuntimeApiUrl: "http://pc.example.com:3100", + }), + ).toBe("http://pc.example.com:3100"); + }); + }); }); diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index 91cc9fdd734e..98c5575f02f4 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -439,6 +439,12 @@ describe("startServer PAPERCLIP_API_URL handling", () => { loadConfigMock.mockReturnValue(buildTestConfig()); process.env.BETTER_AUTH_SECRET = "test-secret"; delete process.env.PAPERCLIP_API_URL; + // startServer() writes PAPERCLIP_RUNTIME_API_URL into process.env, and a + // pre-set value is now honored as the leading runtime candidate. Clear it + // (and the derived candidates) between tests so a prior startServer() call + // can't leak a runtime URL that overrides the PAPERCLIP_API_URL under test. + delete process.env.PAPERCLIP_RUNTIME_API_URL; + delete process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON; }); afterEach(() => { @@ -474,6 +480,21 @@ describe("startServer PAPERCLIP_API_URL handling", () => { expect(JSON.parse(process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON ?? "[]")[0]).toBe("http://custom-api:3100"); }); + it("leads the runtime candidates with a pre-set PAPERCLIP_RUNTIME_API_URL", async () => { + process.env.PAPERCLIP_RUNTIME_API_URL = "http://127.0.0.1:9999"; + process.env.PAPERCLIP_API_URL = "http://custom-api:3100"; + + await startServer(); + + // The pinned runtime URL is honored as the primary env var ... + expect(process.env.PAPERCLIP_RUNTIME_API_URL).toBe("http://127.0.0.1:9999"); + // ... and leads the candidates list, so agents iterating candidates don't + // fall back onto the public API URL the operator decoupled from. + expect(JSON.parse(process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON ?? "[]")[0]).toBe( + "http://127.0.0.1:9999", + ); + }); + it("falls back to host-based URL when PAPERCLIP_API_URL is not set", async () => { const started = await startServer(); diff --git a/server/src/index.ts b/server/src/index.ts index 2755cc9177c3..d68fe340913e 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -56,7 +56,7 @@ import { reconcileAdapterAvailability, } from "./services/adapter-registry-bootstrap.js"; import { createFeedbackTraceShareClientFromConfig } from "./services/feedback-share-client.js"; -import { buildRuntimeApiCandidateUrls, choosePrimaryRuntimeApiUrl } from "./runtime-api.js"; +import { buildRuntimeApiCandidateUrls, choosePrimaryRuntimeApiUrl, resolveRuntimeApiUrl } from "./runtime-api.js"; import { createPluginWorkerManager } from "./services/plugin-worker-manager.js"; import { createStorageServiceFromConfig } from "./storage/index.js"; import { printStartupBanner } from "./startup-banner.js"; @@ -719,8 +719,19 @@ export async function startServer(): Promise { port: listenPort, }); const configuredApiUrl = process.env.PAPERCLIP_API_URL?.trim() || runtimeApiUrl; + // A pre-set PAPERCLIP_RUNTIME_API_URL is the operator's deliberate runtime + // callback override (e.g. loopback behind a public tunnel). When present it + // wins both as the primary env var and as the leading candidate, so agents + // that iterate the candidates don't fall back onto the public hostname the + // operator decoupled from. When unset, candidates lead with the configured + // API URL exactly as before. + const presetRuntimeApiUrl = process.env.PAPERCLIP_RUNTIME_API_URL?.trim() ?? ""; + const resolvedRuntimeApiUrl = resolveRuntimeApiUrl({ + presetRuntimeApiUrl, + derivedRuntimeApiUrl: runtimeApiUrl, + }); const runtimeApiCandidates = buildRuntimeApiCandidateUrls({ - preferredApiUrl: configuredApiUrl, + preferredApiUrl: presetRuntimeApiUrl || configuredApiUrl, authPublicBaseUrl: config.authPublicBaseUrl ?? null, allowedHostnames: config.allowedHostnames, bindHost: runtimeListenHost, @@ -728,7 +739,7 @@ export async function startServer(): Promise { }); process.env.PAPERCLIP_LISTEN_HOST = runtimeListenHost; process.env.PAPERCLIP_LISTEN_PORT = String(listenPort); - process.env.PAPERCLIP_RUNTIME_API_URL = runtimeApiUrl; + process.env.PAPERCLIP_RUNTIME_API_URL = resolvedRuntimeApiUrl; process.env.PAPERCLIP_RUNTIME_API_CANDIDATES_JSON = JSON.stringify(runtimeApiCandidates); process.env.PAPERCLIP_API_URL = configuredApiUrl; diff --git a/server/src/runtime-api.ts b/server/src/runtime-api.ts index bf4caf36eb73..da19bf1bd4da 100644 --- a/server/src/runtime-api.ts +++ b/server/src/runtime-api.ts @@ -80,6 +80,20 @@ export function choosePrimaryRuntimeApiUrl(input: { return formatOrigin("http:", "localhost", input.port); } +/** + * Resolve the runtime API URL that agents call back on. A pre-set, non-blank + * `PAPERCLIP_RUNTIME_API_URL` wins over the derived value so operators can pin + * internal agent traffic to loopback while the dashboard serves a public host + * (e.g. behind a Cloudflare Tunnel). An unset or whitespace-only pre-set value + * falls back to the derived URL, preserving prior behavior. + */ +export function resolveRuntimeApiUrl(input: { + presetRuntimeApiUrl?: string | null; + derivedRuntimeApiUrl: string; +}): string { + return input.presetRuntimeApiUrl?.trim() || input.derivedRuntimeApiUrl; +} + export function collectReachableInterfaceHosts(input: { networkInterfacesMap?: NodeJS.Dict; } = {}): string[] { diff --git a/skills/paperclip/SKILL.md b/skills/paperclip/SKILL.md index 56e633ccf18c..da7851a16e7c 100644 --- a/skills/paperclip/SKILL.md +++ b/skills/paperclip/SKILL.md @@ -395,7 +395,7 @@ Exact response fields are documented in `skills/paperclip/references/api-referen - **Budget**: auto-paused at 100%. Above 80%, focus on critical tasks only. - **Escalate** via `chainOfCommand` when stuck. Reassign to manager or create a task for them. - **Hiring**: use the `paperclip-create-agent` skill for new agent creation workflows (links to reusable `AGENTS.md` templates like `Coder` and `QA`). -- **Commit Co-author**: if you make a git commit you MUST add EXACTLY `Co-Authored-By: Paperclip ` to the end of each commit message. Do not put in your agent name, put `Co-Authored-By: Paperclip `. +- **Commit footer (FlopBut fork override)**: every git commit MUST end with EXACTLY two `Co-Authored-By:` trailers and nothing else — first your agent identity, then your model. Example: `Co-Authored-By: Prospero ` then `Co-Authored-By: Claude Opus 4.8 `. Do NOT add `Co-Authored-By: Paperclip `; that trailer is prohibited by company governance (decided in FLO-490). This bullet intentionally overrides upstream Paperclip's metrics-attribution trailer — keep it through rebases as a fork-patch. This is rule #1: diff --git a/ui/index.html b/ui/index.html index 6a5781e711da..8c2cbaa6f54a 100644 --- a/ui/index.html +++ b/ui/index.html @@ -15,7 +15,7 @@ - +