Merge pull request #2636 from code-yeongyu/fix/pre-publish-blockers

fix: resolve 12 pre-publish blockers (security, correctness, migration)
This commit is contained in:
YeonGyu-Kim
2026-03-17 16:36:04 +09:00
committed by GitHub
36 changed files with 1271 additions and 159 deletions
@@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundTask, LaunchInput } from "./types"
export const TASK_TTL_MS = 30 * 60 * 1000
export const TERMINAL_TASK_TTL_MS = 30 * 60 * 1000
export const MIN_STABILITY_TIME_MS = 10 * 1000
export const DEFAULT_STALE_TIMEOUT_MS = 180_000
export const DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS = 1_800_000
+25 -10
View File
@@ -27,6 +27,7 @@ import {
import {
POLLING_INTERVAL_MS,
TASK_CLEANUP_DELAY_MS,
TASK_TTL_MS,
} from "./constants"
import { subagentSessions } from "../claude-code-session-state"
@@ -100,6 +101,8 @@ export interface SubagentSessionCreatedEvent {
export type OnSubagentSessionCreated = (event: SubagentSessionCreatedEvent) => Promise<void>
const MAX_TASK_REMOVAL_RESCHEDULES = 6
export class BackgroundManager {
@@ -1203,7 +1206,7 @@ export class BackgroundManager {
this.completedTaskSummaries.delete(parentSessionID)
}
private scheduleTaskRemoval(taskId: string): void {
private scheduleTaskRemoval(taskId: string, rescheduleCount = 0): void {
const existingTimer = this.completionTimers.get(taskId)
if (existingTimer) {
clearTimeout(existingTimer)
@@ -1213,17 +1216,29 @@ export class BackgroundManager {
const timer = setTimeout(() => {
this.completionTimers.delete(taskId)
const task = this.tasks.get(taskId)
if (task) {
this.clearNotificationsForTask(taskId)
this.tasks.delete(taskId)
this.clearTaskHistoryWhenParentTasksGone(task.parentSessionID)
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
SessionCategoryRegistry.remove(task.sessionID)
if (!task) return
if (task.parentSessionID) {
const siblings = this.getTasksByParentSession(task.parentSessionID)
const runningOrPendingSiblings = siblings.filter(
sibling => sibling.id !== taskId && (sibling.status === "running" || sibling.status === "pending"),
)
const completedAtTimestamp = task.completedAt?.getTime()
const reachedTaskTtl = completedAtTimestamp !== undefined && (Date.now() - completedAtTimestamp) >= TASK_TTL_MS
if (runningOrPendingSiblings.length > 0 && rescheduleCount < MAX_TASK_REMOVAL_RESCHEDULES && !reachedTaskTtl) {
this.scheduleTaskRemoval(taskId, rescheduleCount + 1)
return
}
log("[background-agent] Removed completed task from memory:", taskId)
this.clearTaskHistoryWhenParentTasksGone(task?.parentSessionID)
}
this.clearNotificationsForTask(taskId)
this.tasks.delete(taskId)
this.clearTaskHistoryWhenParentTasksGone(task.parentSessionID)
if (task.sessionID) {
subagentSessions.delete(task.sessionID)
SessionCategoryRegistry.remove(task.sessionID)
}
log("[background-agent] Removed completed task from memory:", taskId)
}, TASK_CLEANUP_DELAY_MS)
this.completionTimers.set(taskId, timer)
@@ -1,6 +1,5 @@
declare const require: (name: string) => any
const { describe, test, expect, afterEach } = require("bun:test")
import { tmpdir } from "node:os"
import { afterEach, describe, expect, test } from "bun:test"
import type { PluginInput } from "@opencode-ai/plugin"
import { TASK_CLEANUP_DELAY_MS } from "./constants"
import { BackgroundManager } from "./manager"
@@ -157,17 +156,19 @@ function getRequiredTimer(manager: BackgroundManager, taskID: string): ReturnTyp
}
describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
describe("#given 2 tasks for same parent and task A completed", () => {
test("#when task B is still running #then task A is cleaned up from this.tasks after delay even though task B is not done", async () => {
describe("#given 3 tasks for same parent and task A completed first", () => {
test("#when siblings are still running or pending #then task A remains until siblings also complete", async () => {
// given
const { manager } = createManager(false)
managerUnderTest = manager
fakeTimers = installFakeTimers()
const taskA = createTask({ id: "task-a", parentSessionID: "parent-1", description: "task A", status: "completed", completedAt: new Date("2026-03-11T00:01:00.000Z") })
const taskA = createTask({ id: "task-a", parentSessionID: "parent-1", description: "task A", status: "completed", completedAt: new Date() })
const taskB = createTask({ id: "task-b", parentSessionID: "parent-1", description: "task B", status: "running" })
const taskC = createTask({ id: "task-c", parentSessionID: "parent-1", description: "task C", status: "pending" })
getTasks(manager).set(taskA.id, taskA)
getTasks(manager).set(taskB.id, taskB)
getPendingByParent(manager).set(taskA.parentSessionID, new Set([taskA.id, taskB.id]))
getTasks(manager).set(taskC.id, taskC)
getPendingByParent(manager).set(taskA.parentSessionID, new Set([taskA.id, taskB.id, taskC.id]))
// when
await notifyParentSessionForTest(manager, taskA)
@@ -177,8 +178,23 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => {
// then
expect(fakeTimers.getDelay(taskATimer)).toBeUndefined()
expect(getTasks(manager).has(taskA.id)).toBe(false)
expect(getTasks(manager).has(taskA.id)).toBe(true)
expect(getTasks(manager).get(taskB.id)).toBe(taskB)
expect(getTasks(manager).get(taskC.id)).toBe(taskC)
// when
taskB.status = "completed"
taskB.completedAt = new Date()
taskC.status = "completed"
taskC.completedAt = new Date()
await notifyParentSessionForTest(manager, taskB)
await notifyParentSessionForTest(manager, taskC)
const rescheduledTaskATimer = getRequiredTimer(manager, taskA.id)
expect(fakeTimers.getDelay(rescheduledTaskATimer)).toBe(TASK_CLEANUP_DELAY_MS)
fakeTimers.run(rescheduledTaskATimer)
// then
expect(getTasks(manager).has(taskA.id)).toBe(false)
})
})
+1 -2
View File
@@ -9,12 +9,11 @@ import {
DEFAULT_MESSAGE_STALENESS_TIMEOUT_MS,
DEFAULT_STALE_TIMEOUT_MS,
MIN_RUNTIME_BEFORE_STALE_MS,
TERMINAL_TASK_TTL_MS,
TASK_TTL_MS,
} from "./constants"
import { removeTaskToastTracking } from "./remove-task-toast-tracking"
const TERMINAL_TASK_TTL_MS = 30 * 60 * 1000
const TERMINAL_TASK_STATUSES = new Set<BackgroundTask["status"]>([
"completed",
"error",
+3
View File
@@ -59,10 +59,13 @@ export function appendSessionId(directory: string, sessionId: string): BoulderSt
if (!Array.isArray(state.session_ids)) {
state.session_ids = []
}
const originalSessionIds = [...state.session_ids]
state.session_ids.push(sessionId)
if (writeBoulderState(directory, state)) {
return state
}
state.session_ids = originalSessionIds
return null
}
return state
@@ -153,3 +153,25 @@ describe("#given git_env_prefix with commit footer", () => {
})
})
})
describe("#given idempotency of prefixGitCommandsInBashCodeBlocks", () => {
describe("#when git_env_prefix is provided and template already has prefixed commands in env prefix section", () => {
it("#then does NOT double-prefix the already-prefixed commands", () => {
const result = injectGitMasterConfig(SAMPLE_TEMPLATE, {
commit_footer: false,
include_co_authored_by: false,
git_env_prefix: "GIT_MASTER=1",
})
expect(result).not.toContain("GIT_MASTER=1 GIT_MASTER=1 git status")
expect(result).not.toContain("GIT_MASTER=1 GIT_MASTER=1 git add")
expect(result).not.toContain("GIT_MASTER=1 GIT_MASTER=1 git commit")
expect(result).not.toContain("GIT_MASTER=1 GIT_MASTER=1 git push")
expect(result).toContain("GIT_MASTER=1 git status")
expect(result).toContain("GIT_MASTER=1 git add")
expect(result).toContain("GIT_MASTER=1 git commit")
expect(result).toContain("GIT_MASTER=1 git push")
})
})
})
@@ -72,8 +72,16 @@ function prefixGitCommandsInBashCodeBlocks(template: string, prefix: string): st
function prefixGitCommandsInCodeBlock(codeBlock: string, prefix: string): string {
return codeBlock
.replace(LEADING_GIT_COMMAND_PATTERN, `$1${prefix} git`)
.replace(INLINE_GIT_COMMAND_PATTERN, `$1${prefix} git`)
.split("\n")
.map((line) => {
if (line.includes(prefix)) {
return line
}
return line
.replace(LEADING_GIT_COMMAND_PATTERN, `$1${prefix} git`)
.replace(INLINE_GIT_COMMAND_PATTERN, `$1${prefix} git`)
})
.join("\n")
}
function buildCommitFooterInjection(
@@ -199,3 +199,236 @@ describe("EXCLUDED_ENV_PATTERNS", () => {
}
})
})
describe("secret env var filtering", () => {
it("filters out ANTHROPIC_API_KEY", () => {
// given
process.env.ANTHROPIC_API_KEY = "sk-ant-api03-secret"
process.env.PATH = "/usr/bin"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.ANTHROPIC_API_KEY).toBeUndefined()
expect(cleanEnv.PATH).toBe("/usr/bin")
})
it("filters out AWS_SECRET_ACCESS_KEY", () => {
// given
process.env.AWS_SECRET_ACCESS_KEY = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
process.env.AWS_ACCESS_KEY_ID = "AKIAIOSFODNN7EXAMPLE"
process.env.HOME = "/home/user"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.AWS_SECRET_ACCESS_KEY).toBeUndefined()
expect(cleanEnv.AWS_ACCESS_KEY_ID).toBeUndefined()
expect(cleanEnv.HOME).toBe("/home/user")
})
it("filters out GITHUB_TOKEN", () => {
// given
process.env.GITHUB_TOKEN = "ghp_secrettoken123456789"
process.env.GITHUB_API_TOKEN = "another_secret_token"
process.env.SHELL = "/bin/bash"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.GITHUB_TOKEN).toBeUndefined()
expect(cleanEnv.GITHUB_API_TOKEN).toBeUndefined()
expect(cleanEnv.SHELL).toBe("/bin/bash")
})
it("filters out OPENAI_API_KEY", () => {
// given
process.env.OPENAI_API_KEY = "sk-secret123456789"
process.env.LANG = "en_US.UTF-8"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.OPENAI_API_KEY).toBeUndefined()
expect(cleanEnv.LANG).toBe("en_US.UTF-8")
})
it("filters out DATABASE_URL with credentials", () => {
// given
process.env.DATABASE_URL = "postgresql://user:password@localhost:5432/db"
process.env.DB_PASSWORD = "supersecretpassword"
process.env.TERM = "xterm-256color"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.DATABASE_URL).toBeUndefined()
expect(cleanEnv.DB_PASSWORD).toBeUndefined()
expect(cleanEnv.TERM).toBe("xterm-256color")
})
})
describe("suffix-based secret filtering", () => {
it("filters variables ending with _KEY", () => {
// given
process.env.MY_API_KEY = "secret-value"
process.env.SOME_KEY = "another-secret"
process.env.TMPDIR = "/tmp"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.MY_API_KEY).toBeUndefined()
expect(cleanEnv.SOME_KEY).toBeUndefined()
expect(cleanEnv.TMPDIR).toBe("/tmp")
})
it("filters variables ending with _SECRET", () => {
// given
process.env.AWS_SECRET = "secret-value"
process.env.JWT_SECRET = "jwt-secret-token"
process.env.USER = "testuser"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.AWS_SECRET).toBeUndefined()
expect(cleanEnv.JWT_SECRET).toBeUndefined()
expect(cleanEnv.USER).toBe("testuser")
})
it("filters variables ending with _TOKEN", () => {
// given
process.env.ACCESS_TOKEN = "token-value"
process.env.BEARER_TOKEN = "bearer-token"
process.env.HOME = "/home/user"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.ACCESS_TOKEN).toBeUndefined()
expect(cleanEnv.BEARER_TOKEN).toBeUndefined()
expect(cleanEnv.HOME).toBe("/home/user")
})
it("filters variables ending with _PASSWORD", () => {
// given
process.env.DB_PASSWORD = "db-password"
process.env.APP_PASSWORD = "app-secret"
process.env.NODE_ENV = "production"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.DB_PASSWORD).toBeUndefined()
expect(cleanEnv.APP_PASSWORD).toBeUndefined()
expect(cleanEnv.NODE_ENV).toBe("production")
})
it("filters variables ending with _CREDENTIAL", () => {
// given
process.env.GCP_CREDENTIAL = "json-credential"
process.env.AZURE_CREDENTIAL = "azure-creds"
process.env.PWD = "/current/dir"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.GCP_CREDENTIAL).toBeUndefined()
expect(cleanEnv.AZURE_CREDENTIAL).toBeUndefined()
expect(cleanEnv.PWD).toBe("/current/dir")
})
it("filters variables ending with _API_KEY", () => {
// given
// given
process.env.STRIPE_API_KEY = "sk_live_secret"
process.env.SENDGRID_API_KEY = "SG.secret"
process.env.SHELL = "/bin/zsh"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.STRIPE_API_KEY).toBeUndefined()
expect(cleanEnv.SENDGRID_API_KEY).toBeUndefined()
expect(cleanEnv.SHELL).toBe("/bin/zsh")
})
})
describe("safe environment variables preserved", () => {
it("preserves PATH", () => {
// given
process.env.PATH = "/usr/bin:/usr/local/bin"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.PATH).toBe("/usr/bin:/usr/local/bin")
})
it("preserves HOME", () => {
// given
process.env.HOME = "/home/testuser"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.HOME).toBe("/home/testuser")
})
it("preserves SHELL", () => {
// given
process.env.SHELL = "/bin/bash"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.SHELL).toBe("/bin/bash")
})
it("preserves LANG", () => {
// given
process.env.LANG = "en_US.UTF-8"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.LANG).toBe("en_US.UTF-8")
})
it("preserves TERM", () => {
// given
process.env.TERM = "xterm-256color"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.TERM).toBe("xterm-256color")
})
it("preserves TMPDIR", () => {
// given
process.env.TMPDIR = "/tmp"
// when
const cleanEnv = createCleanMcpEnvironment()
// then
expect(cleanEnv.TMPDIR).toBe("/tmp")
})
})
@@ -1,10 +1,28 @@
// Filters npm/pnpm/yarn config env vars that break MCP servers in pnpm projects (#456)
// Also filters secret-containing env vars to prevent exposure to malicious stdio MCP servers (#B-02)
export const EXCLUDED_ENV_PATTERNS: RegExp[] = [
// npm/pnpm/yarn config patterns (original)
/^NPM_CONFIG_/i,
/^npm_config_/,
/^YARN_/,
/^PNPM_/,
/^NO_UPDATE_NOTIFIER$/,
// Specific high-risk secret env vars (explicit blocks)
/^ANTHROPIC_API_KEY$/i,
/^AWS_ACCESS_KEY_ID$/i,
/^AWS_SECRET_ACCESS_KEY$/i,
/^GITHUB_TOKEN$/i,
/^DATABASE_URL$/i,
/^OPENAI_API_KEY$/i,
// Suffix-based patterns for common secret naming conventions
/_KEY$/i,
/_SECRET$/i,
/_TOKEN$/i,
/_PASSWORD$/i,
/_CREDENTIAL$/i,
/_API_KEY$/i,
]
export function createCleanMcpEnvironment(