fix(task-tool): add task ID validation and improve lock acquisition safety
- Add task ID pattern validation (T-[A-Za-z0-9-]+) to prevent path traversal - Refactor lock mechanism to use UUID-based IDs for reliable ownership tracking - Implement atomic lock creation with stale lock detection and cleanup - Add lock acquisition checks in create/update/delete handlers - Expand task-reminder hook to track split tool names and clean up on session deletion - Add comprehensive test coverage for validation and lock handling
This commit is contained in:
parent
914a480136
commit
134dc7687e
@ -8,6 +8,9 @@
|
|||||||
"$schema": {
|
"$schema": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
|
"new_task_system_enabled": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
"disabled_mcps": {
|
"disabled_mcps": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
@ -83,7 +86,8 @@
|
|||||||
"start-work",
|
"start-work",
|
||||||
"atlas",
|
"atlas",
|
||||||
"unstable-agent-babysitter",
|
"unstable-agent-babysitter",
|
||||||
"stop-continuation-guard"
|
"stop-continuation-guard",
|
||||||
|
"tasks-todowrite-disabler"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -3005,10 +3009,6 @@
|
|||||||
"tasks": {
|
"tasks": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"enabled": {
|
|
||||||
"default": false,
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"storage_path": {
|
"storage_path": {
|
||||||
"default": ".sisyphus/tasks",
|
"default": ".sisyphus/tasks",
|
||||||
"type": "string"
|
"type": "string"
|
||||||
@ -3018,28 +3018,6 @@
|
|||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
|
||||||
"swarm": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"enabled": {
|
|
||||||
"default": false,
|
|
||||||
"type": "boolean"
|
|
||||||
},
|
|
||||||
"storage_path": {
|
|
||||||
"default": ".sisyphus/teams",
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"ui_mode": {
|
|
||||||
"default": "toast",
|
|
||||||
"type": "string",
|
|
||||||
"enum": [
|
|
||||||
"toast",
|
|
||||||
"tmux",
|
|
||||||
"both"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -365,7 +365,7 @@ export const SisyphusConfigSchema = z.object({
|
|||||||
export const OhMyOpenCodeConfigSchema = z.object({
|
export const OhMyOpenCodeConfigSchema = z.object({
|
||||||
$schema: z.string().optional(),
|
$schema: z.string().optional(),
|
||||||
/** Enable new task system (default: false) */
|
/** Enable new task system (default: false) */
|
||||||
new_task_system_enabled: z.boolean().default(false),
|
new_task_system_enabled: z.boolean().optional(),
|
||||||
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
||||||
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
|
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
|
||||||
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
|
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
|
||||||
|
|||||||
@ -40,9 +40,9 @@ interface Task {
|
|||||||
|
|
||||||
## STORAGE UTILITIES
|
## STORAGE UTILITIES
|
||||||
|
|
||||||
### getTaskDir(teamName, config)
|
### getTaskDir(config)
|
||||||
|
|
||||||
Returns: `.sisyphus/tasks/{teamName}` (or custom path from config)
|
Returns: `.sisyphus/tasks` (or custom path from config)
|
||||||
|
|
||||||
### readJsonSafe(filePath, schema)
|
### readJsonSafe(filePath, schema)
|
||||||
|
|
||||||
@ -80,7 +80,7 @@ Returns: `.sisyphus/tasks/{teamName}` (or custom path from config)
|
|||||||
```typescript
|
```typescript
|
||||||
import { TaskSchema, getTaskDir, readJsonSafe, writeJsonAtomic, acquireLock } from "./features/claude-tasks"
|
import { TaskSchema, getTaskDir, readJsonSafe, writeJsonAtomic, acquireLock } from "./features/claude-tasks"
|
||||||
|
|
||||||
const taskDir = getTaskDir("my-team", config)
|
const taskDir = getTaskDir(config)
|
||||||
const lock = acquireLock(taskDir)
|
const lock = acquireLock(taskDir)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -73,15 +73,52 @@ export function listTaskFiles(config: Partial<OhMyOpenCodeConfig> = {}): string[
|
|||||||
|
|
||||||
export function acquireLock(dirPath: string): { acquired: boolean; release: () => void } {
|
export function acquireLock(dirPath: string): { acquired: boolean; release: () => void } {
|
||||||
const lockPath = join(dirPath, ".lock")
|
const lockPath = join(dirPath, ".lock")
|
||||||
const now = Date.now()
|
const lockId = randomUUID()
|
||||||
|
|
||||||
if (existsSync(lockPath)) {
|
const createLock = (timestamp: number) => {
|
||||||
|
writeFileSync(lockPath, JSON.stringify({ id: lockId, timestamp }), {
|
||||||
|
encoding: "utf-8",
|
||||||
|
flag: "wx",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStale = () => {
|
||||||
try {
|
try {
|
||||||
const lockContent = readFileSync(lockPath, "utf-8")
|
const lockContent = readFileSync(lockPath, "utf-8")
|
||||||
const lockData = JSON.parse(lockContent)
|
const lockData = JSON.parse(lockContent)
|
||||||
const lockAge = now - lockData.timestamp
|
const lockAge = Date.now() - lockData.timestamp
|
||||||
|
return lockAge > STALE_LOCK_THRESHOLD_MS
|
||||||
|
} catch {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (lockAge <= STALE_LOCK_THRESHOLD_MS) {
|
const tryAcquire = () => {
|
||||||
|
const now = Date.now()
|
||||||
|
try {
|
||||||
|
createLock(now)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
if (error && typeof error === "object" && "code" in error && error.code === "EEXIST") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDir(dirPath)
|
||||||
|
|
||||||
|
let acquired = tryAcquire()
|
||||||
|
if (!acquired && isStale()) {
|
||||||
|
try {
|
||||||
|
unlinkSync(lockPath)
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
acquired = tryAcquire()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!acquired) {
|
||||||
return {
|
return {
|
||||||
acquired: false,
|
acquired: false,
|
||||||
release: () => {
|
release: () => {
|
||||||
@ -89,21 +126,16 @@ export function acquireLock(dirPath: string): { acquired: boolean; release: () =
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// If lock file is corrupted, treat as stale and override
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureDir(dirPath)
|
|
||||||
writeFileSync(lockPath, JSON.stringify({ timestamp: now }), "utf-8")
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
acquired: true,
|
acquired: true,
|
||||||
release: () => {
|
release: () => {
|
||||||
try {
|
try {
|
||||||
if (existsSync(lockPath)) {
|
if (!existsSync(lockPath)) return
|
||||||
|
const lockContent = readFileSync(lockPath, "utf-8")
|
||||||
|
const lockData = JSON.parse(lockContent)
|
||||||
|
if (lockData.id !== lockId) return
|
||||||
unlinkSync(lockPath)
|
unlinkSync(lockPath)
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore cleanup errors
|
// Ignore cleanup errors
|
||||||
}
|
}
|
||||||
|
|||||||
@ -122,4 +122,29 @@ describe("TaskReminderHook", () => {
|
|||||||
expect(output1.output).toContain("task tools haven't been used")
|
expect(output1.output).toContain("task tools haven't been used")
|
||||||
expect(output2.output).not.toContain("task tools haven't been used")
|
expect(output2.output).not.toContain("task tools haven't been used")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("cleans up counters on session.deleted", async () => {
|
||||||
|
//#given
|
||||||
|
const sessionID = "test-session"
|
||||||
|
const output = { output: "Result" }
|
||||||
|
|
||||||
|
//#when
|
||||||
|
for (let i = 0; i < 10; i++) {
|
||||||
|
await hook["tool.execute.after"]?.(
|
||||||
|
{ tool: "bash", sessionID, callID: `call-${i}` },
|
||||||
|
output
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await hook.event?.({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } })
|
||||||
|
const outputAfterDelete = { output: "Result" }
|
||||||
|
for (let i = 0; i < 9; i++) {
|
||||||
|
await hook["tool.execute.after"]?.(
|
||||||
|
{ tool: "bash", sessionID, callID: `call-after-${i}` },
|
||||||
|
outputAfterDelete
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(outputAfterDelete.output).not.toContain("task tools haven't been used")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,10 +1,17 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
|
|
||||||
const TASK_TOOLS = new Set(["task"])
|
const TASK_TOOLS = new Set([
|
||||||
|
"task",
|
||||||
|
"task_create",
|
||||||
|
"task_list",
|
||||||
|
"task_get",
|
||||||
|
"task_update",
|
||||||
|
"task_delete",
|
||||||
|
])
|
||||||
const TURN_THRESHOLD = 10
|
const TURN_THRESHOLD = 10
|
||||||
const REMINDER_MESSAGE = `
|
const REMINDER_MESSAGE = `
|
||||||
|
|
||||||
The task tools haven't been used recently. If you're working on tasks that would benefit from tracking progress, consider using TaskCreate to add new tasks and TaskUpdate to update task status (set to in_progress when starting, completed when done).`
|
The task tools haven't been used recently. If you're tracking work, use task with action=create/update (or task_create/task_update) to record progress.`
|
||||||
|
|
||||||
interface ToolExecuteInput {
|
interface ToolExecuteInput {
|
||||||
tool: string
|
tool: string
|
||||||
@ -41,5 +48,12 @@ export function createTaskReminderHook(_ctx: PluginInput) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
"tool.execute.after": toolExecuteAfter,
|
"tool.execute.after": toolExecuteAfter,
|
||||||
|
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||||
|
if (event.type !== "session.deleted") return
|
||||||
|
const props = event.properties as { info?: { id?: string } } | undefined
|
||||||
|
const sessionId = props?.info?.id
|
||||||
|
if (!sessionId) return
|
||||||
|
sessionCounters.delete(sessionId)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -113,7 +113,7 @@ export function loadPluginConfig(
|
|||||||
|
|
||||||
// Load user config first (base)
|
// Load user config first (base)
|
||||||
let config: OhMyOpenCodeConfig =
|
let config: OhMyOpenCodeConfig =
|
||||||
loadConfigFromPath(userConfigPath, ctx) ?? { new_task_system_enabled: false };
|
loadConfigFromPath(userConfigPath, ctx) ?? {};
|
||||||
|
|
||||||
// Override with project config
|
// Override with project config
|
||||||
const projectConfig = loadConfigFromPath(projectConfigPath, ctx);
|
const projectConfig = loadConfigFromPath(projectConfigPath, ctx);
|
||||||
@ -121,6 +121,11 @@ export function loadPluginConfig(
|
|||||||
config = mergeConfigs(config, projectConfig);
|
config = mergeConfigs(config, projectConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
config = {
|
||||||
|
...config,
|
||||||
|
new_task_system_enabled: config.new_task_system_enabled ?? false,
|
||||||
|
};
|
||||||
|
|
||||||
log("Final merged config", {
|
log("Final merged config", {
|
||||||
agents: config.agents,
|
agents: config.agents,
|
||||||
disabled_agents: config.disabled_agents,
|
disabled_agents: config.disabled_agents,
|
||||||
|
|||||||
@ -351,6 +351,22 @@ describe("task_tool", () => {
|
|||||||
expect(result.task).toBeNull()
|
expect(result.task).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("rejects invalid task id", async () => {
|
||||||
|
//#given
|
||||||
|
const args = {
|
||||||
|
action: "get" as const,
|
||||||
|
id: "../package",
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||||
|
const result = JSON.parse(resultStr)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toHaveProperty("error")
|
||||||
|
expect(result.error).toBe("invalid_task_id")
|
||||||
|
})
|
||||||
|
|
||||||
test("returns result as JSON string with task property", async () => {
|
test("returns result as JSON string with task property", async () => {
|
||||||
//#given
|
//#given
|
||||||
const testId = await createTestTask("Test task")
|
const testId = await createTestTask("Test task")
|
||||||
@ -480,6 +496,41 @@ describe("task_tool", () => {
|
|||||||
expect(result.error).toBe("task_not_found")
|
expect(result.error).toBe("task_not_found")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("rejects invalid task id", async () => {
|
||||||
|
//#given
|
||||||
|
const args = {
|
||||||
|
action: "update" as const,
|
||||||
|
id: "../package",
|
||||||
|
title: "New title",
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||||
|
const result = JSON.parse(resultStr)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toHaveProperty("error")
|
||||||
|
expect(result.error).toBe("invalid_task_id")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns lock unavailable when lock is held", async () => {
|
||||||
|
//#given
|
||||||
|
writeFileSync(join(TEST_DIR, ".lock"), JSON.stringify({ id: "test", timestamp: Date.now() }))
|
||||||
|
const args = {
|
||||||
|
action: "update" as const,
|
||||||
|
id: "T-nonexistent",
|
||||||
|
title: "New title",
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||||
|
const result = JSON.parse(resultStr)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toHaveProperty("error")
|
||||||
|
expect(result.error).toBe("task_lock_unavailable")
|
||||||
|
})
|
||||||
|
|
||||||
test("returns result as JSON string with task property", async () => {
|
test("returns result as JSON string with task property", async () => {
|
||||||
//#given
|
//#given
|
||||||
const testId = await createTestTask("Test task")
|
const testId = await createTestTask("Test task")
|
||||||
@ -574,6 +625,22 @@ describe("task_tool", () => {
|
|||||||
expect(result.error).toBe("task_not_found")
|
expect(result.error).toBe("task_not_found")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("rejects invalid task id", async () => {
|
||||||
|
//#given
|
||||||
|
const args = {
|
||||||
|
action: "delete" as const,
|
||||||
|
id: "../package",
|
||||||
|
}
|
||||||
|
|
||||||
|
//#when
|
||||||
|
const resultStr = await taskTool.execute(args, TEST_CONTEXT)
|
||||||
|
const result = JSON.parse(resultStr)
|
||||||
|
|
||||||
|
//#then
|
||||||
|
expect(result).toHaveProperty("error")
|
||||||
|
expect(result.error).toBe("invalid_task_id")
|
||||||
|
})
|
||||||
|
|
||||||
test("returns result as JSON string", async () => {
|
test("returns result as JSON string", async () => {
|
||||||
//#given
|
//#given
|
||||||
const testId = await createTestTask("Test task")
|
const testId = await createTestTask("Test task")
|
||||||
|
|||||||
@ -27,6 +27,13 @@ import {
|
|||||||
listTaskFiles,
|
listTaskFiles,
|
||||||
} from "../../features/claude-tasks/storage"
|
} from "../../features/claude-tasks/storage"
|
||||||
|
|
||||||
|
const TASK_ID_PATTERN = /^T-[A-Za-z0-9-]+$/
|
||||||
|
|
||||||
|
function parseTaskId(id: string): string | null {
|
||||||
|
if (!TASK_ID_PATTERN.test(id)) return null
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
export function createTask(config: Partial<OhMyOpenCodeConfig>): ToolDefinition {
|
export function createTask(config: Partial<OhMyOpenCodeConfig>): ToolDefinition {
|
||||||
return tool({
|
return tool({
|
||||||
description: `Unified task management tool with create, list, get, update, delete actions.
|
description: `Unified task management tool with create, list, get, update, delete actions.
|
||||||
@ -88,6 +95,10 @@ async function handleCreate(
|
|||||||
const taskDir = getTaskDir(config)
|
const taskDir = getTaskDir(config)
|
||||||
const lock = acquireLock(taskDir)
|
const lock = acquireLock(taskDir)
|
||||||
|
|
||||||
|
if (!lock.acquired) {
|
||||||
|
return JSON.stringify({ error: "task_lock_unavailable" })
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const taskId = generateTaskId()
|
const taskId = generateTaskId()
|
||||||
const task: TaskObject = {
|
const task: TaskObject = {
|
||||||
@ -176,8 +187,12 @@ async function handleGet(
|
|||||||
config: Partial<OhMyOpenCodeConfig>
|
config: Partial<OhMyOpenCodeConfig>
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const validatedArgs = TaskGetInputSchema.parse(args)
|
const validatedArgs = TaskGetInputSchema.parse(args)
|
||||||
|
const taskId = parseTaskId(validatedArgs.id)
|
||||||
|
if (!taskId) {
|
||||||
|
return JSON.stringify({ error: "invalid_task_id" })
|
||||||
|
}
|
||||||
const taskDir = getTaskDir(config)
|
const taskDir = getTaskDir(config)
|
||||||
const taskPath = join(taskDir, `${validatedArgs.id}.json`)
|
const taskPath = join(taskDir, `${taskId}.json`)
|
||||||
|
|
||||||
const task = readJsonSafe(taskPath, TaskObjectSchema)
|
const task = readJsonSafe(taskPath, TaskObjectSchema)
|
||||||
|
|
||||||
@ -189,11 +204,19 @@ async function handleUpdate(
|
|||||||
config: Partial<OhMyOpenCodeConfig>
|
config: Partial<OhMyOpenCodeConfig>
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const validatedArgs = TaskUpdateInputSchema.parse(args)
|
const validatedArgs = TaskUpdateInputSchema.parse(args)
|
||||||
|
const taskId = parseTaskId(validatedArgs.id)
|
||||||
|
if (!taskId) {
|
||||||
|
return JSON.stringify({ error: "invalid_task_id" })
|
||||||
|
}
|
||||||
const taskDir = getTaskDir(config)
|
const taskDir = getTaskDir(config)
|
||||||
const lock = acquireLock(taskDir)
|
const lock = acquireLock(taskDir)
|
||||||
|
|
||||||
|
if (!lock.acquired) {
|
||||||
|
return JSON.stringify({ error: "task_lock_unavailable" })
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const taskPath = join(taskDir, `${validatedArgs.id}.json`)
|
const taskPath = join(taskDir, `${taskId}.json`)
|
||||||
const task = readJsonSafe(taskPath, TaskObjectSchema)
|
const task = readJsonSafe(taskPath, TaskObjectSchema)
|
||||||
|
|
||||||
if (!task) {
|
if (!task) {
|
||||||
@ -234,11 +257,19 @@ async function handleDelete(
|
|||||||
config: Partial<OhMyOpenCodeConfig>
|
config: Partial<OhMyOpenCodeConfig>
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const validatedArgs = TaskDeleteInputSchema.parse(args)
|
const validatedArgs = TaskDeleteInputSchema.parse(args)
|
||||||
|
const taskId = parseTaskId(validatedArgs.id)
|
||||||
|
if (!taskId) {
|
||||||
|
return JSON.stringify({ error: "invalid_task_id" })
|
||||||
|
}
|
||||||
const taskDir = getTaskDir(config)
|
const taskDir = getTaskDir(config)
|
||||||
const lock = acquireLock(taskDir)
|
const lock = acquireLock(taskDir)
|
||||||
|
|
||||||
|
if (!lock.acquired) {
|
||||||
|
return JSON.stringify({ error: "task_lock_unavailable" })
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const taskPath = join(taskDir, `${validatedArgs.id}.json`)
|
const taskPath = join(taskDir, `${taskId}.json`)
|
||||||
|
|
||||||
if (!existsSync(taskPath)) {
|
if (!existsSync(taskPath)) {
|
||||||
return JSON.stringify({ error: "task_not_found" })
|
return JSON.stringify({ error: "task_not_found" })
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user