feat: per-connector schedules (targeted cron pause/resume/delete) - #438
Conversation
…d promise rejection crash Any tool error during agent execution crashed the entire run via unhandled promise rejection in the langchain tool-call transformer. Root cause: when wrapToolCall middleware is present, tool errors get wrapped in MiddlewareError by the composition layer. ToolNode.#handleError then re-throws them as fatal because handleToolErrors !== true. The createToolCallTransformer.rejects output promises that nobody catches, leading to UnhandledPromiseRejection -> process crash. Fix: catch tool execution errors inside the wrapToolCall middleware and convert them to ToolMessages before they reach the composition layer. GraphInterrupt and AbortError are re-thrown as control flow. Also add defensive error handling to addFrontmatterWarning so validation failures don't crash the run. Closes langchain-ai#426
Implement per-connector schedules so openwiki cron pause|resume|delete <source> targets a single connector, not just all. Changes: - Data model: preserve sourceInstances[].schedule during normalization, migrate global ingestionSchedule to per-source instances - Command parser: accept <source|source-instance|all> for cron commands - Schedule operations: rewrite list/pause/resume/delete to operate on individual source instances with IngestionTarget support - Launchd management: per-connector plist files with com.openwiki.ingestion.<source-instance-id> labels - Ingestion scheduling: check per-source schedule.pausedAt instead of global config.ingestionSchedule - Power schedule: iterate all active source schedules for wake window - CLI dispatch: validate source-instance targets, show per-source results - Tests: update existing cron tests, add per-connector schedule tests Closes langchain-ai#399
|
Colin Francis (@colifran) could you please review and approve the PR, Thanks |
There was a problem hiding this comment.
The per-connector schedule feature interpolates an unvalidated sourceInstanceId into filesystem paths; while a path traversal is technically possible via a crafted onboarding.json, exploitation is limited to the local user's own files with no privilege escalation, making this a low-severity hardening gap rather than a critical vulnerability.
| @@ -182,10 +188,13 @@ export async function installConnectorSchedule({ | |||
| ); | |||
There was a problem hiding this comment.
The sourceInstanceId read from ~/.openwiki/onboarding.json reaches two path.join() calls in src/schedules.ts with no sanitization: the log path at line 182 and the plist filename at lines 1070-1074. A crafted ID like ../../.zshrc causes writes to resolve outside the intended directories when openwiki cron resume is run, overwriting arbitrary user-owned files with plist XML or log content.
// 1) Per-instance log path in the generated plist
logPath: path.join(logsDir, `ingestion.${sourceInstanceId}.schedule.log`),// 2) Per-instance launchd plist filename
function getLaunchAgentPathForInstance(instanceId: string): string {
return path.join(
getLaunchAgentsDir(),
`${getLaunchAgentLabelForInstance(instanceId)}.plist`,
);
}Remediation: Validate sourceInstanceId against a strict allowlist (e.g. /^[a-z][a-z0-9-]{0,63}$/) in normalizeOnboardingConfig and at the top of installConnectorSchedule, or perform a path.resolve-and-startsWith containment check on every constructed path before writing.
Attack Path
- Attacker writes crafted
~/.openwiki/onboarding.jsonwith"id": "../../.zshrc". - Victim runs
openwiki cron resume. cli.tsxcallsresumeConnectorSchedules→installConnectorSchedulewith the unsanitized ID.getLaunchAgentPathForInstancebuilds~/Library/LaunchAgents/com.openwiki.ingestion.../../.zshrc.plist→ resolves to~/.zshrc.plist.writeFilewrites plist XML to that path; launchd later appends log output to the traversed log path.
For more details, see the finding in Corridor.
Provide feedback: Reply with whether this is a valid vulnerability or false positive to help improve Corridor's accuracy.
Summary
Implements per-connector schedules so
openwiki cron pause|resume|delete <source>targets a single connector instead of all-or-nothing. Each connector gets its own independent launchd job, schedule state, and pause/resume/delete lifecycle.Closes #399
Motivation
Today OpenWiki keeps a single unified schedule (
config.ingestionSchedule) backed by one OS-level job.openwiki cron pause|resume|deleteis all-or-nothing — you can't pause just one connector.This matters in practice:
There's also an inconsistency:
openwiki ingest <source|all>is per-source (on-demand), so users reasonably expectcronto be too.What Changed
Data Model & Migration (
src/onboarding.ts)sourceInstances[].schedule— the field already existed in types but was always deleted during normalization. Now it's preserved as the source of truth.config.ingestionScheduleexists, it's copied to every source instance that doesn't already have its own schedule. Migration is transparent with no manual steps required.Command Parser (
src/commands.ts)CronTargettype widened fromConnectorId | "all"toIngestionTarget(includes source instance IDs)cron pause|resume|deletenow accept<source|source-instance|all>instead of justallSchedule Operations (
src/schedules.ts)listConnectorSchedules— returns one entry per source instance with its own schedule state, instead of a single "all" entrypauseConnectorSchedules— pauses individual instances by ID, connector type, or allresumeConnectorSchedules— resumes individual instances with per-instance launchd reinstalldeleteConnectorSchedules— removes schedules from individual instances with per-instance plist cleanupresolveScheduleTarget,getLaunchAgentLabelForInstance,getLaunchAgentPathForInstance,unloadLaunchAgentForInstance,removeLaunchAgentPlistForInstance,isLaunchAgentLoadedForInstance,cleanupGlobalPlistScheduleTargetupdated toIngestionTargetto match the CLI type chainLaunchd Management (
src/schedules.ts)com.openwiki.ingestiontocom.openwiki.ingestion.<source-instance-id>createLaunchAgentPlistnow runsingest <connectorId> --scheduledinstead ofingest all --scheduledcom.openwiki.ingestion.pliston migrationIngestion Scheduling (
src/ingestion.ts)resolveIngestionSourceInstancesnow checkssourceConfig.schedule.pausedAtper-source instead of the globalconfig.ingestionSchedule.pausedAtPower Schedule (
src/schedules.ts)getPowerWindowForConfiguredSchedulesiterates allsourceInstances[].scheduleinstead of the single global scheduleCLI Dispatch (
src/cli.tsx)runCronCommandvalidates source-instance targets exist in config before dispatchingformatScheduleMutationResultresolves source instance IDs to display namesresolveSourceInstanceLabelhelper for human-readable outputCredentials (
src/credentials.tsx)sourceInstanceIdtoinstallConnectorScheduleduring onboarding setupAcceptance Criteria
openwiki cron pause web-searchpauses only that connector; others keep runningcron listshows per-connector stateallstill applies to every connectorcom.openwiki.ingestion.<source-instance-id>)Tests
Updated (
test/commands.test.ts)New (
test/schedules.test.ts)New (
test/ingestion-scheduling.test.ts)scheduledOnlyfilteringUpdated (
test/onboarding.test.ts)Migration Path
Existing single-schedule configs are handled automatically:
config.ingestionSchedule(old format)normalizeOnboardingConfigcopies the global schedule to every source instancecom.openwiki.ingestion.plist) is cleaned up on first resume/installFiles Changed
src/schedules.tssrc/commands.tssrc/cli.tsxsrc/onboarding.tssrc/ingestion.tssrc/credentials.tsxtest/schedules.test.tstest/ingestion-scheduling.test.tstest/onboarding.test.tstest/commands.test.ts