Merge remote-tracking branch 'public-gh/master'

* public-gh/master:
  fix: disable secure cookies for HTTP deployments
  feat(adapters): add claude-sonnet-4-6 and claude-haiku-4-6 models
  Add opencode-ai to global npm install in Dockerfile
  fix: correct env var priority for authDisableSignUp
  Add pi-local package.json to Dockerfile
  feat: add auth.disableSignUp config option
  refactor: extract roleLabels to shared constants
  fix(secrets): add secretKeys tracking to resolveEnvBindings for consistent redaction
  fix(db): reuse MIGRATIONS_FOLDER constant instead of recomputing
  fix(server): wake agent when issue status changes from backlog
  fix(server): use home-based path for run logs instead of cwd
  fix(db): use fileURLToPath for Windows-safe migration paths
  fix(server): auto-deduplicate agent names on creation instead of rejecting
  feat(ui): show human-readable role labels in agent list and properties
  fix(ui): prevent blank screen when prompt template is emptied
  fix(server): redact secret-sourced env vars in run logs by provenance
  fix(cli): split path and query in buildUrl to prevent %3F encoding
  fix(scripts): use shell on Windows to fix spawn EINVAL in dev-runner
This commit is contained in:
Dotta
2026-03-09 07:29:34 -05:00
21 changed files with 98 additions and 42 deletions

View File

@@ -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) {

View File

@@ -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(

View File

@@ -245,7 +245,7 @@ export function agentRoutes(db: Db) {
adapterConfig: Record<string, unknown>,
) {
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: {

View File

@@ -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 {

View File

@@ -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]);

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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<string, string>; secretKeys: Set<string> }> => {
const record = asRecord(envValue);
if (!record) return {} as Record<string, string>;
if (!record) return { env: {} as Record<string, string>, secretKeys: new Set<string>() };
const resolved: Record<string, string> = {};
const secretKeys = new Set<string>();
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<string, unknown>) => {
resolveAdapterConfigForRuntime: async (companyId: string, adapterConfig: Record<string, unknown>): Promise<{ config: Record<string, unknown>; secretKeys: Set<string> }> => {
const resolved = { ...adapterConfig };
const secretKeys = new Set<string>();
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<string, string> = {};
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 };
},
};
}