mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-19 20:44:16 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Agent runs need credentials for a specific provider and sign-in method. > - Connections already owns accounts, grants, and access permissions. > - AI authentication should use those same boundaries. > - This pull request adds the storage, API, adoption, and runtime foundation. > - Legacy agents keep their authentication until they explicitly adopt a managed connection. ## Linked Issues or Issue Description **Problem or motivation** AI credentials are configured separately from Connections. Agents cannot consistently reuse a responsible user's account or a permitted shared account. **Proposed solution** Manage AI accounts with the existing Connections grants and permissions. Keep model and harness selection independent from credential selection. Preserve legacy authentication until validated adoption. **Alternatives considered** A separate credential registry would duplicate ownership and access policy. Automatic fallback would risk using the wrong account. **Roadmap alignment** This extends the shipped Apps, multi-user, secrets, and agent-runtime capabilities. The maintainer requested the feature and reviewed the UI. Related groundwork: #11899 (connection permissions), #10910 (connection wizard), #11692 (Claude subscription profiles), and #11854 (Codex account rotation). ## What Changed - Add AI-purpose/runtime-auth contracts and an additive, idempotent migration. - Add Claude, OpenAI, OpenRouter, and Grok provider capabilities and catalog entries. - Store credentials on grants. Resolve responsible-user defaults or explicit permitted grants. - Isolate managed credentials and provider sessions across accounts. Block missing credentials without ambient fallback. - Keep imported legacy secrets unchanged during reconnect. Use independent local Codex/Grok sign-in attempts for rotating credentials. - Add authorization, migration, concurrent refresh, retry, cancellation, and legacy-compatibility tests. This is part 1 of a two-PR stack. The app UI follows in #13248. Merge the foundation first. ## Verification - Updated against master `04e364236`, preserving upstream provider login and connector workflows. - Full workspace typecheck, production build, Storybook build, and token gates passed on the integrated branch. Final local-login changes passed 59 focused tests; new-agent and inbox regression suites passed 63 tests. - Browser checks verified automatic local Claude account detection, resumable Codex login commands, retry, focus restoration, and desktop/phone layouts. Commands create their isolated directory before invoking the CLI. - All current-head CI checks passed on `2a996560a`, including all server/workspace tests, browser shards, runner verification, typecheck, build, and canary dry run. Greptile reviewed that commit at 5/5 with no unresolved threads. Earlier local full-suite attempts hit the Mac PostgreSQL shared-memory limit; the complete suites passed in CI. - Renumbered the additive AI migration to `0276` after upstream migrations and regenerated its snapshot. Existing legacy agents retain their configuration. - Added local login status checks, owner-scoped retry, managed OpenCode remote homes, credential-aware model discovery, and task connection-repair delivery. ## Risks - Managed credential failures intentionally block execution. They do not restore legacy fallback. - Preview-era copied Codex/Grok subscriptions require independent reconnect. - The integrated branch has live provider acceptance coverage. This update verifies local Claude detection and Codex API-key task repair; it does not add a new subscription authorization/refresh or Daytona stress pass. - Runtime-auth connections must stay excluded from tool and channel handling. ## Model Used OpenAI GPT-6 through Codex, with reasoning, repository tools, code execution, and browser testing. The exact runtime model identifier and context-window size are not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
402 lines
14 KiB
TypeScript
402 lines
14 KiB
TypeScript
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const {
|
|
runChildProcess,
|
|
ensureCommandResolvable,
|
|
resolveCommandForLogs,
|
|
prepareWorkspaceForSshExecution,
|
|
restoreWorkspaceFromSshExecution,
|
|
runSshCommand,
|
|
syncDirectoryToSsh,
|
|
startAdapterExecutionTargetPaperclipBridge,
|
|
} = vi.hoisted(() => ({
|
|
runChildProcess: vi.fn(async (_runId: string, _command: string, args: string[]) => {
|
|
if (args.includes("models")) {
|
|
return {
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: "opencode/gpt-5-nano\nopenai/gpt-4.1\n",
|
|
stderr: "",
|
|
pid: 122,
|
|
startedAt: new Date().toISOString(),
|
|
};
|
|
}
|
|
return {
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: [
|
|
JSON.stringify({ type: "step_start", sessionID: "session_123" }),
|
|
JSON.stringify({ type: "text", sessionID: "session_123", part: { text: "hello" } }),
|
|
JSON.stringify({
|
|
type: "step_finish",
|
|
sessionID: "session_123",
|
|
part: { cost: 0.001, tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } } },
|
|
}),
|
|
].join("\n"),
|
|
stderr: "",
|
|
pid: 123,
|
|
startedAt: new Date().toISOString(),
|
|
};
|
|
}),
|
|
ensureCommandResolvable: vi.fn(async () => undefined),
|
|
resolveCommandForLogs: vi.fn(async () => "ssh://fixture@127.0.0.1:2222/remote/workspace :: opencode"),
|
|
prepareWorkspaceForSshExecution: vi.fn(async () => ({ gitBacked: false })),
|
|
restoreWorkspaceFromSshExecution: vi.fn(async () => undefined),
|
|
runSshCommand: vi.fn(async () => ({
|
|
stdout: "/home/agent",
|
|
stderr: "",
|
|
exitCode: 0,
|
|
})),
|
|
syncDirectoryToSsh: vi.fn(async () => undefined),
|
|
startAdapterExecutionTargetPaperclipBridge: vi.fn(async () => ({
|
|
env: {
|
|
PAPERCLIP_API_URL: "http://127.0.0.1:4310",
|
|
PAPERCLIP_API_KEY: "bridge-token",
|
|
PAPERCLIP_API_BRIDGE_MODE: "queue_v1",
|
|
},
|
|
stop: async () => {},
|
|
})),
|
|
}));
|
|
|
|
vi.mock("@paperclipai/adapter-utils/server-utils", async () => {
|
|
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/server-utils")>(
|
|
"@paperclipai/adapter-utils/server-utils",
|
|
);
|
|
return {
|
|
...actual,
|
|
ensureCommandResolvable,
|
|
resolveCommandForLogs,
|
|
runChildProcess,
|
|
};
|
|
});
|
|
|
|
vi.mock("@paperclipai/adapter-utils/ssh", async () => {
|
|
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/ssh")>(
|
|
"@paperclipai/adapter-utils/ssh",
|
|
);
|
|
return {
|
|
...actual,
|
|
prepareWorkspaceForSshExecution,
|
|
restoreWorkspaceFromSshExecution,
|
|
runSshCommand,
|
|
syncDirectoryToSsh,
|
|
};
|
|
});
|
|
|
|
vi.mock("@paperclipai/adapter-utils/execution-target", async () => {
|
|
const actual = await vi.importActual<typeof import("@paperclipai/adapter-utils/execution-target")>(
|
|
"@paperclipai/adapter-utils/execution-target",
|
|
);
|
|
return {
|
|
...actual,
|
|
startAdapterExecutionTargetPaperclipBridge,
|
|
};
|
|
});
|
|
|
|
import { execute } from "./execute.js";
|
|
|
|
describe("opencode remote execution", () => {
|
|
const cleanupDirs: string[] = [];
|
|
const originalOpenCodeAllowAllModels = process.env.OPENCODE_ALLOW_ALL_MODELS;
|
|
|
|
beforeEach(async () => {
|
|
const configHome = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-test-config-"));
|
|
cleanupDirs.push(configHome);
|
|
vi.stubEnv("XDG_CONFIG_HOME", configHome);
|
|
delete process.env.OPENCODE_ALLOW_ALL_MODELS;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
vi.clearAllMocks();
|
|
vi.unstubAllEnvs();
|
|
if (originalOpenCodeAllowAllModels === undefined) {
|
|
delete process.env.OPENCODE_ALLOW_ALL_MODELS;
|
|
} else {
|
|
process.env.OPENCODE_ALLOW_ALL_MODELS = originalOpenCodeAllowAllModels;
|
|
}
|
|
while (cleanupDirs.length > 0) {
|
|
const dir = cleanupDirs.pop();
|
|
if (!dir) continue;
|
|
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
}
|
|
});
|
|
|
|
it.each([false, true])("prepares the workspace, syncs OpenCode skills, and restores workspace changes for remote SSH execution (managed=%s)", async (managed) => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-remote-"));
|
|
cleanupDirs.push(rootDir);
|
|
const workspaceDir = path.join(rootDir, "workspace");
|
|
const alternateWorkspaceDir = path.join(rootDir, "workspace-other");
|
|
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-1/workspace";
|
|
await mkdir(workspaceDir, { recursive: true });
|
|
await mkdir(alternateWorkspaceDir, { recursive: true });
|
|
|
|
const result = await execute({
|
|
runId: "run-1",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "OpenCode Builder",
|
|
adapterType: "opencode_local",
|
|
adapterConfig: {},
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
command: "opencode",
|
|
model: "opencode/gpt-5-nano",
|
|
...(managed ? {
|
|
managedAiConnection: { provider: "openrouter", method: "api_key" },
|
|
} : {}),
|
|
env: {
|
|
XDG_CONFIG_HOME: path.join(rootDir, "config"),
|
|
...(managed ? { HOME: "/var/folders/qa-managed", XDG_DATA_HOME: "/var/folders/qa-managed/data" } : {}),
|
|
},
|
|
},
|
|
context: {
|
|
paperclipWorkspace: {
|
|
cwd: workspaceDir,
|
|
source: "project_primary",
|
|
},
|
|
paperclipWorkspaces: [
|
|
{
|
|
workspaceId: "workspace-1",
|
|
cwd: workspaceDir,
|
|
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
|
repoRef: "main",
|
|
},
|
|
{
|
|
workspaceId: "workspace-2",
|
|
cwd: alternateWorkspaceDir,
|
|
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
|
repoRef: "feature/other",
|
|
},
|
|
],
|
|
},
|
|
executionTransport: {
|
|
remoteExecution: {
|
|
host: "127.0.0.1",
|
|
port: 2222,
|
|
username: "fixture",
|
|
remoteWorkspacePath: "/remote/workspace",
|
|
remoteCwd: "/remote/workspace",
|
|
privateKey: "PRIVATE KEY",
|
|
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
|
|
strictHostKeyChecking: true,
|
|
},
|
|
},
|
|
onLog: async () => {},
|
|
});
|
|
|
|
expect(result.sessionParams).toMatchObject({
|
|
sessionId: "session_123",
|
|
cwd: managedRemoteWorkspace,
|
|
remoteExecution: {
|
|
transport: "ssh",
|
|
host: "127.0.0.1",
|
|
port: 2222,
|
|
username: "fixture",
|
|
remoteCwd: managedRemoteWorkspace,
|
|
},
|
|
});
|
|
expect(prepareWorkspaceForSshExecution).toHaveBeenCalledTimes(1);
|
|
expect(syncDirectoryToSsh).toHaveBeenCalledTimes(2);
|
|
expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({
|
|
remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/opencode/xdgConfig`,
|
|
}));
|
|
expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({
|
|
remoteDir: `${managedRemoteWorkspace}/.paperclip-runtime/opencode/skills`,
|
|
followSymlinks: true,
|
|
}));
|
|
expect(runSshCommand).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.stringContaining(".claude/skills"),
|
|
expect.anything(),
|
|
);
|
|
const runCall = runChildProcess.mock.calls.find((entry) => Array.isArray(entry[2]) && entry[2].includes("run")) as
|
|
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
|
|
| undefined;
|
|
const modelProbeCall = runChildProcess.mock.calls.find((entry) => Array.isArray(entry[2]) && entry[2].includes("models")) as
|
|
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
|
|
| undefined;
|
|
expect(modelProbeCall?.[2]).toEqual(["models"]);
|
|
// The model probe runs after the runtime workspace is prepared (so XDG
|
|
// points at the managed subdirectory) but the SSH session targets the
|
|
// original target remoteCwd — the per-run subdirectory is layered
|
|
// underneath via XDG/runtime config rather than by switching the cwd.
|
|
expect(modelProbeCall?.[3].env.XDG_CONFIG_HOME).toBe(
|
|
`${managedRemoteWorkspace}/.paperclip-runtime/opencode/xdgConfig`,
|
|
);
|
|
expect(modelProbeCall?.[3].remoteExecution?.remoteCwd).toBe("/remote/workspace");
|
|
const call = runCall as
|
|
| [string, string, string[], { env: Record<string, string>; remoteExecution?: { remoteCwd: string } | null }]
|
|
| undefined;
|
|
expect(call?.[3].env.PAPERCLIP_WORKSPACE_CWD).toBe(managedRemoteWorkspace);
|
|
if (managed) {
|
|
const home = `${managedRemoteWorkspace}/.paperclip-runtime/opencode/managed-auth/run-1`;
|
|
expect(call?.[3].env.HOME).toBe(home);
|
|
expect(call?.[3].env.XDG_DATA_HOME).toBe(`${home}/data`);
|
|
expect(modelProbeCall?.[3].env.XDG_DATA_HOME).toBe(`${home}/data`);
|
|
expect(runSshCommand).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.stringContaining(`${home}/.claude/skills`),
|
|
expect.anything(),
|
|
);
|
|
}
|
|
expect(JSON.parse(call?.[3].env.PAPERCLIP_WORKSPACES_JSON ?? "[]")).toEqual([
|
|
{
|
|
workspaceId: "workspace-1",
|
|
cwd: managedRemoteWorkspace,
|
|
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
|
repoRef: "main",
|
|
},
|
|
{
|
|
workspaceId: "workspace-2",
|
|
repoUrl: "https://github.com/paperclipai/paperclip.git",
|
|
repoRef: "feature/other",
|
|
},
|
|
]);
|
|
expect(call?.[3].env.PAPERCLIP_API_URL).toBe("http://127.0.0.1:4310");
|
|
expect(call?.[3].env.PAPERCLIP_API_BRIDGE_MODE).toBe("queue_v1");
|
|
expect(call?.[3].env.XDG_CONFIG_HOME).toBe(`${managedRemoteWorkspace}/.paperclip-runtime/opencode/xdgConfig`);
|
|
expect(call?.[3].remoteExecution?.remoteCwd).toBe(managedRemoteWorkspace);
|
|
expect(startAdapterExecutionTargetPaperclipBridge).toHaveBeenCalledTimes(1);
|
|
expect(restoreWorkspaceFromSshExecution).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("fails before the remote run when the configured model is unavailable on the SSH target", async () => {
|
|
runChildProcess.mockImplementationOnce(async () => ({
|
|
exitCode: 0,
|
|
signal: null,
|
|
timedOut: false,
|
|
stdout: "openai/gpt-4.1\n",
|
|
stderr: "",
|
|
pid: 456,
|
|
startedAt: new Date().toISOString(),
|
|
}));
|
|
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-remote-model-"));
|
|
cleanupDirs.push(rootDir);
|
|
const workspaceDir = path.join(rootDir, "workspace");
|
|
await mkdir(workspaceDir, { recursive: true });
|
|
|
|
await expect(() =>
|
|
execute({
|
|
runId: "run-ssh-model-missing",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "OpenCode Builder",
|
|
adapterType: "opencode_local",
|
|
adapterConfig: {},
|
|
},
|
|
runtime: {
|
|
sessionId: null,
|
|
sessionParams: null,
|
|
sessionDisplayId: null,
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
command: "opencode",
|
|
model: "opencode/gpt-5-nano",
|
|
},
|
|
context: {
|
|
paperclipWorkspace: {
|
|
cwd: workspaceDir,
|
|
source: "project_primary",
|
|
},
|
|
},
|
|
executionTransport: {
|
|
remoteExecution: {
|
|
host: "127.0.0.1",
|
|
port: 2222,
|
|
username: "fixture",
|
|
remoteWorkspacePath: "/remote/workspace",
|
|
remoteCwd: "/remote/workspace",
|
|
privateKey: "PRIVATE KEY",
|
|
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
|
|
strictHostKeyChecking: true,
|
|
},
|
|
},
|
|
onLog: async () => {},
|
|
}),
|
|
).rejects.toThrow("Configured OpenCode model is unavailable on the remote execution target");
|
|
|
|
expect(runChildProcess).toHaveBeenCalledTimes(1);
|
|
expect((runChildProcess.mock.calls[0]?.[2] as string[] | undefined) ?? []).toEqual(["models"]);
|
|
expect(startAdapterExecutionTargetPaperclipBridge).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("resumes saved OpenCode sessions for remote SSH execution only when the identity matches", async () => {
|
|
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-opencode-remote-resume-"));
|
|
cleanupDirs.push(rootDir);
|
|
const workspaceDir = path.join(rootDir, "workspace");
|
|
const managedRemoteWorkspace = "/remote/workspace/.paperclip-runtime/runs/run-ssh-resume/workspace";
|
|
await mkdir(workspaceDir, { recursive: true });
|
|
|
|
await execute({
|
|
runId: "run-ssh-resume",
|
|
agent: {
|
|
id: "agent-1",
|
|
companyId: "company-1",
|
|
name: "OpenCode Builder",
|
|
adapterType: "opencode_local",
|
|
adapterConfig: {},
|
|
},
|
|
runtime: {
|
|
sessionId: "session-123",
|
|
sessionParams: {
|
|
sessionId: "session-123",
|
|
cwd: managedRemoteWorkspace,
|
|
remoteExecution: {
|
|
transport: "ssh",
|
|
host: "127.0.0.1",
|
|
port: 2222,
|
|
username: "fixture",
|
|
remoteCwd: managedRemoteWorkspace,
|
|
},
|
|
},
|
|
sessionDisplayId: "session-123",
|
|
taskKey: null,
|
|
},
|
|
config: {
|
|
command: "opencode",
|
|
model: "opencode/gpt-5-nano",
|
|
},
|
|
context: {
|
|
paperclipWorkspace: {
|
|
cwd: workspaceDir,
|
|
source: "project_primary",
|
|
},
|
|
},
|
|
executionTransport: {
|
|
remoteExecution: {
|
|
host: "127.0.0.1",
|
|
port: 2222,
|
|
username: "fixture",
|
|
remoteWorkspacePath: "/remote/workspace",
|
|
remoteCwd: "/remote/workspace",
|
|
privateKey: "PRIVATE KEY",
|
|
knownHosts: "[127.0.0.1]:2222 ssh-ed25519 AAAA",
|
|
strictHostKeyChecking: true,
|
|
},
|
|
},
|
|
onLog: async () => {},
|
|
});
|
|
|
|
const call = runChildProcess.mock.calls.find((entry) => Array.isArray(entry[2]) && entry[2].includes("run")) as
|
|
| [string, string, string[]]
|
|
| undefined;
|
|
expect(call?.[2]).toContain("--session");
|
|
expect(call?.[2]).toContain("session-123");
|
|
});
|
|
});
|