Skip to content

tasks: add detached runtime plugin registration contract#68915

Merged
mbelinky merged 5 commits into
mainfrom
mariano/detached-runtime-contract
Apr 19, 2026
Merged

tasks: add detached runtime plugin registration contract#68915
mbelinky merged 5 commits into
mainfrom
mariano/detached-runtime-contract

Conversation

@mbelinky

@mbelinky mbelinky commented Apr 19, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Problem: PR1 added the detached-task lifecycle seam, but there was still no real core-owned way for an external executor to register itself against that seam.
  • Why it matters: without an explicit registration contract, detached execution still has to reach into task internals ad hoc, which blocks pushing durable executor mechanics out of core.
  • What changed: this PR adds detached runtime registration state, exposes plugin registration for that runtime, routes detached cancellation through the registered runtime, and preserves loader cache/rollback semantics around that registration.
  • What did NOT change: this does not add a durable queue, retry engine, crash recovery, stall detection, or any minions-style executor implementation inside core.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

User-visible / Behavior Changes

None.

Diagram (if applicable)

Before:
[detached caller] -> [core seam] -> [legacy core executor]
[plugin runtime] -> [no real ownership contract]

After:
[detached caller] -> [core seam] -> [registered runtime if present | legacy core executor fallback]
[plugin loader/registry] -> [register detached runtime] -> [owned lifecycle + cancel dispatch]

Security Impact (required)

  • New permissions/capabilities? (Yes/No) No
  • Secrets/tokens handling changed? (Yes/No) No
  • New/changed network calls? (Yes/No) No
  • Command/tool execution surface changed? (Yes/No) No
  • Data access scope changed? (Yes/No) No
  • If any Yes, explain risk + mitigation:

Human Verification (required)

What I personally verified:

  • detached runtime registration through the plugin API
  • detached runtime lifecycle dispatch through the seam
  • detached cancel and TaskFlow child cancel through the same runtime contract
  • duplicate runtime registration rejection
  • runtime ownership rollback/preservation across cache hits, non-activating loads, register failures, and active reload clearing
  • fallback to core cancel when a registered runtime declines ownership

What I did not verify:

  • no durable external executor implementation
  • no retry/recovery/stall semantics
  • no end-to-end plugin shipping flow beyond core registration + contract behavior

Authoritative validation on mb-server, rerun after rebasing onto current main:

  • OPENCLAW_TEST_PROFILE=serial OPENCLAW_TEST_SERIAL_GATEWAY=1 pnpm test -- src/tasks/task-executor.test.ts src/plugins/loader.test.ts src/plugins/runtime/runtime-tasks.test.ts src/tasks/detached-task-runtime.test.ts
  • pnpm tsgo:core
  • pnpm tsgo:core:test

Known unrelated mb-server baseline noise, not used as the authoritative gate for this narrow PR:

  • pnpm tsgo:all still fails in existing extension lanes on current main
  • pnpm build still hits the known runtime-postbuild bundled dependency staging issue on mb-server

Risks and Mitigations

  • Risk: runtime ownership could become ambiguous if more than one plugin tries to register a detached runtime.
    • Mitigation: registry rejects later registrations and records a plugin diagnostic instead of silently replacing the active runtime.
  • Risk: loader rollback/cache behavior could leak stale runtime ownership across plugin load attempts.
    • Mitigation: loader snapshots and restores detached runtime registration during cache hits, non-activating loads, register failures, and active reload clearing.
  • Risk: cancel behavior could drift from lifecycle dispatch if cancel bypasses the seam.
    • Mitigation: detached cancel and TaskFlow-driven child-task cancel now route through the same registered runtime contract, with explicit fallback only when the runtime declines ownership.

Compatibility / Migration

  • Backward compatible? (Yes/No) Yes
  • Config/env changes? (Yes/No) No
  • Migration needed? (Yes/No) No
  • If yes, exact upgrade steps:

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

