diff --git a/Dockerfile b/Dockerfile index e99f9323..ee566109 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,6 +18,8 @@ COPY packages/adapters/codex-local/package.json packages/adapters/codex-local/ COPY packages/adapters/cursor-local/package.json packages/adapters/cursor-local/ COPY packages/adapters/openclaw-gateway/package.json packages/adapters/openclaw-gateway/ COPY packages/adapters/opencode-local/package.json packages/adapters/opencode-local/ +COPY packages/adapters/pi-local/package.json packages/adapters/pi-local/ + RUN pnpm install --frozen-lockfile FROM base AS build @@ -31,7 +33,7 @@ RUN test -f server/dist/index.js || (echo "ERROR: server build output missing" & FROM base AS production WORKDIR /app COPY --from=build /app /app -RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest +RUN npm install --global --omit=dev @anthropic-ai/claude-code@latest @openai/codex@latest opencode-ai ENV NODE_ENV=production \ HOME=/paperclip \ diff --git a/cli/src/client/http.ts b/cli/src/client/http.ts index 863249b7..60be8d2d 100644 --- a/cli/src/client/http.ts +++ b/cli/src/client/http.ts @@ -104,8 +104,10 @@ export class PaperclipApiClient { function buildUrl(apiBase: string, path: string): string { const normalizedPath = path.startsWith("/") ? path : `/${path}`; + const [pathname, query] = normalizedPath.split("?"); const url = new URL(apiBase); - url.pathname = `${url.pathname.replace(/\/+$/, "")}${normalizedPath}`; + url.pathname = `${url.pathname.replace(/\/+$/, "")}${pathname}`; + if (query) url.search = query; return url.toString(); } diff --git a/packages/adapters/claude-local/src/index.ts b/packages/adapters/claude-local/src/index.ts index 481c305d..f8b59bad 100644 --- a/packages/adapters/claude-local/src/index.ts +++ b/packages/adapters/claude-local/src/index.ts @@ -3,6 +3,8 @@ export const label = "Claude Code (local)"; export const models = [ { id: "claude-opus-4-6", label: "Claude Opus 4.6" }, + { id: "claude-sonnet-4-6", label: "Claude Sonnet 4.6" }, + { id: "claude-haiku-4-6", label: "Claude Haiku 4.6" }, { id: "claude-sonnet-4-5-20250929", label: "Claude Sonnet 4.5" }, { id: "claude-haiku-4-5-20251001", label: "Claude Haiku 4.5" }, ]; diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 93edb2a5..8fa979d2 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -2,12 +2,13 @@ import { createHash } from "node:crypto"; import { drizzle as drizzlePg } from "drizzle-orm/postgres-js"; import { migrate as migratePg } from "drizzle-orm/postgres-js/migrator"; import { readFile, readdir } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; import postgres from "postgres"; import * as schema from "./schema/index.js"; -const MIGRATIONS_FOLDER = new URL("./migrations", import.meta.url).pathname; +const MIGRATIONS_FOLDER = fileURLToPath(new URL("./migrations", import.meta.url)); const DRIZZLE_MIGRATIONS_TABLE = "__drizzle_migrations"; -const MIGRATIONS_JOURNAL_JSON = new URL("./migrations/meta/_journal.json", import.meta.url).pathname; +const MIGRATIONS_JOURNAL_JSON = fileURLToPath(new URL("./migrations/meta/_journal.json", import.meta.url)); function isSafeIdentifier(value: string): boolean { return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value); @@ -702,8 +703,7 @@ export async function migratePostgresIfEmpty(url: string): Promise = { + ceo: "CEO", + cto: "CTO", + cmo: "CMO", + cfo: "CFO", + engineer: "Engineer", + designer: "Designer", + pm: "PM", + qa: "QA", + devops: "DevOps", + researcher: "Researcher", + general: "General", +}; + export const AGENT_ICON_NAMES = [ "bot", "cpu", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index a91f8844..1a594cb1 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -6,6 +6,7 @@ export { AGENT_STATUSES, AGENT_ADAPTER_TYPES, AGENT_ROLES, + AGENT_ROLE_LABELS, AGENT_ICON_NAMES, ISSUE_STATUSES, ISSUE_PRIORITIES, diff --git a/scripts/dev-runner.mjs b/scripts/dev-runner.mjs index 71039492..43262fd6 100644 --- a/scripts/dev-runner.mjs +++ b/scripts/dev-runner.mjs @@ -47,7 +47,7 @@ const serverScript = mode === "watch" ? "dev:watch" : "dev"; const child = spawn( pnpmBin, ["--filter", "@paperclipai/server", serverScript, ...forwardedArgs], - { stdio: "inherit", env }, + { stdio: "inherit", env, shell: process.platform === "win32" }, ); child.on("exit", (code, signal) => { diff --git a/server/src/auth/better-auth.ts b/server/src/auth/better-auth.ts index 5c80fad7..d338eeb8 100644 --- a/server/src/auth/better-auth.ts +++ b/server/src/auth/better-auth.ts @@ -70,6 +70,9 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins? const secret = process.env.BETTER_AUTH_SECRET ?? process.env.PAPERCLIP_AGENT_JWT_SECRET ?? "paperclip-dev-secret"; const effectiveTrustedOrigins = trustedOrigins ?? deriveAuthTrustedOrigins(config); + const publicUrl = process.env.PAPERCLIP_PUBLIC_URL ?? baseUrl; + const isHttpOnly = publicUrl ? publicUrl.startsWith("http://") : false; + const authConfig = { baseURL: baseUrl, secret, @@ -86,7 +89,9 @@ export function createBetterAuthInstance(db: Db, config: Config, trustedOrigins? emailAndPassword: { enabled: true, requireEmailVerification: false, + disableSignUp: config.authDisableSignUp, }, + ...(isHttpOnly ? { advanced: { useSecureCookies: false } } : {}), }; if (!baseUrl) { diff --git a/server/src/config.ts b/server/src/config.ts index 5aa0c31e..983eba22 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -37,6 +37,7 @@ export interface Config { allowedHostnames: string[]; authBaseUrlMode: AuthBaseUrlMode; authPublicBaseUrl: string | undefined; + authDisableSignUp: boolean; databaseMode: DatabaseMode; databaseUrl: string | undefined; embeddedPostgresDataDir: string; @@ -142,6 +143,11 @@ export function loadConfig(): Config { authBaseUrlModeFromEnv ?? fileConfig?.auth?.baseUrlMode ?? (authPublicBaseUrl ? "explicit" : "auto"); + const disableSignUpFromEnv = process.env.PAPERCLIP_AUTH_DISABLE_SIGN_UP; + const authDisableSignUp: boolean = + disableSignUpFromEnv !== undefined + ? disableSignUpFromEnv === "true" + : (fileConfig?.auth?.disableSignUp ?? false); const allowedHostnamesFromEnvRaw = process.env.PAPERCLIP_ALLOWED_HOSTNAMES; const allowedHostnamesFromEnv = allowedHostnamesFromEnvRaw ? allowedHostnamesFromEnvRaw @@ -203,6 +209,7 @@ export function loadConfig(): Config { allowedHostnames, authBaseUrlMode, authPublicBaseUrl, + authDisableSignUp, databaseMode: fileDatabaseMode, databaseUrl: process.env.DATABASE_URL ?? fileDbUrl, embeddedPostgresDataDir: resolveHomeAwarePath( diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index a57b63c2..d150bb10 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -245,7 +245,7 @@ export function agentRoutes(db: Db) { adapterConfig: Record, ) { if (adapterType !== "opencode_local") return; - const runtimeConfig = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig); + const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(companyId, adapterConfig); const runtimeEnv = asRecord(runtimeConfig.env) ?? {}; try { await ensureOpenCodeModelConfiguredAndAvailable({ @@ -420,7 +420,7 @@ export function agentRoutes(db: Db) { inputAdapterConfig, { strictMode: strictSecretsMode }, ); - const runtimeAdapterConfig = await secretsSvc.resolveAdapterConfigForRuntime( + const { config: runtimeAdapterConfig } = await secretsSvc.resolveAdapterConfigForRuntime( companyId, normalizedAdapterConfig, ); @@ -1264,7 +1264,7 @@ export function agentRoutes(db: Db) { } const config = asRecord(agent.adapterConfig) ?? {}; - const runtimeConfig = await secretsSvc.resolveAdapterConfigForRuntime(agent.companyId, config); + const { config: runtimeConfig } = await secretsSvc.resolveAdapterConfigForRuntime(agent.companyId, config); const result = await runClaudeLogin({ runId: `claude-login-${randomUUID()}`, agent: { diff --git a/server/src/routes/issues.ts b/server/src/routes/issues.ts index 8d21fe65..e4035dfc 100644 --- a/server/src/routes/issues.ts +++ b/server/src/routes/issues.ts @@ -575,6 +575,10 @@ export function issueRoutes(db: Db, storage: StorageService) { } const assigneeChanged = assigneeWillChange; + const statusChangedFromBacklog = + existing.status === "backlog" && + issue.status !== "backlog" && + req.body.status !== undefined; // Merge all wakeups from this update into one enqueue per agent to avoid duplicate runs. void (async () => { @@ -592,6 +596,18 @@ export function issueRoutes(db: Db, storage: StorageService) { }); } + if (!assigneeChanged && statusChangedFromBacklog && issue.assigneeAgentId) { + wakeups.set(issue.assigneeAgentId, { + source: "automation", + triggerDetail: "system", + reason: "issue_status_changed", + payload: { issueId: issue.id, mutation: "update" }, + requestedByActorType: actor.actorType, + requestedByActorId: actor.actorId, + contextSnapshot: { issueId: issue.id, source: "issue.status_change" }, + }); + } + if (commentBody && comment) { let mentionedIds: string[] = []; try { diff --git a/server/src/services/agents.ts b/server/src/services/agents.ts index 817a9e7b..fa65c7e4 100644 --- a/server/src/services/agents.ts +++ b/server/src/services/agents.ts @@ -341,13 +341,17 @@ export function agentService(db: Db) { await ensureManager(companyId, data.reportsTo); } - await assertCompanyShortnameAvailable(companyId, data.name); + const existingAgents = await db + .select({ id: agents.id, name: agents.name, status: agents.status }) + .from(agents) + .where(eq(agents.companyId, companyId)); + const uniqueName = deduplicateAgentName(data.name, existingAgents); const role = data.role ?? "general"; const normalizedPermissions = normalizeAgentPermissions(data.permissions, role); const created = await db .insert(agents) - .values({ ...data, companyId, role, permissions: normalizedPermissions }) + .values({ ...data, name: uniqueName, companyId, role, permissions: normalizedPermissions }) .returning() .then((rows) => rows[0]); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 0fb575d7..dbba40b2 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1240,11 +1240,16 @@ export function heartbeatService(db: Db) { const mergedConfig = issueAssigneeOverrides?.adapterConfig ? { ...config, ...issueAssigneeOverrides.adapterConfig } : config; - const resolvedConfig = await secretsSvc.resolveAdapterConfigForRuntime( + const { config: resolvedConfig, secretKeys } = await secretsSvc.resolveAdapterConfigForRuntime( agent.companyId, mergedConfig, ); const onAdapterMeta = async (meta: AdapterInvocationMeta) => { + if (meta.env && secretKeys.size > 0) { + for (const key of secretKeys) { + if (key in meta.env) meta.env[key] = "***REDACTED***"; + } + } await appendRunEvent(currentRun, seq++, { eventType: "adapter.invoke", stream: "system", diff --git a/server/src/services/run-log-store.ts b/server/src/services/run-log-store.ts index bbc98391..6429c5f2 100644 --- a/server/src/services/run-log-store.ts +++ b/server/src/services/run-log-store.ts @@ -2,6 +2,7 @@ import { createReadStream, promises as fs } from "node:fs"; import path from "node:path"; import { createHash } from "node:crypto"; import { notFound } from "../errors.js"; +import { resolvePaperclipInstanceRoot } from "../home-paths.js"; export type RunLogStoreType = "local_file"; @@ -148,7 +149,7 @@ let cachedStore: RunLogStore | null = null; export function getRunLogStore() { if (cachedStore) return cachedStore; - const basePath = process.env.RUN_LOG_BASE_PATH ?? path.resolve(process.cwd(), "data/run-logs"); + const basePath = process.env.RUN_LOG_BASE_PATH ?? path.resolve(resolvePaperclipInstanceRoot(), "data", "run-logs"); cachedStore = createLocalFileRunLogStore(basePath); return cachedStore; } diff --git a/server/src/services/secrets.ts b/server/src/services/secrets.ts index 8a3595b4..f18dcb18 100644 --- a/server/src/services/secrets.ts +++ b/server/src/services/secrets.ts @@ -308,10 +308,11 @@ export function secretService(db: Db) { return normalized; }, - resolveEnvBindings: async (companyId: string, envValue: unknown) => { + resolveEnvBindings: async (companyId: string, envValue: unknown): Promise<{ env: Record; secretKeys: Set }> => { const record = asRecord(envValue); - if (!record) return {} as Record; + if (!record) return { env: {} as Record, secretKeys: new Set() }; const resolved: Record = {}; + const secretKeys = new Set(); for (const [key, rawBinding] of Object.entries(record)) { if (!ENV_KEY_RE.test(key)) { @@ -326,20 +327,22 @@ export function secretService(db: Db) { resolved[key] = binding.value; } else { resolved[key] = await resolveSecretValue(companyId, binding.secretId, binding.version); + secretKeys.add(key); } } - return resolved; + return { env: resolved, secretKeys }; }, - resolveAdapterConfigForRuntime: async (companyId: string, adapterConfig: Record) => { + resolveAdapterConfigForRuntime: async (companyId: string, adapterConfig: Record): Promise<{ config: Record; secretKeys: Set }> => { const resolved = { ...adapterConfig }; + const secretKeys = new Set(); if (!Object.prototype.hasOwnProperty.call(adapterConfig, "env")) { - return resolved; + return { config: resolved, secretKeys }; } const record = asRecord(adapterConfig.env); if (!record) { resolved.env = {}; - return resolved; + return { config: resolved, secretKeys }; } const env: Record = {}; for (const [key, rawBinding] of Object.entries(record)) { @@ -355,10 +358,11 @@ export function secretService(db: Db) { env[key] = binding.value; } else { env[key] = await resolveSecretValue(companyId, binding.secretId, binding.version); + secretKeys.add(key); } } resolved.env = env; - return resolved; + return { config: resolved, secretKeys }; }, }; } diff --git a/ui/src/components/AgentConfigForm.tsx b/ui/src/components/AgentConfigForm.tsx index 2ea08954..06cf01d4 100644 --- a/ui/src/components/AgentConfigForm.tsx +++ b/ui/src/components/AgentConfigForm.tsx @@ -441,7 +441,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) { "promptTemplate", String(config.promptTemplate ?? ""), )} - onChange={(v) => mark("adapterConfig", "promptTemplate", v || undefined)} + onChange={(v) => mark("adapterConfig", "promptTemplate", v ?? "")} placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..." contentClassName="min-h-[88px] text-sm font-mono" imageUploadHandler={async (file) => { diff --git a/ui/src/components/AgentProperties.tsx b/ui/src/components/AgentProperties.tsx index 6ff2dfeb..b49b2b10 100644 --- a/ui/src/components/AgentProperties.tsx +++ b/ui/src/components/AgentProperties.tsx @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; import { Link } from "@/lib/router"; -import type { Agent, AgentRuntimeState } from "@paperclipai/shared"; +import { AGENT_ROLE_LABELS, type Agent, type AgentRuntimeState } from "@paperclipai/shared"; import { agentsApi } from "../api/agents"; import { useCompany } from "../context/CompanyContext"; import { queryKeys } from "../lib/queryKeys"; @@ -24,6 +24,8 @@ const adapterLabels: Record = { http: "HTTP", }; +const roleLabels = AGENT_ROLE_LABELS as Record; + function PropertyRow({ label, children }: { label: string; children: React.ReactNode }) { return (
@@ -51,7 +53,7 @@ export function AgentProperties({ agent, runtimeState }: AgentPropertiesProps) { - {agent.role} + {roleLabels[agent.role] ?? agent.role} {agent.title && ( diff --git a/ui/src/components/agent-config-primitives.tsx b/ui/src/components/agent-config-primitives.tsx index 4849d456..8e5c7050 100644 --- a/ui/src/components/agent-config-primitives.tsx +++ b/ui/src/components/agent-config-primitives.tsx @@ -15,6 +15,7 @@ import { import { Button } from "@/components/ui/button"; import { HelpCircle, ChevronDown, ChevronRight } from "lucide-react"; import { cn } from "../lib/utils"; +import { AGENT_ROLE_LABELS } from "@paperclipai/shared"; /* ---- Help text for (?) tooltips ---- */ export const help: Record = { @@ -59,11 +60,7 @@ export const adapterLabels: Record = { http: "HTTP", }; -export const roleLabels: Record = { - ceo: "CEO", cto: "CTO", cmo: "CMO", cfo: "CFO", - engineer: "Engineer", designer: "Designer", pm: "PM", - qa: "QA", devops: "DevOps", researcher: "Researcher", general: "General", -}; +export const roleLabels = AGENT_ROLE_LABELS as Record; /* ---- Primitive components ---- */ diff --git a/ui/src/pages/Agents.tsx b/ui/src/pages/Agents.tsx index 959a1215..5c00444f 100644 --- a/ui/src/pages/Agents.tsx +++ b/ui/src/pages/Agents.tsx @@ -18,7 +18,7 @@ import { PageTabBar } from "../components/PageTabBar"; import { Tabs } from "@/components/ui/tabs"; import { Button } from "@/components/ui/button"; import { Bot, Plus, List, GitBranch, SlidersHorizontal } from "lucide-react"; -import type { Agent } from "@paperclipai/shared"; +import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared"; const adapterLabels: Record = { claude_local: "Claude", @@ -30,11 +30,7 @@ const adapterLabels: Record = { http: "HTTP", }; -const roleLabels: Record = { - ceo: "CEO", cto: "CTO", cmo: "CMO", cfo: "CFO", - engineer: "Engineer", designer: "Designer", pm: "PM", - qa: "QA", devops: "DevOps", researcher: "Researcher", general: "General", -}; +const roleLabels = AGENT_ROLE_LABELS as Record; type FilterTab = "all" | "active" | "paused" | "error"; @@ -230,7 +226,7 @@ export function Agents() { diff --git a/ui/src/pages/OrgChart.tsx b/ui/src/pages/OrgChart.tsx index 00621e69..89b4d581 100644 --- a/ui/src/pages/OrgChart.tsx +++ b/ui/src/pages/OrgChart.tsx @@ -10,7 +10,7 @@ import { EmptyState } from "../components/EmptyState"; import { PageSkeleton } from "../components/PageSkeleton"; import { AgentIcon } from "../components/AgentIconPicker"; import { Network } from "lucide-react"; -import type { Agent } from "@paperclipai/shared"; +import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared"; // Layout constants const CARD_W = 200; @@ -421,11 +421,7 @@ export function OrgChart() { ); } -const roleLabels: Record = { - ceo: "CEO", cto: "CTO", cmo: "CMO", cfo: "CFO", - engineer: "Engineer", designer: "Designer", pm: "PM", - qa: "QA", devops: "DevOps", researcher: "Researcher", general: "General", -}; +const roleLabels = AGENT_ROLE_LABELS as Record; function roleLabel(role: string): string { return roleLabels[role] ?? role;