@openclaw-barnacle openclaw-barnacle Bot added commands Command implementations size: L maintainer Maintainer-authored PR labels Apr 19, 2026
@mbelinky
mbelinky force-pushed the mariano/detached-runtime-contract branch from 6e5b5d8 to 888f9f4 Compare April 19, 2026 10:41
@mbelinky
mbelinky marked this pull request as ready for review April 19, 2026 10:43
@greptile-apps

greptile-apps Bot commented Apr 19, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds the explicit plugin registration contract for detached task runtimes: a new state module (detached-task-runtime-state.ts) holds the singleton registration, a dispatch layer (detached-task-runtime.ts) routes lifecycle calls through the registered runtime with core fallbacks, and cancelDetachedTaskRunById in task-executor.ts is the new external-facing cancel entry point that consults the registered runtime before falling back to core. The loader correctly snapshots/restores the registration across cache hits, non-activating loads, register failures, and active-reload clearing. Two minor observations are noted inline: same-plugin silent re-registration in registry.ts, and the registered runtime being bypassed in cancelDetachedTaskRunById when the task is not found in the core registry.

Confidence Score: 5/5

Safe to merge; all remaining findings are P2 style/design suggestions with no blocking correctness issues.

The implementation is well-structured with clean separation between state, dispatch, and routing layers. Loader rollback/restore coverage is thorough across all four identified paths. Both open observations are P2 and do not affect correctness under the stated design invariants.

src/plugins/registry.ts (same-plugin re-registration), src/tasks/task-executor.ts (early-return in cancelDetachedTaskRunById)

Prompt To Fix All With AI
This is a comment left during a code review.
Path: src/plugins/registry.ts
Line: 1176-1188

Comment:
**Same-plugin silent re-registration**

The guard `existing.pluginId !== record.id` allows the same plugin to call `registerDetachedTaskRuntime` multiple times, silently replacing the prior runtime without any diagnostic. If a plugin's activation path calls this twice (e.g., due to a misconfigured re-entrant load), the second registration overwrites the first with no warning — the same protection offered to *other* plugins is absent for the registering plugin itself. Consider emitting a warning-level diagnostic or making the second call a no-op when the plugin id already matches.

How can I resolve this? If you propose a fix, please make it concise.

---

This is a comment left during a code review.
Path: src/tasks/task-executor.ts
Line: 711-724

Comment:
**Registered runtime bypassed for unknown tasks**

When `getTaskById` returns `undefined` the function immediately falls back to `cancelTaskById`, never consulting the registered runtime. If a task were ever created through the registered runtime before it appeared in the core registry (or after it was removed from it), the registered runtime's cancel logic would be skipped entirely. The fallback is correct by current design invariants, but the early-return bypasses the runtime contract the rest of the function enforces.

```typescript
// current
if (!task) {
  return cancelTaskById(params);
}
// would be more consistent with the contract:
if (!task) {
  const registeredRuntime = getRegisteredDetachedTaskLifecycleRuntime();
  if (registeredRuntime) {
    const result = await registeredRuntime.cancelDetachedTaskRunById(params);
    if (result.found) return result;
  }
  return cancelTaskById(params);
}
```
This is a design-level call — fine to leave as-is if the invariant "task in registry ⟺ runtime owns it" holds firmly, but worth a comment in the code.

How can I resolve this? If you propose a fix, please make it concise.

Reviews (1): Last reviewed commit: "tasks: harden detached runtime ownership" | Re-trigger Greptile

Comment thread src/plugins/registry.ts
Comment on lines +1176 to +1188
registerDetachedTaskRuntime: (runtime) => {
const existing = getDetachedTaskLifecycleRuntimeRegistration();
if (existing && existing.pluginId !== record.id) {
pushDiagnostic({
level: "error",
pluginId: record.id,
source: record.source,
message: `detached task runtime already registered by ${existing.pluginId}`,
});
return;
}
registerDetachedTaskLifecycleRuntime(record.id, runtime);
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Same-plugin silent re-registration

The guard existing.pluginId !== record.id allows the same plugin to call registerDetachedTaskRuntime multiple times, silently replacing the prior runtime without any diagnostic. If a plugin's activation path calls this twice (e.g., due to a misconfigured re-entrant load), the second registration overwrites the first with no warning — the same protection offered to other plugins is absent for the registering plugin itself. Consider emitting a warning-level diagnostic or making the second call a no-op when the plugin id already matches.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/plugins/registry.ts
Line: 1176-1188

Comment:
**Same-plugin silent re-registration**

The guard `existing.pluginId !== record.id` allows the same plugin to call `registerDetachedTaskRuntime` multiple times, silently replacing the prior runtime without any diagnostic. If a plugin's activation path calls this twice (e.g., due to a misconfigured re-entrant load), the second registration overwrites the first with no warning — the same protection offered to *other* plugins is absent for the registering plugin itself. Consider emitting a warning-level diagnostic or making the second call a no-op when the plugin id already matches.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 711 to 724
export async function cancelDetachedTaskRunById(params: { cfg: OpenClawConfig; taskId: string }) {
const task = getTaskById(params.taskId);
if (!task) {
return cancelTaskById(params);
}
const registeredRuntime = getRegisteredDetachedTaskLifecycleRuntime();
if (registeredRuntime) {
const cancelled = await registeredRuntime.cancelDetachedTaskRunById(params);
if (cancelled.found) {
return cancelled;
}
}
return cancelTaskById(params);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Registered runtime bypassed for unknown tasks

When getTaskById returns undefined the function immediately falls back to cancelTaskById, never consulting the registered runtime. If a task were ever created through the registered runtime before it appeared in the core registry (or after it was removed from it), the registered runtime's cancel logic would be skipped entirely. The fallback is correct by current design invariants, but the early-return bypasses the runtime contract the rest of the function enforces.

// current
if (!task) {
  return cancelTaskById(params);
}
// would be more consistent with the contract:
if (!task) {
  const registeredRuntime = getRegisteredDetachedTaskLifecycleRuntime();
  if (registeredRuntime) {
    const result = await registeredRuntime.cancelDetachedTaskRunById(params);
    if (result.found) return result;
  }
  return cancelTaskById(params);
}

This is a design-level call — fine to leave as-is if the invariant "task in registry ⟺ runtime owns it" holds firmly, but worth a comment in the code.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/tasks/task-executor.ts
Line: 711-724

Comment:
**Registered runtime bypassed for unknown tasks**

When `getTaskById` returns `undefined` the function immediately falls back to `cancelTaskById`, never consulting the registered runtime. If a task were ever created through the registered runtime before it appeared in the core registry (or after it was removed from it), the registered runtime's cancel logic would be skipped entirely. The fallback is correct by current design invariants, but the early-return bypasses the runtime contract the rest of the function enforces.

```typescript
// current
if (!task) {
  return cancelTaskById(params);
}
// would be more consistent with the contract:
if (!task) {
  const registeredRuntime = getRegisteredDetachedTaskLifecycleRuntime();
  if (registeredRuntime) {
    const result = await registeredRuntime.cancelDetachedTaskRunById(params);
    if (result.found) return result;
  }
  return cancelTaskById(params);
}
```
This is a design-level call — fine to leave as-is if the invariant "task in registry ⟺ runtime owns it" holds firmly, but worth a comment in the code.

How can I resolve this? If you propose a fix, please make it concise.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 888f9f44a0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +718 to +719
const cancelled = await registeredRuntime.cancelDetachedTaskRunById(params);
if (cancelled.found) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle runtime cancel exceptions before returning

When a registered detached runtime throws inside cancelDetachedTaskRunById (for example due to a transient RPC failure), this function currently propagates the exception instead of returning the structured cancel result used by the rest of the task APIs. That means callers like task/flow cancellation can fail hard and skip both the legacy fallback and normal reason reporting, leaving cancellation in a partially applied state. Wrap the runtime call in a try/catch and return a non-throwing result (or fallback) to preserve existing cancellation semantics.

Useful? React with 👍 / 👎.

@aisle-research-bot

aisle-research-bot Bot commented Apr 19, 2026

Copy link
Copy Markdown

🔒 Aisle Security Analysis

We found 2 potential security issue(s) in this PR:

# Severity Title
1 🟠 High Detached task lifecycle runtime can be overwritten via internal state registration (plugin isolation bypass)
2 🟡 Medium Untrusted detached-task runtime can return arbitrary TaskRecord in cancel response (data leakage)
1. 🟠 Detached task lifecycle runtime can be overwritten via internal state registration (plugin isolation bypass)
Property Value
Severity High
CWE CWE-284
Location src/tasks/detached-task-runtime-state.ts:10-18

Description

The new global detached task runtime state allows unguarded overwrites.

  • createPluginRegistry().api.registerDetachedTaskRuntime() enforces exclusivity by checking the existing registration and refusing if another plugin already registered.
  • However, registerDetachedTaskLifecycleRuntime() in src/tasks/detached-task-runtime-state.ts unconditionally overwrites the global registration.
  • Any code that can import/call this internal function (or registerDetachedTaskRuntime() in src/tasks/detached-task-runtime.ts, which forwards to it) can replace the detached task runtime even when another plugin already registered it.

Impact (if third-party plugins can access these internal modules at runtime): a malicious or compromised plugin could hijack detached task lifecycle operations (create/start/complete/fail/cancel), enabling task cancellation or manipulation across plugins.

Vulnerable code:

export function registerDetachedTaskLifecycleRuntime(pluginId: string, runtime: DetachedTaskLifecycleRuntime): void {
  detachedTaskLifecycleRuntimeRegistration = { pluginId, runtime };
}

Recommendation

Enforce exclusivity at the state layer as the single source of truth, so callers cannot bypass checks.

Example:

export function registerDetachedTaskLifecycleRuntime(
  pluginId: string,
  runtime: DetachedTaskLifecycleRuntime,
): void {
  const existing = detachedTaskLifecycleRuntimeRegistration;
  if (existing && existing.pluginId !== pluginId) {
    throw new Error(`detached task runtime already registered by ${existing.pluginId}`);
  }
  detachedTaskLifecycleRuntimeRegistration = { pluginId, runtime };
}

Additionally:

  • Avoid exporting internal registration functions on any public surface.
  • If plugins run in-process, consider hardening the plugin execution environment to prevent importing/using non-SDK internal modules (or treat plugins as fully trusted).
2. 🟡 Untrusted detached-task runtime can return arbitrary TaskRecord in cancel response (data leakage)
Property Value
Severity Medium
CWE CWE-200
Location src/tasks/task-executor.ts:711-721

Description

cancelDetachedTaskRunById delegates task cancellation to a registered (plugin-provided) detached task lifecycle runtime and returns its result directly when found is true.

Because DetachedTaskCancelResult allows task?: TaskRecord, an untrusted/buggy runtime can:

  • Return a different task record than the one requested (or a record belonging to another owner)
  • Cause higher layers (e.g., plugin runtime mapCancelledTaskResult in src/plugins/runtime/runtime-tasks.ts) to map and surface that record to callers
  • Potentially leak sensitive fields from TaskRecord (task content, ownerKey, error strings, summaries, etc.) across tenants/owners

Vulnerable flow:

  • input: taskId passed to cancelDetachedTaskRunById (ultimately user-controlled via APIs/CLI)
  • trust boundary: registeredRuntime.cancelDetachedTaskRunById is plugin-supplied
  • sink: returned cancelled.task is forwarded to callers without validation

Vulnerable code:

const cancelled = await registeredRuntime.cancelDetachedTaskRunById(params);
if (cancelled.found) {
  return cancelled;
}

Recommendation

Do not trust task (or other mutable fields) coming back from a plugin runtime.

Recommended mitigations (apply one or more):

  1. Validate the returned task record matches the requested task:

    • cancelled.task.taskId === params.taskId
    • and (if applicable) cancelled.task.ownerKey === expectedOwnerKey (requires passing/deriving owner context)
  2. Prefer to ignore cancelled.task from the runtime and instead re-load the task from core storage after cancellation:

const cancelled = await registeredRuntime.cancelDetachedTaskRunById(params);
if (cancelled.found) {
  const task = getTaskById(params.taskId);
  return {
    ...cancelled,
    task: task ?? undefined,
  };
}
  1. If this API must be exposed to plugins/users, change the contract to return a minimal representation (e.g., {taskId, status}) rather than a full TaskRecord.

Analyzed PR: #68915 at commit 68717e7

Last updated on: 2026-04-19T11:21:37Z

@mbelinky
mbelinky force-pushed the mariano/detached-runtime-contract branch 2 times, most recently from 3c942b2 to 3bcd35f Compare April 19, 2026 11:09
@mbelinky
mbelinky force-pushed the mariano/detached-runtime-contract branch from 3bcd35f to 68717e7 Compare April 19, 2026 11:12
@mbelinky
mbelinky merged commit bd3ad34 into main Apr 19, 2026
30 checks passed
@mbelinky
mbelinky deleted the mariano/detached-runtime-contract branch April 19, 2026 11:13

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 68717e7317

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +717 to +719
if (registeredRuntime) {
const cancelled = await registeredRuntime.cancelDetachedTaskRunById(params);
if (cancelled.found) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Handle runtime cancel exceptions in detached cancel path

cancelDetachedTaskRunById directly awaits registeredRuntime.cancelDetachedTaskRunById(...) without a guard, so any runtime-side exception (for example transient RPC/network failures) will throw out of this API instead of returning the structured cancel result that callers expect. Since this function now fronts flow and task cancellation paths, one thrown runtime error can abort cancellation mid-operation and skip both legacy fallback and reason reporting.

Useful? React with 👍 / 👎.

Mquarmoc pushed a commit to Mquarmoc/openclaw that referenced this pull request Apr 20, 2026
)

* tasks: register detached runtime plugins

* tasks: harden detached runtime ownership

* tasks: extract detached runtime contract types

* changelog: note detached runtime contract

* changelog: attribute detached runtime contract
lovewanwan pushed a commit to lovewanwan/openclaw that referenced this pull request Apr 28, 2026
)

* tasks: register detached runtime plugins

* tasks: harden detached runtime ownership

* tasks: extract detached runtime contract types

* changelog: note detached runtime contract

* changelog: attribute detached runtime contract
ogt-redknie pushed a commit to ogt-redknie/OPENX that referenced this pull request May 2, 2026
)

* tasks: register detached runtime plugins

* tasks: harden detached runtime ownership

* tasks: extract detached runtime contract types

* changelog: note detached runtime contract

* changelog: attribute detached runtime contract
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 9, 2026
)

* tasks: register detached runtime plugins

* tasks: harden detached runtime ownership

* tasks: extract detached runtime contract types

* changelog: note detached runtime contract

* changelog: attribute detached runtime contract
globalcaos pushed a commit to globalcaos/tinkerclaw that referenced this pull request May 13, 2026
)

* tasks: register detached runtime plugins

* tasks: harden detached runtime ownership

* tasks: extract detached runtime contract types

* changelog: note detached runtime contract

* changelog: attribute detached runtime contract
github-actions Bot pushed a commit to Desicool/openclaw that referenced this pull request May 24, 2026
)

* tasks: register detached runtime plugins

* tasks: harden detached runtime ownership

* tasks: extract detached runtime contract types

* changelog: note detached runtime contract

* changelog: attribute detached runtime contract
jameslcowan pushed a commit to jameslcowan/openclaw that referenced this pull request Jun 2, 2026
)

* tasks: register detached runtime plugins

* tasks: harden detached runtime ownership

* tasks: extract detached runtime contract types

* changelog: note detached runtime contract

* changelog: attribute detached runtime contract
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

commands Command implementations maintainer Maintainer-authored PR size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant